While there is a good amount of documentation on how to work with control-maps and device configurations, there has been little documentation on how to write your own application from the ground up.
To adress this need, here is the first in a series of exercises, gradually becoming more complex with each step.
Part 1: Creating a simple Duplex application
This is a basic demonstration of how to write an application, and the techniques involved in getting proper bi-directional communication going with the Duplex framework.
For the sake of simplicity, weâll be creating a simple application with just a few lines of code. We want to be able to control the metronome in Renoise, and we want the state of the Metronome to be reflected on the controller at all times.
Although you could write an application that doesnât care what Renoise is doing, itâs simply more intuitive, and feel more ârobustâ to see a button reflect the correct state at all times - this is called bi-directional communication, and part of the reason why Duplex was born (as well as making a script across a wide range of devices, of course).
Letâs start with a bit of background info, and take a look at how an application appear on a controller in the first place.
1. How applications are tied in with the Duplex framework
Weâve got a special folder in Duplex called âApplicationsâ. This is where all the applications are situated: Mixer.lua, Effect.lua, etc.
Any .lua files in this folder are automatically included on each startup, but you canât just write an application, put it in this folder and expect your device to magically start using it - you need to create a configuration for it as well.
So letâs start with that.
Under normal circumstances, all of Duplexâ configurations are located inside the device classes. For the sake of this example, weâll use the imaginary âR-Controlâ device.
Opening the R-Control class, weâll see that it has existing configuration entries. We simply want to add our new one at the bottom, so we add the following:
duplex_configurations:insert {
-- configuration properties
name = "Metronome",
pinned = true,
-- device properties
device = {
display_name = "R-control",
device_port_in = "USB R-control",
device_port_out = "USB R-control",
control_map = "Controllers/R-control/R-control.xml",
thumbnail = "R-control.bmp",
protocol = DEVICE_MIDI_PROTOCOL
},
applications = {
Metronome = {
mappings = {
toggle = {
group_name = "Switches",
index = 1,
}
}
},
}
}
Actually, this is just a copy-paste of the previous entry (the device, class name etc.), with a single entry in the applications branch for our Metronome application (note that you are not limited to running a single application, you can add as many applications as you like in any single configuration - but you can only run a single configuration for each device).
Inside our Metronome application entry, we also created the mapping we want to control (âtoggleâ), and it has been assigned to the first button in the group called âSwitchesâ. The group name is referring to a named group in the control-map - if you want, you can open the control-map file (R-Control.xml) to see where this group is actually located.
Now, if you start Renoise, the Duplex tool menu already contains a new preset for the R-Control, but itâs hardly functional yet.
2. Creating a functional application
To make our application work, we need to make sure that it has the special methods that is required for a Duplex application (special events like when a new document has been created, or continuous polling to create updates over time). We could of course write these methods ourselves, but thanks to the object-oriented nature of Lua, itâs possible to automatically inherit those methods from a parent class. To do this, we extend the Duplex Application class with our own Metronome âimplementationâ (this is what a class that inherit another class is called when programming in an object oriented language).
This is the skeleton for the class: Apart from extending the Application class, we also specify a table for itâs options (which, in this case, is empty as our Metronome has no options), and supply our mapping (âtoggleâ) with a useful description.
class 'Metronome' (Application)
Metronome.default_options = {}
function Metronome:__init(display,mappings,options,config_name)
self.mappings = {
toggle = {
description = "Metronome: toggle on/off"
}
}
-- initialize our parent class
Application.__init(self,display,mappings,options,config_name)
end
Save the file into the Duplex/Applications folder as âMetronome.luaâ.
It doesnât do anything yet, but at least we no longer encounter an error message if we choose to run the application.
Now we really want to add the exciting stuff (a button), but to do this we need to initialize the application first. Letâs add the following:
-- called automatically when application is started
function Metronome:start_app()
if not Application.start_app(self) then
return
end
end
As you can see, the call to Application.start_app() return a boolean value. This is because the start_app() method is checking the available mappings, and if a problem is encountered (such as a missing or invalid mapping), the application should return a helpful error message.
Oh yeah, and start_app() will automatically call build_app() the first time itâs run. This is a special function where we construct our user-interface:
-- construct the user interface
function Metronome:_build_app()
local c = UIToggleButton(self.display)
c.group_name = self.mappings.toggle.group_name
c:set_pos(self.mappings.toggle.index)
c.on_change = function(obj)
-- a stopped application should ignore any communication
if not self.active then
return false
end
renoise.song().transport.metronome_enabled = obj.active
end
self._toggle = c
-- we successfully created the UI
return true
end
Weâve got a couple of button types to choose from, for this purpose we chose the UIToggleButton, as we want a simple on/off button.
Now save, reload the tools and try to launch the âR-control Metronomeâ configuration, and hopefully itâs working! Weâre getting close to something useful, but itâs not completely finished yet.
As updates to the button state happen when we press it, or when itâs changed in Renoise, thereâs a third option to consider: what about when we start the application? The initial state needs to be correct as well.
We can solve this by calling a method which will simply set the button to the correct state, and make sure itâs always called when the application is started.
-- set button to current state
function Metronome:update()
if self._toggle then
self._toggle:set(renoise.song().transport.metronome_enabled)
end
end
-- make sure update() is called when app is started
function Metronome:start_app()
if not Application.start_app(self) then
return
end
self:update()
end
Now, we can launch the R-Control configuration, and it will always display the correct state to begin with.
Next step is to listen to the changes in the metronome state from within Renoise. To pull this off, we need to attach a listener to the observable value, which tell us the present state of the metronome.
If we open âRenoise.Song.API.luaâ and search for âmetronomeâ weâll see that weâve got exactly what we need:
renoise.song().transport.metronome_enabled_observable
However, itâs get a little more complicated than that - because the property is a member of renoise.song() we also need to make sure that the notifier is recreated when we open a new song, or it would become invalid each time a new song was loaded or created. Basically, we need to re-attach the listener to our script each time this happens.
For this purpose, weâll add two functions, on_new_document() and attach_to_song(). Note that the method on_new_document() is special, since itâs automatically called by our parent Application class whenever a new song becomes available.
-- called whenever a new document becomes available
function Metronome:on_new_document()
self:_attach_to_song()
end
-- attach notifier to the song, handle changes
function Metronome:_attach_to_song()
renoise.song().transport.metronome_enabled_observable:add_notifier(
function()
if self._toggle then
self._toggle:set(renoise.song().transport.metronome_enabled)
end
end
)
end
We will also add attach_to_song() to our build_app() method, to make sure that itâs called at initialization time.
And thatâs it - weâre ready to include our faboulous Metronome into any device configurationâŚwell, any device that features a button
This is the full source for the Metronome application:
--[[============================================================================
-- Duplex.Application.Metronome
============================================================================]]--
--[[--
Take control of the Renoise metronome (tutorial).
--]]
--==============================================================================
class 'Metronome' (Application)
Metronome.default_options = {}
Metronome.available_mappings = {
toggle = {
description = "Metronome: toggle on/off"
}
}
Metronome.default_palette = {
enabled = { color = {0xFF,0x80,0x80}, text = "M", val=true },
disabled = { color = {0x00,0x00,0x00}, text = "M", val=false }
}
--------------------------------------------------------------------------------
--- Constructor method
-- @param (VarArg)
-- @see Duplex.Application
function Metronome:__init(...)
Application.__init(self,...)
end
--------------------------------------------------------------------------------
--- inherited from Application
-- @see Duplex.Application.start_app
-- @return bool or nil
function Metronome:start_app()
if not Application.start_app(self) then
return
end
self:update()
end
--------------------------------------------------------------------------------
--- inherited from Application
-- @see Duplex.Application._build_app
-- @return bool
function Metronome:_build_app()
local map = self.mappings.toggle
local c = UIButton(self,map)
c.on_press = function(obj)
local enabled = renoise.song().transport.metronome_enabled
renoise.song().transport.metronome_enabled = not enabled
self:update()
end
self._toggle = c
-- attach to song at first run
self:_attach_to_song()
return true
end
--------------------------------------------------------------------------------
--- set button to current state
function Metronome:update()
if self._toggle then
if renoise.song().transport.metronome_enabled then
self._toggle:set(self.palette.enabled)
else
self._toggle:set(self.palette.disabled)
end
end
end
--------------------------------------------------------------------------------
--- inherited from Application
-- @see Duplex.Application.on_new_document
function Metronome:on_new_document()
self:_attach_to_song()
end
--------------------------------------------------------------------------------
--- attach notifier to the song, handle changes
function Metronome:_attach_to_song()
renoise.song().transport.metronome_enabled_observable:add_notifier(
function()
self:update()
end
)
end