Router Lua extension
This document describes the Router Lua extension for the Electra One MIDI controller. It lets a preset carry its own MIDI routing engine — a script that decides, for every single MIDI packet arriving at the controller, where that packet goes, whether it is changed on the way, and whether it goes anywhere at all.
If you have used a hardware MIDI patchbay or merger — an iConnectivity mioXL, a MIDI Solutions box, a Blokas Midihub — this is the same job, done with a script instead of a matrix. Where those boxes give you a fixed grid of sources and destinations, a router script can look at each message and decide.
Note
Router Lua is available from firmware 5.0. It is switched off by default and is turned on in the configuration file.
What you can do with it
- Forward MIDI between any ports and interfaces, with full control over what is forwarded.
- Split a keyboard into zones and send each zone to a different instrument.
- Layer one keyboard onto several instruments, each with its own channel and velocity curve.
- Filter out messages a particular instrument cannot cope with.
- Remap channels, transpose notes, rescale velocities and controllers.
- Expand a program change into a bank select pair plus a program change.
- Thin down a controller that sends faster than its target can follow.
- Allocate notes round-robin across a chain of mono synthesizers.
- Route SysEx dumps to the instrument that sent them, without buffering them.
- Turn a footswitch into an Electra One UI command.
- Delay, echo or re-time messages.
And, most importantly, do all of that while the preset's own controls stay in charge of how it behaves — the routing engine runs fast and dumb, and the preset's user interface configures it live.
Two kinds of Lua extension
This is the most important section in this document. Electra One has two separate Lua environments, and using the wrong one will either not work or will feel broken.
They are called the Preset Lua extension and the Router Lua extension.
Preset Lua extension
Preset Lua is the Lua you already know — the script you upload with a preset, described in the Preset Lua Extension document. It runs on the application thread, which is the same thread that paints the display, reads the touch screen, moves the parameter map, and loads files.
That is exactly right for what it does. A value formatter has to read a control. A patch request has to know which device is selected. A custom graphic has to draw. All of that needs the preset's world, and the preset's world lives on the application thread.
The price is timing. When a MIDI message arrives, the firmware does not call your onNoteOn() straight away. It puts the message in a queue and the application thread picks it up when it gets round to it. On an idle controller that takes about half a millisecond. When the display is redrawing a busy page, it can take up to 80 milliseconds.
For updating a knob on screen, 80 ms is invisible. For passing a note through to a synthesizer, 80 ms is a stumble you can hear.
Router Lua extension
Router Lua runs on its own router thread, which sits in the MIDI path and does nothing else. It decides where each incoming message is forwarded, and it gets the message from the MIDI thread without waiting for the application thread.
The router script runs in front of the routing matrix, not instead of it. The matrix is still there, running on the router thread straight after the script, and it forwards whatever the script left it. A script that changes a message and passes it on changes what the matrix forwards.
The preset is not affected by that decision. The MIDI thread gives the preset its copy of the message first, exactly as it does without a router script, and only then hands the packet to the router. A router script cannot hide a message from the preset, and a change it makes is seen by the destinations it forwards to, not by the preset.
It works on the raw four-byte USB MIDI packet. Reading m.note does not look up a parsed message; it reads a byte out of that packet, and a SysEx message is never copied into a buffer for the router. The message object itself is reused for every packet. A few fields do build something to give you: m.source makes a port object, and m.bytes and m.header each make a table, so read them only when you need them.
The price is isolation. Router Lua cannot see the preset at all. No controls, no parameterMap, no pages, no graphics, no files. It gets MIDI, numbers, and plain Lua. That restriction is not an oversight — it is what allows the router to run at high priority without ever having to wait for the application thread to finish repainting something.
Side by side
| Preset Lua | Router Lua | |
|---|---|---|
| Script file | main.lua | router.lua |
| Runs on | application thread (priority 8) | router thread (priority 3) |
| Position in the MIDI path | after the message is delivered to the preset, through a queue | in front of the routing matrix, straight from the MIDI thread |
| Time from message to your code | ~0.5 ms, up to 80 ms when busy | short and steady, not held up by the preset or the display |
| What your code receives | a parsed MIDI message | a raw MIDI packet |
| Read and change controls, values, pages | yes | no |
| Draw on the display | yes | no |
| Read and write the parameter map | yes | no |
| Decide where a message goes | no | yes |
| Stop a message from being forwarded | no | yes |
| Change a message before it is forwarded | no | yes |
| Hide a message from the preset | no | no |
| Held up by a slow repaint | yes | never |
| Runs while the preset is pinned in the background | yes | yes |
Why the difference is so large
Three separate things add up.
Where it sits. Preset MIDI callbacks are deferred on purpose. If they ran immediately, a slow value formatter would stall MIDI input for every preset on the controller. Deferring them protects the MIDI path — and puts your script behind whatever the application thread is already doing. The router hook has no such problem, because it is the MIDI path.
What has to happen first. Before preset Lua sees a message, the firmware has parsed the packet, built a message object, offered it to every active preset's device list, and queued it for the application thread. Router Lua skips all of that: the MIDI thread hands it the raw packet directly, and the router reads only the bytes the script asks for.
What it competes with. The application thread shares its time with painting, touch, SD card access and preset loading. The router thread only routes.
The short version
Preset Lua is for what a message means. Router Lua is for where it goes.
Which one should I use?
Use preset Lua when you want to react to MIDI:
- update a control when a synth sends a value back
- format a value for display
- request a patch dump
- parse a SysEx response into the parameter map
Use router Lua when you want to change the flow of MIDI:
- forward, merge or split ports
- filter messages out
- remap channels or transpose notes in passing
- throttle a controller that sends too fast
- decide a destination per message
Use both when you want a performance instrument: a fast routing engine that never stutters, with the preset's knobs, pads and pages configuring it live. That combination is what Controlling the router from a preset is about, and it is the reason the two scripts are separate files rather than one.
A common mistake
Do not try to build MIDI thru, merging or splitting with preset Lua and midi.sendNoteOn(). It will work at your desk and fall apart on stage. The 80 ms tail is real, and it lands exactly when the display is busiest — which is exactly when you are playing.
Where the router sits in the MIDI path
A packet arriving on any interface is handled on the MIDI thread first, in this order:
- the MIDI parser, the status bar indicator, the clock tracker and the held-note tracker
- capture recording of incoming messages
- the remote knobs, which consume the messages routed to them
- MIDI control, which consumes the messages routed to it
- the morph and program change handling on the CTRL port, and capture trigger notes
- the presets: devices, the parameter map, the controls, then the preset's own
midi.onXcallbacks, which are queued for the application thread
Only then is the packet handed to the router thread, where two things run, in order:
- the router chain — every loaded router script, most recently started first
- the routing matrix — the static routes from the configuration, which forward whatever the chain left them, unless a script owned the packet
Three consequences are worth stating plainly.
- A router script cannot hide anything from captures, the remote knobs, MIDI control or the preset.
return falsestops the routing matrix, nothing else. - A change a script makes to a message is not seen by the preset or by anything above. They all had the original.
- Whatever a script sends goes straight out to the destination. It is never offered back to the chain, so a router cannot trigger itself.
Messages a script sends are not recorded as internal output by a capture. Messages sent to ports.preset are recorded, because they go through incoming handling again.
While any router is loaded, every packet takes the hop to the router thread, watched or not, and the matrix runs there. With no router loaded the matrix runs on the MIDI thread as it always did. The hop is short, but it is not nothing: the router queue holds 256 packets, with a further 4096-entry overflow list behind it, and packets are dropped once both are full.
Getting started
Where the script lives
A router script belongs to a preset. It sits next to main.lua in the preset's slot:
/ctrlv2/slots/b02/p05/main.lua <- preset Lua, as always
/ctrlv2/slots/b02/p05/router.lua <- router LuaYou upload it the same way you upload any other preset file, through the file transfer API, using routerLua as the file type:
electraone files upload --location slots --type routerLua --bank 2 --slot 5 router.luaIt downloads and is removed through the same descriptor. A preset list and a slot info report hasRouter beside hasLua, so a host can tell which presets carry one without listing every slot.
There is no global or system-wide router script. Routing belongs to the preset that needs it, so it travels with the preset you share or download.
Turning it on
Router scripts are disabled by default and must be switched on in the configuration file:
{
"router": {
"lua": {
"presetRouters": true,
"budget": 20000
},
"routes": []
}
}presetRouters— whenfalseor missing,router.luafiles are ignored entirely.budget— how much work one script may do per message, in Lua instructions. Accepted between 1000 and 1000000; a value outside that range is ignored and the previous one kept. See Limits and safety.
Switching it on does not start anything
Turning presetRouters on starts no router by itself. The preset that is already on screen keeps running without one until you enter it again — switch away and back, or reload it. Switching the option off stops every running router immediately.
A change to budget is handed to a router when it starts. Routers already running keep the budget they were given.
The routes array — the ordinary routing matrix — keeps working exactly as before. A router script sits in front of it, and anything the script passes on still goes through it.
When a router runs
A preset's router is live exactly when the preset is active. That is the same rule the firmware already uses for MIDI: a preset receives MIDI while it is active, and a pinned preset stays active after you switch away from it.
| What you do | What happens to the router |
|---|---|
Load a preset that has a router.lua | it starts: the chunk runs, then init() |
| Switch to another preset | it stops, unless the preset was pinned |
| Pin a preset and switch away | it keeps running in the background |
| Switch back to a pinned preset | it keeps running, with its variables, parameters and timers intact |
| Reload the preset | it is stopped and started again from the top |
Call router.reload() from main.lua | the same: stopped, router.lua read again, started from the top |
Upload a new router.lua | nothing, until the preset is reloaded, re-entered, or router.reload() is called |
Upload a new main.lua to the running preset | the preset's script is replaced and its router is started again from the top |
| Switch preset routers off in the configuration | every router stops at once |
| The script fails 16 times in a row | that router stops and leaves the chain, and says so in the log. The preset keeps working |
Starting a router always means the same thing: a fresh Lua state, the chunk runs, init() runs if it is a function, parameters go back to what init() declares, every variable the script was keeping is gone, and everything it had scheduled is cancelled. The router also moves to the front of the chain.
If several presets are pinned and each has a router, they all run, one after another, most recently started first. See Several routers at once.
The router starts after main.lua has run
The request to start a router is made once the preset is up, and it is handled on the router thread a moment later. Inside onLoad(), onReady() or onEnter() the router is therefore not running yet: router.isActive() is false and router.set() raises. Nothing tells the preset when the router has started — see Parameters start from init() for the pattern that works.
Your first router script
-- Forward the notes arriving on MIDI IO port 1 to USB host port 1,
-- moving them to channel 1 on the way.
function init()
router.watch{
types = { NOTE_ON, NOTE_OFF },
from = { ports.midiIo1 },
}
end
function onMidi(m)
m.channel = 1
m:to(ports.usbHost1)
return false
endThree things are happening.
init() runs once, when the router starts. router.watch tells the firmware which messages you are interested in — here, note on and note off from the first DIN input. Everything else skips your script entirely.
onMidi(m) runs for each matching packet. m is the message. Setting m.channel rewrites it in place. m:to() sends it.
return false means I have dealt with this. The routing matrix does not forward it as well. If you returned true instead, the message would also be forwarded by whatever the routing matrix says. Either way the preset has already received the original message, as it always does.
Always set types
Leaving types out does not mean "notes and controllers". The default set also contains song position, song select and MIDI time code quarter frames, and none of those has a channel: m.channel = 1 raises an error on them. A DAW sending time code sends about a hundred quarter frames a second, so a script that writes m.channel without a types filter fails sixteen times in a fifth of a second and switches itself off.
Pass or own
Every hook answers one question, and there are only two answers.
return true, or return nothing — carry on. The message, including any changes you made to it, continues to the next router and then into the routing matrix. This is how you transpose or remap everything the matrix forwards without writing a single send:
function init()
router.watch{ types = { NOTE_ON, NOTE_OFF } }
end
function onMidi(m)
if m.note < 116 then
m.note = m.note + 12 -- every forwarded note is now an octave higher
end
return true
endOnly the boolean false owns a message. Returning nil, true, a number or a string all mean carry on.
return false — you own it. The routing matrix does not forward it, and no router behind you in the chain sees it. Whatever m:to() calls you made have already gone out, and that is the entire effect on where the message goes.
Neither answer changes what the rest of the controller does with the message. By the time the router sees it, the preset has already received the original, and the remote knobs and MIDI control have already taken the messages routed to them.
Two answers cover everything a matrix router needs several rules for:
| What you want | How to write it |
|---|---|
| Keep a message from being forwarded | return false |
| Change what the matrix forwards | change a field, return true |
| Send it somewhere extra as well | m:to(port), return true |
| Send it somewhere and nowhere else | m:to(port), return false |
| Send it to several places, differently | change, m:to(), change, m:to(), return false |
Because m:to() sends the message exactly as it stands at that moment, fanning out with variations needs no copies:
m.channel = 3; m:to(ports.midiIo1)
m.channel = 9; m:to(ports.usbHost2)
return falseIf your hook raises an error, or runs out of its instruction budget, the packet is passed on unchanged — as though the script had not been there. Sends it had already made have gone out.
Choosing what you see
A hook that runs for every message is a hook that costs something for every message. Almost every router only cares about a few kinds of message from a few places, so you say which, and the firmware filters before Lua is involved at all.
function init()
router.watch{
types = { NOTE_ON, NOTE_OFF, CONTROL_CHANGE },
from = { ports.midiIo1, ports.usbHost1 },
channels = { 1, 2, 10 },
}
endAnything that does not match goes straight on its way without your script being called.
| Setting | What it takes | If you leave it out |
|---|---|---|
types | a list of message type globals | all channel voice messages, plus song position, song select and time code quarter frame |
from | a list of ports | every interface and port |
channels | a list of 1 to 16 | all sixteen |
sysex | a number of header bytes, "packets", true, false or "off" | SysEx is not watched at all |
Two message classes are never included unless you ask for them by name:
- MIDI clock and realtime. Clock arrives 24 times per beat from every source that sends it. Add
CLOCK,START,CONTINUE,STOP,ACTIVE_SENSINGorRESETtotypesonly if you actually need them, and expect them to cost. - SysEx. A dump can be thousands of packets. See Working with SysEx.
TUNE_REQUEST is not in the default set either, and has to be asked for by name.
A few details that matter when you write a filter:
typesreplaces the whole set. Listing{ NOTE_ON }means note on and nothing else, not "notes as well as the default".types = {}watches nothing at all — except SysEx, which is controlled bysysex.- A key you leave out keeps its current value.
router.watchedits the filter rather than replacing it, so you can narrow it in two calls. router.watchis not restricted toinit(). Call it whenever you like; it applies from the next packet on.channelsonly affects messages that have a channel. Everything else passes the channel test.- An entry in
fromthat is not a port is ignored, and a port set built withports.set()is not a port — put the ports themselves in the list. If nothing infromis a port, the filter matches nothing. SYSEXintypesraises. SysEx is asked for with thesysexkey.- A type written as a string, such as
"noteOn", raises.
The message
The m passed to your hook is a live view of the MIDI packet. Reading a field decodes it on the spot; writing one rewrites the packet. The object is reused for every message, so never store it in a variable that outlives the hook — copy the values you need instead. Touching it outside the hook it was given to raises router: this message is only valid inside the hook it was given to.
Reading and writing
function init()
router.watch{ types = { NOTE_ON } }
end
function onMidi(m)
if m.velocity > 100 then
m.velocity = 100 -- cap the velocity
end
return true
endThe general fields work for every message:
| Field | Meaning |
|---|---|
m.type | NOTE_ON, CONTROL_CHANGE, PROGRAM_CHANGE, and so on |
m.source | the port it arrived on |
m.channel | 1 to 16, or nil for messages that have no channel |
m.data1, m.data2 | the two data bytes, whatever the message is |
The named fields are easier to read, and are the same two bytes under another name:
| Field | Reads and writes |
|---|---|
m.note, m.velocity | note on, note off |
m.controller, m.value | control change |
m.program | program change |
m.pressure | channel and polyphonic aftertouch |
m.bend | pitch bend, −8192 to 8191 |
The named fields are not checked against the message type. m.note on a control change reads the controller number, and writing it rewrites the controller number. Test the type first, with m:is(NOTE_ON, NOTE_OFF) or with a types filter that only lets the right messages through.
m:is(NOTE_ON, NOTE_OFF) is the tidy way to test the type against several possibilities at once.
Writing a value outside its legal range raises an error:
| Write | Range | Error when it is outside |
|---|---|---|
m.channel | 1 to 16 | router: channel is out of range: 17 |
m.channel on a message that has none | — | router: a SONG_POSITION has no channel |
m.data1, m.note, m.controller, m.program | 0 to 127 | router: note is out of range: 130 |
m.data2, m.velocity, m.value | 0 to 127 | router: velocity is out of range: -1 |
m.pressure | 0 to 127 | router: pressure is out of range: 200 |
m.bend | −8192 to 8191 | router: bend is out of range: 9000 |
| any other name | — | router: a message has no field called 'foo' |
So guard arithmetic before you write it back:
local note = m.note + router.params.transpose
if (note < 0) or (note > 127) then
return false -- nothing to play: do not raise
end
m.note = noteA field written with a number that has a fractional part raises as well, so round a value that came from a knob or a calculation before writing it.
Reading a name that is not a field gives nil rather than raising, so a typo in a read is silent. Writing one raises.
m.raw is the whole four-byte packet as a number. Writing it is not checked: you can change the code index number, the cable or the message type with it, and nothing stops you writing nonsense. It is there for holding a packet back and sending it later, as in Deciding as it arrives.
Ports and destinations
Every input and output has a name. You never build interface and port numbers by hand.
| Name | What it is |
|---|---|
ports.midiIo1, ports.midiIo2 | the DIN sockets |
ports.usbDev1, ports.usbDev2 | USB device ports 1 and 2, as your computer sees them |
ports.ctrl | the Electra One control port, USB device port 3 |
ports.usbHost1, ports.usbHost2 | USB host ports 1 and 2 |
ports.all | port 1 of every interface: MIDI IO port 1, USB device port 1 and USB host port 1 |
ports.preset | the controller's own preset handling, see The Electra One as a destination |
ports.midiControl | the controller's MIDI control mappings |
ports.all is one port object, not all of them: it reaches port 1 of each interface. To send to two ports of one interface, name them both.
Ports compare directly, which makes them easy to test:
if m.source == ports.midiIo1 then
...
endA port also carries four read-only fields:
| Field | Value |
|---|---|
port.name | "midiIo/port1", "midiUsbDev/port3", "midiUsbHost/port2", "midiAll/port1", "preset", "midiControl" |
port.connected | true for everything except a USB host port with no device assigned to it |
port.interface | the interface number: 0 MIDI IO, 1 USB device, 2 USB host, 3 all, 240 preset, 241 MIDI control |
port.port | the port within the interface, counted from 0 |
tostring(port) gives port(midiIo, 1) or port(preset).
A USB host instrument can also be found by its product name, which is far more readable than a port number. ports.find looks for a connected USB host device whose product name contains the text you give (upper and lower case matter) and returns the USB host port that device is assigned to:
local rev2 = ports.find("Rev2")
if rev2 then
m:to(rev2)
endIt returns nil when no such device is plugged in, and m:to(nil) raises. A device found in init() may not be plugged in yet when the router starts, so either look it up again when you need it or check the variable before you use it. The port it returns is the whole USB host port, so a message sent to it reaches every USB instrument assigned to that port.
When you send to several places at once, a port set saves a loop:
local rack = ports.set(ports.midiIo1, ports.usbHost1, ports.usbHost2)
m:to(rack)A port set is a plain Lua array of ports. Only m:to() accepts one: m:claimStream(), the port send functions and router.watch{ from = ... } all want ports themselves.
Sending new messages
A port can also generate messages that did not come from anywhere. The functions mirror the ordinary midi library, without its interface and port arguments:
ports.midiIo1:sendNoteOn(1, 60, 100)
ports.usbHost1:sendControlChange(1, 74, 64)
ports.midiIo2:sendProgramChange(3, 12)They work in onMidi(), in onSysex(), in onParam() and in a scheduled function — that last one is the difference from port:send(), which needs a message in hand and raises without one.
Each of them counts against the send limit, and none of them checks its arguments: a channel outside 1 to 16 and a data byte above 127 are masked rather than refused, so a wrong number sends a wrong message instead of raising.
The Electra One as a destination
Two of the names in ports are not physical ports at all — they are the controller's own handling. Sending to them is how a router script reaches the instrument it runs on.
| Port | What it does |
|---|---|
ports.preset | the message, as the script has changed it, enters normal preset handling — devices, parameter map, the control that listens for it |
ports.midiControl | the message is offered to the MIDI control mappings, which decide which UI command it is |
The preset has already received every incoming message in its original form, so sending an unchanged message to ports.preset would deliver it twice. ports.preset is for a message you have changed and want the preset to react to in its changed form. A keyboard that sends on channel 5 can drive a synthesizer and a preset whose device listens on channel 1:
function init()
router.watch{ types = { CONTROL_CHANGE }, from = { ports.midiIo1 } }
end
function onMidi(m)
m.channel = 1
m:to(ports.usbHost1)
m:to(ports.preset) -- the preset's channel 1 device follows along
return false
endA footswitch on the DIN input can change pages the same way, through ports.midiControl.
Three things to know about them:
- Neither is a wire, so neither is immediate: both are handed to the application thread, which picks them up on its next pass. That is invisible for a control change moving a knob on screen, and it is the reason these are not the way to forward notes to an instrument.
- A message sent to
ports.presetarrives as though it came from a MIDI IO port — the DIN socket whose number matches the cable the original arrived on, so a message fromports.usbDev2appears as MIDI IO port 2. A message built with a port send function appears as MIDI IO port 1. Preset devices resolve against that, and a capture records it a second time. The preset's ownmidi.onXcallbacks, the clock tracker and the status bar are not run for it. - A message sent to
ports.midiControlgoes straight to the mapping handler. The source and channel of the MIDI control configuration are not matched against it.
The port send functions work on these two as well, so a scheduled function or onParam() can reach the preset without a message in hand:
ports.preset:sendControlChange(1, 74, 64)System exclusive cannot be sent to either — it is a run of packets and none of them is a message on its own. Sending one is silently ignored.
ports.remote is not built
Driving a remote knob from a router is in the design and not in the firmware. See What is not built yet.
Working with SysEx
SysEx messages can be very large — a patch dump is often several kilobytes. The router never holds one in memory. It sees the message as it arrives, in small pieces, and hands you enough of it to decide what to do.
Deciding as it arrives
Ask for SysEx by name — it is excluded by default — saying how many header bytes you want kept:
function init()
router.watch{ sysex = 8, from = { ports.usbDev1 } } -- keep eight bytes; max 12
end
function onSysex(m, first, last)
if m.header[2] == 0x41 then -- Roland
return m:claimStream(ports.midiIo1)
end
return m:passStream()
endYour script is called for every packet of the message until it latches a decision — the sysex number says how many header bytes m.header shows you, not how many packets you are called for. m.bytes is this packet's one to three bytes, and m.header is everything seen of the message so far, so a three-byte manufacturer id that spans two packets is still readable.
As soon as you know enough, latch a decision. From then on the remaining packets are handled in C and your script is not called again for that message, so a four kilobyte dump costs one call rather than one per three bytes:
| Function | Effect on the rest of the message | Returns |
|---|---|---|
m:claimStream(port, …) | forward it all to those ports, up to four of them | false |
m:passStream() | hand it all to the routing matrix | true |
m:dropStream() | throw it all away | false |
They return the right verdict for the packet in hand too, so return m:claimStream(port) reads correctly. m:claimStream() also sends the packet in hand to the destinations, and those sends count against the send limit; the packets that follow are forwarded in C and are not counted or capped.
Until you latch, each packet is decided on its own: returning false owns just that packet, which is how you hold the beginning of a message back while you wait to see more of it. Nothing can give a packet back to the matrix once you have owned it, so a packet you hold is yours to deliver — with m.raw — or to lose.
-- A manufacturer id that starts with 00 is three bytes long and is not
-- complete in the first packet, so that packet is held back until the id is
-- known. Once a packet is owned only this script can deliver it, so every
-- path that holds one ends in a claim.
local owners = {
[0x41] = ports.midiIo1, -- Roland, a one byte id
[0x002032] = ports.usbHost1, -- Behringer, the three byte id 00 20 32
}
local fallback = ports.midiIo2 -- where a held dump goes if nothing claims it
local held = nil
function init()
router.watch{ sysex = 4, from = { ports.usbDev1 } } -- 4, so header[4] exists
end
function onSysex(m, first, last)
if first then
held = nil -- a new message: forget an abandoned one
if m.header[2] == 0 then
held = m.raw -- own this packet, decide on the next
return false
end
end
local id = m.header[2]
if held then
id = (m.header[2] << 16) | (m.header[3] << 8) | m.header[4]
end
local destination = owners[id]
if not destination then
if not held then
return m:passStream() -- nothing owned yet: the matrix can have it
end
destination = fallback -- the first packet is ours to deliver
end
if held then
local current = m.raw -- send the packet we kept back, first
m.raw = held
m:to(destination)
m.raw = current
held = nil
end
return m:claimStream(destination)
endA few more rules of the stream handling:
- A stream starts at the packet carrying
F0. Packets of a message whoseF0was never seen — the router started in the middle of a dump — go to the matrix without being offered to the script. - A stream with nothing new for 2 seconds is forgotten. The next packet that is not an
F0is treated as a fragment. fromnarrows system exclusive the same way it narrows everything else: a script watching one input is not shown dumps arriving on another.channelsandtypeshave nothing to say about SysEx.m:claimStream()takes up to four destinations. Further ones are ignored.- Nine streams from nine different sources can be in progress at once.
m.headeris{}outsideonSysex(), andm.bytesis{}for anything that is not SysEx.firstis true on the packet carrying theF0;lastis true on the packet that ends the message.- If
onSysexis not defined, SysEx goes to the matrix whateverrouter.watchasked for.
Electra One SysEx is protected
Messages beginning F0 00 21 45 are the Electra One's own protocol — how the Preset Editor and the file transfer API talk to your controller. The controller handles them before any routing, your script never sees them, and the routing matrix never sends them to the MIDI sockets. A mistake in a script cannot lock you out of your own device.
Telling them apart takes four bytes, so the router withholds the first packet of every message beginning F0 00 21 from the script until the fourth byte has arrived. For another manufacturer in that block — F0 00 21 09, say — the script is called from the second packet on, and the first packet has already been handed to the matrix. If the script then claims or drops the stream, the destination it claimed receives the dump without its first three bytes. Prefer the matrix for those, or rebuild the header at the destination.
Controlling the router from a preset
A router script is deliberately fast and deliberately blind. It cannot read a knob or draw a meter. But a performance rig needs both — an engine that never stutters, and a page of controls that changes what the engine does.
The two talk through parameters and events. Both are carried on queues of their own, between the router thread and the application thread.
Parameters: the preset tells the router
The router declares the settings it accepts, with their starting values:
-- router.lua
function init()
router.params{
transpose = 0,
split = 60,
mute = false,
}
router.watch{ types = { NOTE_ON, NOTE_OFF }, from = { ports.midiIo1 } }
end
function onMidi(m)
if router.params.mute then return false end
local note = m.note + math.floor(router.params.transpose)
if (note < 0) or (note > 127) then return false end
m.note = note
return true
endrouter.params is an ordinary Lua table — the very table you passed to router.params(). Reading router.params.transpose inside a hook is a plain table lookup: nothing is fetched, nothing is locked, and it costs nothing worth measuring.
A parameter declared with a number is a number, and a parameter declared with true or false is a boolean, on both sides. if router.params.mute then does what it reads like. A number arrives as a Lua number, which may have a fractional part if the preset sent one, so round it before writing it into a message field.
At most 16 parameters may be declared, and a name is cut to 20 characters. A key that is not a string is ignored, entries past the sixteenth are ignored, and a value that cannot be read as a number is taken as 0.
The preset script sets them:
-- main.lua
function onTransposeChange(control, value)
router.set("transpose", value - 64)
end
function onMutePad(control, value)
router.set("mute", value > 0)
endWhen a change takes effect
The router applies parameter changes between messages, never in the middle of one. Your hook always sees a consistent set of values — it can never observe a transpose that changed halfway through its own run.
Writing router.params from inside the router script is local scratch. It changes the table the hooks read, and nothing else: the preset side does not see it, and the next router.set() for that name overwrites it.
If the script defines onParam(name, value), it is called whenever the preset sets a parameter, with the value in the type the parameter was declared with. It runs on the router thread, between messages, with its own instruction budget and its own allowance of 32 sends.
Events: the router tells the preset
Going the other way, the router reports and the preset displays:
-- router.lua
router.emit("voices", voicesInUse)-- main.lua
function onRouterEvent(name, value)
if name == "voices" then
controls.get(VOICE_METER):getValue("value"):overrideValue(value)
end
endonRouterEvent is an ordinary preset callback, like onNoteOn. It runs on the application thread, so it can do anything a preset script can do — set a control, change a colour, switch a page. The value always arrives as a number: router.emit("mute", true) delivers 1.
An event name is cut to 20 characters. The event queue holds 32 entries and the application thread takes up to 8 of them per pass; events that do not fit are dropped, and the loss is reported in the log every two seconds.
Do not emit per note
An event runs a Lua function on the application thread. Emitting on every note gives that thread work at note rate, and it is already the thread painting your display. Emit when something actually changes, not when something happens.
Parameters start from init()
Router parameters are not saved. Each time the router starts — the preset is loaded or reloaded, or router.reload() is called — they begin at the values init() declares. A pinned preset you switch back to is the exception: its router was never stopped, so it keeps the values it had.
To bring a rig back the way you left it, keep the settings on the preset's controls and send them again from main.lua once the router is running. Nothing announces that moment, so ask until the answer is yes:
-- main.lua
-- What the controls last chose. The control callbacks keep it up to date,
-- and it is sent again as soon as the router is there to receive it.
local settings = { transpose = 0, split = 60 }
local settingsHandle = nil
local function sendSettings()
if not router.isActive() then
return -- not started yet, ask again in 50 ms
end
for name, value in pairs(settings) do
router.set(name, value)
end
schedule.cancel(settingsHandle)
settingsHandle = nil
end
function onReady()
settingsHandle = schedule.every(50, sendSettings)
end
function onTransposeChange(control, value)
settings.transpose = value - 64
if router.isActive() then
router.set("transpose", settings.transpose)
end
endrouter.set() raises while the router has not declared the parameter yet, which includes every moment before it has started, so the isActive() test is not optional.
The router table
A preset script reaches its router through a built-in router table, the same way it reaches midi or pipe. There is nothing to require.
| Function | What it does |
|---|---|
router.set(name, value) | set a router parameter; a number or a boolean |
router.get(name) | read one back, or nil when there is no such parameter |
router.params() | every parameter and its current value, as a table |
router.isActive() | whether this preset's router is loaded and running |
router.reload() | re-read router.lua and restart it, without touching the preset |
router.set raises when the router has no parameter of that name, so a typo is reported rather than quietly ignored.
Two tables called router
Inside main.lua, router is the table above. Inside router.lua it is the router's own API — watch, params, emit, after and the rest. They share a name because a script only ever sees one of them.
Scheduling
A hook only runs when a message arrives, which is no help for delays, echoes, or anything that has to happen later. The router has its own scheduler, running on the router thread, so what you schedule is as well timed as what you route.
-- Send a note off 200 ms after the note on, whatever the keyboard does
function init()
router.watch{ types = { NOTE_ON }, from = { ports.midiIo1 } }
end
function onMidi(m)
if m.velocity > 0 then
local note = m.note
router.after(200, function()
ports.midiIo1:sendNoteOff(1, note, 0)
end)
end
return true
end| Function | What it does |
|---|---|
router.after(ms, fn, ...) | run fn once, after ms milliseconds |
router.every(ms, fn, ...) | run fn repeatedly until cancelled |
router.cancel(handle) | stop something previously scheduled |
router.now() | milliseconds since the controller started |
This is not the preset timer
The timer and schedule libraries that presets use are different things with different behaviour, on a different thread. The router scheduler is separate. You cannot use one from the other.
You can have 32 things scheduled at once per router. Asking for more raises, rather than silently doing nothing.
Extra arguments after the function are passed to it when it runs, so a value can be captured without building a closure:
router.after(120, function(note, velocity)
ports.midiIo1:sendNoteOn(1, note + 12, velocity // 2)
end, m.note, m.velocity)A scheduled function runs with its own instruction budget and its own allowance of 32 sends. There is no message while it runs, so port:send() and everything on m raise; the port send functions are what a scheduled function sends with. A function that raises is dropped, even a repeating one. A repeating function is due again a period after it ran, so a late run pushes the next one later; router.every(0, ...) is treated as 1 ms.
Several routers at once
Because a pinned preset stays active, more than one router can be running. They form a short chain: the most recently started first, then the others, then the routing matrix. A router that starts again — a reload — goes back to the front.
The first script to return false ends the chain — later routers never see that message. Returning true, or returning nothing, passes it on to the next one, along with any change that was made to it.
At most eight routers run at once. A ninth preset with a router will not start one, and says so in the log.
One function shortens the chain:
-- No router behind this one in the chain runs
function init()
router.exclusive()
endrouter.exclusive() stops the walk after this router, whether or not this router wanted the packet. It does not reach routers ahead of it — a preset activated later is ahead of it and still runs first. router.exclusive(0) switches it off again; it is not restricted to init(), and it takes a number, so router.exclusive(true) raises.
Most of the time you will not need it. A single unpinned preset is a chain of one.
Limits and safety
A router script runs at a higher priority than the display and the user interface. A script that misbehaves there matters more than one that misbehaves in a preset, so the firmware bounds it rather than trusting it.
| Limit | Value |
|---|---|
| Instructions per call into the script | router.lua.budget, 20000 by default, 1000 to 1000000 |
Instructions for the chunk and for init() | ten times that |
Messages sent per incoming message, per scheduled call, per onParam | 32 |
| Failures in a row before the router stops | 16 |
| Parameters, and the length of a name | 16, 20 characters |
| Scheduled functions | 32 |
| SysEx streams at once | 9 |
Destinations for m:claimStream() | 4 |
| Header bytes kept | 12 |
| Routers running at once | 8 |
| Event queue to the preset | 32, 8 delivered per application pass |
router.log() lines | about 5 a second, shared by every router |
Work per call. Each call into your script is allowed a fixed number of Lua instructions — 20,000 by default, roughly a millisecond. A call that exceeds it is stopped, and the message is passed on unchanged, exactly as though your script had not been there. The chunk and init() get ten times as much, because they set everything up.
The budget cannot be escaped. A pcall or xpcall around a runaway loop catches the error and is immediately stopped again, and the call ends as a failure whatever the script does with the error. xpcall's message handler runs after the stack has unwound, as an ordinary call the budget covers — which is the one visible difference from standard Lua. For the same reason setmetatable() refuses a metatable with a __gc field: a finaliser runs where the budget cannot see it.
Failures. A call that raises, or that runs out of budget, is a failure. After 16 failures in a row the router stops, leaves the chain and says so in the log. Any successful call resets the count. The preset keeps working, and the routing matrix keeps forwarding.
Sends per message. At most 32 messages may be sent in response to one incoming message. The 33rd raises router: too many messages sent for one incoming message, which counts as a failure. A scheduled function and onParam each start from zero. So do not write a loop that sends 128 note offs — send CONTROL_CHANGE 123, all notes off, instead.
Nothing that waits. There is no file access, no io, no os, no package, no debug, no coroutine, no utf8, and no way to reach the preset directly. require, load, dofile and loadfile are removed: a router script is a single file. What is left is the base library, string, table and math, the type globals, and the router and ports libraries.
A send goes onto an output queue, and a queue that is full can hold the router thread up briefly. print() writes to the log and is not rate limited, which is why router.log() exists.
No debugger. Router scripts cannot be stopped in the Lua debugger. Stopping a script that sits in the MIDI path would stop MIDI. Use router.log(), or report values to the preset with router.emit() and put them on screen.
No loops. Messages your script sends go straight out. They never come back into your script, so a router cannot trigger itself.
Examples
A channel remap
An old synthesizer only listens on channel 1. Your keyboard sends on channel 5. Rather than changing either, fix it in passing.
function init()
router.watch{
types = { NOTE_ON, NOTE_OFF, CONTROL_CHANGE, PROGRAM_CHANGE,
PITCH_BEND, CHANNEL_PRESSURE, POLY_PRESSURE },
from = { ports.usbDev1 },
channels = { 5 },
}
end
function onMidi(m)
m.channel = 1
m:to(ports.midiIo1)
return false
endEvery type in the list has a channel, so m.channel = 1 is always safe. return false means the message is forwarded to the synthesizer and nowhere else. The preset has already received the original on channel 5. If a preset device listens on channel 1 and a control on screen should follow the keyboard, add m:to(ports.preset) before the return.
A keyboard split
One master keyboard, three instruments. The bottom of the keyboard plays a bass, the middle plays a lead, and the top plays pads an octave down so they sit in a comfortable register.
local keys, bass, lead, pads
function init()
keys = ports.midiIo1
bass = ports.usbHost1
lead = ports.midiIo2
pads = ports.usbDev1
router.watch{ types = { NOTE_ON, NOTE_OFF }, from = { keys } }
end
function onMidi(m)
if m.note < 48 then
m.channel = 1
m:to(bass)
elseif m.note < 84 then
m.channel = 2
m:to(lead)
else
m.channel = 3
m.note = m.note - 12
m:to(pads)
end
return false
endOnly note messages are watched, so pitch bend, aftertouch and control changes from the keyboard are untouched and follow whatever the routing matrix says. The top zone plays from note 84 upwards, so m.note - 12 can never go below 0.
A transpose driven by a preset parameter
The pattern the whole feature exists for: the router does the work, and the preset's controls change what it does while you play.
-- /ctrlv2/slots/b02/p05/router.lua
local keys, synth
function init()
keys = ports.midiIo1
synth = ports.usbHost1
router.params{
transpose = 0,
velFloor = 0,
mute = false,
}
router.watch{ types = { NOTE_ON, NOTE_OFF }, from = { keys } }
end
function onMidi(m)
if router.params.mute then
return false
end
local note = m.note + math.floor(router.params.transpose)
if (note < 0) or (note > 127) then
return false
end
m.note = note
-- a note on with velocity 0 is a note off in disguise, so it is left alone
if m.velocity > 0 then
m.velocity = math.max(math.floor(router.params.velFloor), m.velocity)
end
m:to(synth)
return false
end
-- Muting in the middle of a phrase would leave notes sounding, so silence
-- the synthesizer whenever the mute comes on. One message, not 128.
function onParam(name, value)
if (name == "mute") and value then
synth:sendControlChange(1, 123, 0) -- all notes off
end
end-- /ctrlv2/slots/b02/p05/main.lua
-- Each control sets one router parameter. Nothing here knows what a packet is.
function onTransposeChange(control, value)
router.set("transpose", value - 64)
end
function onVelocityFloorChange(control, value)
router.set("velFloor", value)
end
function onMutePad(control, value)
router.set("mute", value > 0)
end
function onRouterEvent(name, value)
print(name .. " is now " .. value)
endmute was declared with false, so it is a boolean everywhere: router.params.mute in the hook, the value given to onParam, and router.get("mute") on the preset side.
Layering, with a velocity floor
The same notes to four instruments at once. Two of them are inaudible below a certain velocity, so each gets its own minimum.
local rig = {
{ port = ports.midiIo1, channel = 1, floor = 0 },
{ port = ports.midiIo2, channel = 1, floor = 30 },
{ port = ports.usbHost1, channel = 5, floor = 0 },
{ port = ports.usbHost2, channel = 16, floor = 64 },
}
function init()
router.watch{ types = { NOTE_ON, NOTE_OFF } }
end
function onMidi(m)
local velocity = m.velocity
for _, slot in ipairs(rig) do
m.channel = slot.channel
-- a note on with velocity 0 is a note off in disguise,
-- so it must never be lifted off the floor
if velocity == 0 then
m.velocity = 0
else
m.velocity = math.max(slot.floor, velocity)
end
m:to(slot.port)
end
return false
endNote that velocity is read once, before the loop. Each pass writes m.velocity, so reading it inside the loop would compound the changes. Four destinations is four sends, well inside the limit of 32.
Filtering what an instrument cannot handle
A vintage synthesizer locks up when it receives aftertouch, and its manual is quite clear that it should not be sent MIDI clock either.
local fragile = ports.midiIo2
function init()
router.watch{
types = { NOTE_ON, NOTE_OFF, CONTROL_CHANGE, PITCH_BEND,
CHANNEL_PRESSURE, POLY_PRESSURE, CLOCK },
from = { ports.usbDev1 },
}
end
function onMidi(m)
-- these never reach the old synth, but other instruments still get them
if m:is(CHANNEL_PRESSURE, POLY_PRESSURE, CLOCK) then
return true
end
m:to(fragile)
return true
endHere everything returns true, so the routing matrix still delivers messages to everything else as usual. The script only adds a delivery to the fragile synthesizer, and only for messages it can cope with.
A clock divider
A drum machine cannot follow the sequencer's tempo, so it is clocked at half speed. Start, continue and stop are passed on as they are. A repeating scheduled function notices when the clock has stopped without a stop message and silences the drums.
local sequencer = ports.usbDev1
local drums = ports.midiIo2
local counter = 0
local running = false
local lastClock = 0
local function watchdog()
if running and ((router.now() - lastClock) > 500) then
running = false
counter = 0
drums:sendControlChange(10, 123, 0) -- all notes off
end
end
function init()
router.params{ divisor = 2 }
router.watch{
types = { CLOCK, START, CONTINUE, STOP },
from = { sequencer },
}
router.every(200, watchdog)
end
function onMidi(m)
if m:is(START, CONTINUE) then
counter = 0
running = true
m:to(drums)
return false
end
if m:is(STOP) then
running = false
m:to(drums)
return false
end
-- a clock
lastClock = router.now()
counter = counter + 1
if counter >= math.max(1, math.floor(router.params.divisor)) then
counter = 0
m:to(drums)
end
return false
endClock is watched here, so this script runs 24 times a beat for every clock the sequencer sends. That is the price of dividing it, and it is the reason clock is not in the default watch.
Thinning a controller that sends too fast
A fader box floods a synthesizer with control changes and the synthesizer audibly struggles. Pass at most one value per controller every few milliseconds — but never add delay to the first move of a gesture, and never lose the final value.
local target = ports.usbHost1
local lastSent, pending, armed = {}, {}, {}
function init()
router.params{ interval = 8 }
router.watch{ types = { CONTROL_CHANGE }, from = { ports.usbDev1 } }
end
local function key(channel, controller)
return channel * 128 + controller
end
local function flush(k, channel, controller)
armed[k] = nil
if pending[k] then
target:sendControlChange(channel, controller, pending[k])
pending[k] = nil
lastSent[k] = router.now()
end
end
function onMidi(m)
local interval = math.max(1, math.floor(router.params.interval))
local k = key(m.channel, m.controller)
local now = router.now()
local since = lastSent[k] and (now - lastSent[k])
-- first move, or enough time has passed: straight through
if not since or since >= interval then
lastSent[k] = now
return true
end
-- too soon: remember the newest value and schedule it
pending[k] = m.value
if not armed[k] then
armed[k] = true
router.after(interval - since, flush, k, m.channel, m.controller)
end
return false
endThe interval is a parameter, so a knob on the preset page can tune it while you listen. One scheduled function is armed per controller, and the scheduler holds 32 — a fader box moving more than 32 controllers at once would run out and the hook would raise.
Program change into bank select
A sequencer sends plain program changes 0, 1 and 2. The rack expects a bank select pair first, and uses different program numbers.
local rack = ports.usbHost1
local patches = {
[0] = { bank = 2, program = 17 },
[1] = { bank = 2, program = 41 },
[2] = { bank = 5, program = 3 },
}
function init()
router.watch{ types = { PROGRAM_CHANGE } }
end
function onMidi(m)
local patch = patches[m.program]
if not patch then
router.log(string.format("no patch mapped for program %d", m.program))
return false
end
rack:sendControlChange(m.channel, 0, patch.bank >> 7)
rack:sendControlChange(m.channel, 32, patch.bank & 0x7f)
rack:sendProgramChange(m.channel, patch.program)
return false
endRound-robin across a chain of mono synths
Six monophonic synthesizers on one MIDI chain, each listening on its own channel. Notes are handed to whichever is free, and each note off has to find the channel holding that note.
local chain = ports.midiIo1
local CHANNELS = 6
local holding = {} -- channel -> the note it is currently playing
local cursor = 1
function init()
router.watch{ types = { NOTE_ON, NOTE_OFF } }
end
local function isNoteOff(m)
return m:is(NOTE_OFF) or (m:is(NOTE_ON) and m.velocity == 0)
end
function onMidi(m)
if isNoteOff(m) then
for ch = 1, CHANNELS do
if holding[ch] == m.note then
holding[ch] = nil
m.channel = ch
m:to(chain)
break
end
end
return false
end
-- a note on: take the next free channel, starting where we left off
for i = 0, CHANNELS - 1 do
local ch = ((cursor - 1 + i) % CHANNELS) + 1
if not holding[ch] then
holding[ch] = m.note
cursor = (ch % CHANNELS) + 1
m.channel = ch
m:to(chain)
break
end
end
return false
endIf every channel is busy the note is simply dropped. Stealing the oldest voice instead is a few more lines, and a matter of taste.
Routing a SysEx dump to one instrument
Send each manufacturer's dumps to the instrument that understands them, without ever holding a dump in memory.
local owners = {
[0x41] = ports.midiIo1, -- Roland
[0x42] = ports.usbHost2, -- Korg
[0x43] = ports.midiIo2, -- Yamaha
}
function init()
router.watch{ sysex = 4, from = { ports.usbDev1 } }
end
function onSysex(m, first, last)
local destination = owners[m.header[2]]
if destination then
return m:claimStream(destination) -- the whole dump goes there
end
return m:passStream() -- anything else follows the matrix
endThe decision is made on the first packet and latched, so the script runs once per dump, however long the dump is. A three-byte manufacturer id (one starting with 00) is not complete in the first packet; holding that packet back until the id is known is shown in Deciding as it arrives.
A footswitch that reconfigures the rig
A pedal on the input changes what the router does, rather than being forwarded: one switch mutes the lead, another steps the transpose. The pedal never reaches an instrument.
function init()
router.params{ transpose = 0, mute = false }
router.watch{ types = { CONTROL_CHANGE }, from = { ports.usbDev1 } }
end
function onMidi(m)
if m.controller == 80 then
if m.value > 63 then
router.params.mute = not router.params.mute
router.emit("mute", router.params.mute)
end
return false -- the pedal itself is not forwarded
end
if m.controller == 81 and m.value > 63 then
router.params.transpose = router.params.transpose + 1
router.emit("transpose", router.params.transpose)
return false
end
-- a third switch is handed straight to the controller's own MIDI control
-- mappings, which is simpler when there is already a mapping for it
if m.controller == 82 and m.value > 63 then
m:to(ports.midiControl)
return false
end
return true
endWriting router.params from inside the router is local scratch — the preset side is the authority, and its next router.set() overwrites it. Emitting keeps the two in step; onRouterEvent receives mute as 1 or 0, because an event value is always a number.
Router Lua Extension API Reference
Ports
A port carries the read-only fields port.name, port.connected, port.interface and port.port, described in Ports and destinations, and the port send functions listed below. Those take the same arguments as their counterparts in the midi library, without the interface and port arguments, and none of them returns anything except port:send().
None of them checks its arguments: a channel outside 1 to 16 or a data byte above 127 is masked into range rather than refused. Each call counts as one send against the limit of 32.
Functions
A device that is not plugged in yet gives nil, so test the result before using it.
Parameters
Returns
Only m:to() takes a set. m:claimStream(), the port send functions and router.watch{ from = ... } all want ports themselves.
Parameters
Returns
Raises port:send: there is no message to send just now outside onMidi() and onSysex() — in a scheduled function and in onParam() there is no message. Any arguments are ignored.
Returns
Parameters
Parameters
Parameters
Parameters
Parameters
Parameters
Parameters
Router callbacks
The firmware calls these in router.lua, and a script defines only the ones it needs.
Functions
The usual place for router.watch(), router.params() and router.exclusive(), though none of them is restricted to it. An error raised here stops the router from starting at all.
A router script is a single file: require(), dofile(), loadfile() and load() are not available.
The message is passed to the next router and then to the routing matrix with whatever changes the hook made to it, unless the hook returns false. If the hook raises or runs out of budget, the message is passed on unchanged.
Parameters
Returns
A latch set during the call decides the packet in hand as well, whatever the hook returns.
Parameters
Returns
For routers that must act on a change rather than simply read the value — rebuilding a lookup table, or silencing an instrument when a mute comes on. It has its own instruction budget and its own allowance of 32 sends. There is no message, so port:send() raises; send with the port functions instead.
Parameters
Router functions
These are available in router.lua only.
Functions
types is a list of message type globals, such as NOTE_ON (see Message types); it replaces the whole set, and SYSEX in it raises. from is a list of ports; entries that are not ports are ignored. channels is a list of MIDI channel numbers, 1 to 16; other numbers are ignored. sysex is a number of header bytes (1 to 12), the string "packets", true (the same as "packets"), or false or "off" to stop watching SysEx.
Leaving types, from or channels out means no filtering on that property. Realtime messages, TUNE_REQUEST and system exclusive are never included unless asked for by name.
May be called at any time, not only from init(); the new filter applies from the next packet on.
Raises on a type given as a string, on an unknown type number, on a sysex number outside 1 to 12 and on a sysex string other than "packets" or "off".
Parameters
After this call, router.params is the very table you passed, which your hooks read from. A parameter declared with a number stays a number, and one declared with true or false stays a boolean, in the table, in onParam() and on the preset side.
Calling it again replaces the whole declaration. Usually called from init(), but not restricted to it.
Parameters
The queue holds 32 events and the application thread takes 8 per pass, so do not call this at note rate; events that do not fit are dropped and the loss is logged.
Parameters
Raises router: nothing more can be scheduled just now when all 32 slots are in use, and router: a delay cannot be negative for a negative delay.
Parameters
Returns
Parameters
Returns
Parameters
Returns
Returns
May be called at any time, not only from init().
Parameters
Parameters
seen counts the messages the hooks were called for, owned the ones the script took, sends every message it sent, errors every call that failed, and overruns the calls that failed because they ran out of instruction budget — a subset of errors.
Returns
Message
The m a hook is given is a live view of the MIDI packet it is handling. Fields, all readable, most writable:
| Field | Access | Description |
|---|---|---|
m.type | read | the message type, one of the message type globals |
m.source | read | the port it arrived on; a new port object on every read |
m.channel | read, write | 1 to 16, nil for messages with no channel. Writing it on one of those raises |
m.data1, m.data2 | read, write | the two data bytes, 0 to 127, whatever the message is |
m.note, m.velocity | read, write | the same two bytes, for note messages |
m.controller, m.value | read, write | the same two bytes, for control change |
m.program | read, write | the first data byte, for program change |
m.pressure | read, write | the first data byte for channel pressure, the second for polyphonic |
m.bend | read, write | pitch bend, −8192 to 8191 |
m.bytes | read | SysEx only: this packet's one to three bytes, 1-based. A new table on every read |
m.header | read | SysEx only: the bytes seen of this message so far, up to the number asked for in router.watch(), 1-based. A new table on every read |
m.raw | read, write | the raw four-byte packet as a number. Writing it is not checked |
m.cin | read | the USB MIDI code index number, 0 to 15 |
The named fields do not check the message type: m.note on a control change is the controller number. Reading a name that is not a field gives nil; writing one raises.
Functions
Parameters
Returns
Parameters
Returns
The packet in hand counts as one send per destination; the packets that follow are forwarded without being counted. Raises outside onSysex().
Parameters
Returns
Returns
Returns
Preset router table
These are available in a preset's main.lua, not in router.lua, together with the onRouterEvent() callback the preset defines to hear from its router.
Functions
Raises when this preset's router has no parameter of that name — which includes the whole time before the router has started, so a typo is reported rather than quietly ignored. Use router.isActive() to tell the two apart.
Parameters
Parameters
Returns
Returns
Returns
The request is handled on the router thread, so the restart has not happened yet when the call returns. Mostly useful while developing.
Returns
Parameters
Globals
The globals a router script has are the MIDI message types.
Message types
Used by m.type, m:is() and router.watch{ types = ... }. They are the same globals, with the same values, as in preset Lua: each is the message's status byte without its channel, so m.type == NOTE_ON for a note on any channel. Being numbers, they compare without any string handling.
| Global | Value | Message | In the default watch |
|---|---|---|---|
NOTE_OFF | 128 | Note Off | yes |
NOTE_ON | 144 | Note On | yes |
POLY_PRESSURE | 160 | Polyphonic Aftertouch | yes |
CONTROL_CHANGE | 176 | Control Change | yes |
PROGRAM_CHANGE | 192 | Program Change | yes |
CHANNEL_PRESSURE | 208 | Channel Aftertouch | yes |
PITCH_BEND | 224 | Pitch Bend | yes |
SYSEX | 240 | System Exclusive | no, asked for with sysex |
TIME_CODE_QUARTER_FRAME | 241 | MIDI Time Code Quarter Frame | yes |
SONG_POSITION | 242 | Song Position Pointer | yes |
SONG_SELECT | 243 | Song Select | yes |
TUNE_REQUEST | 246 | Tune Request | no |
CLOCK | 248 | Timing Clock | no |
START | 250 | Start | no |
CONTINUE | 251 | Continue | no |
STOP | 252 | Stop | no |
ACTIVE_SENSING | 254 | Active Sensing | no |
RESET | 255 | System Reset | no |
The trap in the default watch is the last three marked yes: TIME_CODE_QUARTER_FRAME, SONG_POSITION and SONG_SELECT have no channel, and writing m.channel on one raises.
SYSEX is what m.type reads inside onSysex(). It is not accepted in router.watch{ types = ... }: SysEx is asked for with the sysex key, which also says how many header bytes to keep. A type written as a string, such as "noteOn", raises an error.
What is not built yet
The design calls for these and the firmware does not have them. They are listed so that a script written today does not reach for one by accident.
| What it would do | |
|---|---|
ports.remote | let a router move a remote knob. ports.preset and ports.midiControl are built |
| saved parameters | keep router parameters with the preset's state, so they survive switching presets and restarting |
router.next(m) | run the rest of the chain from inside a hook and use its answer |
router.scope("devices") | set the watch filters from the preset's own device list |
router.trace(m) | show a packet in the MIDI monitor, marked as script-touched |
onPortChange(port, connected) | tell a script when a USB host device is plugged in or removed |
onUnload() | tell a script that its router is about to stop |
port:sendSysex(), sendNrpn, sendRpn, sendControlChange14Bit, the transport sends | generate those from a router; the channel voice sends all work |
Two behaviours are worth knowing about as well.
A router runs after the preset has already received the message, so return false stops the routing matrix but does not stop the preset's own controls from reacting, and a change the script makes is not seen by the preset. Making a router come first means moving the hook ahead of the preset dispatch, which changes what an existing preset does and so waits until this has been on real hardware for a while.
There is no onOutput(). A router sees what arrives, not what the Electra One sends.
See also
- Preset Lua Extension — preset Lua, and the
schedulelibrary used in the examples - Config JSON — the routing matrix and the
presetRoutersswitch - File Transfer SysEx API — uploading
router.lua