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