Skip to content

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 LuaRouter Lua
Script filemain.luarouter.lua
Runs onapplication thread (priority 8)router thread (priority 3)
Position in the MIDI pathafter the message is delivered to the preset, through a queuein front of the routing matrix, straight from the MIDI thread
Time from message to your code~0.5 ms, up to 80 ms when busyshort and steady, not held up by the preset or the display
What your code receivesa parsed MIDI messagea raw MIDI packet
Read and change controls, values, pagesyesno
Draw on the displayyesno
Read and write the parameter mapyesno
Decide where a message goesnoyes
Stop a message from being forwardednoyes
Change a message before it is forwardednoyes
Hide a message from the presetnono
Held up by a slow repaintyesnever
Runs while the preset is pinned in the backgroundyesyes

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:

  1. the MIDI parser, the status bar indicator, the clock tracker and the held-note tracker
  2. capture recording of incoming messages
  3. the remote knobs, which consume the messages routed to them
  4. MIDI control, which consumes the messages routed to it
  5. the morph and program change handling on the CTRL port, and capture trigger notes
  6. the presets: devices, the parameter map, the controls, then the preset's own midi.onX callbacks, which are queued for the application thread

Only then is the packet handed to the router thread, where two things run, in order:

  1. the router chain — every loaded router script, most recently started first
  2. 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 false stops 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 Lua

You 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.lua

It 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:

json
{
  "router": {
    "lua": {
      "presetRouters": true,
      "budget": 20000
    },
    "routes": []
  }
}
  • presetRouters — when false or missing, router.lua files 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 doWhat happens to the router
Load a preset that has a router.luait starts: the chunk runs, then init()
Switch to another presetit stops, unless the preset was pinned
Pin a preset and switch awayit keeps running in the background
Switch back to a pinned presetit keeps running, with its variables, parameters and timers intact
Reload the presetit is stopped and started again from the top
Call router.reload() from main.luathe same: stopped, router.lua read again, started from the top
Upload a new router.luanothing, until the preset is reloaded, re-entered, or router.reload() is called
Upload a new main.lua to the running presetthe preset's script is replaced and its router is started again from the top
Switch preset routers off in the configurationevery router stops at once
The script fails 16 times in a rowthat 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

lua
-- 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
end

Three 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:

lua
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
end

Only 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 wantHow to write it
Keep a message from being forwardedreturn false
Change what the matrix forwardschange a field, return true
Send it somewhere extra as wellm:to(port), return true
Send it somewhere and nowhere elsem:to(port), return false
Send it to several places, differentlychange, 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:

lua
m.channel = 3; m:to(ports.midiIo1)
m.channel = 9; m:to(ports.usbHost2)
return false

If 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.

lua
function init()
  router.watch{
    types    = { NOTE_ON, NOTE_OFF, CONTROL_CHANGE },
    from     = { ports.midiIo1, ports.usbHost1 },
    channels = { 1, 2, 10 },
  }
end

Anything that does not match goes straight on its way without your script being called.

SettingWhat it takesIf you leave it out
typesa list of message type globalsall channel voice messages, plus song position, song select and time code quarter frame
froma list of portsevery interface and port
channelsa list of 1 to 16all sixteen
sysexa 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_SENSING or RESET to types only 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:

  • types replaces 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 by sysex.
  • A key you leave out keeps its current value. router.watch edits the filter rather than replacing it, so you can narrow it in two calls.
  • router.watch is not restricted to init(). Call it whenever you like; it applies from the next packet on.
  • channels only affects messages that have a channel. Everything else passes the channel test.
  • An entry in from that is not a port is ignored, and a port set built with ports.set() is not a port — put the ports themselves in the list. If nothing in from is a port, the filter matches nothing.
  • SYSEX in types raises. SysEx is asked for with the sysex key.
  • 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

lua
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
end

The general fields work for every message:

FieldMeaning
m.typeNOTE_ON, CONTROL_CHANGE, PROGRAM_CHANGE, and so on
m.sourcethe port it arrived on
m.channel1 to 16, or nil for messages that have no channel
m.data1, m.data2the two data bytes, whatever the message is

The named fields are easier to read, and are the same two bytes under another name:

FieldReads and writes
m.note, m.velocitynote on, note off
m.controller, m.valuecontrol change
m.programprogram change
m.pressurechannel and polyphonic aftertouch
m.bendpitch 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:

WriteRangeError when it is outside
m.channel1 to 16router: channel is out of range: 17
m.channel on a message that has nonerouter: a SONG_POSITION has no channel
m.data1, m.note, m.controller, m.program0 to 127router: note is out of range: 130
m.data2, m.velocity, m.value0 to 127router: velocity is out of range: -1
m.pressure0 to 127router: pressure is out of range: 200
m.bend−8192 to 8191router: bend is out of range: 9000
any other namerouter: a message has no field called 'foo'

So guard arithmetic before you write it back:

lua
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 = note

A 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.

NameWhat it is
ports.midiIo1, ports.midiIo2the DIN sockets
ports.usbDev1, ports.usbDev2USB device ports 1 and 2, as your computer sees them
ports.ctrlthe Electra One control port, USB device port 3
ports.usbHost1, ports.usbHost2USB host ports 1 and 2
ports.allport 1 of every interface: MIDI IO port 1, USB device port 1 and USB host port 1
ports.presetthe controller's own preset handling, see The Electra One as a destination
ports.midiControlthe 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:

lua
if m.source == ports.midiIo1 then
  ...
end

A port also carries four read-only fields:

FieldValue
port.name"midiIo/port1", "midiUsbDev/port3", "midiUsbHost/port2", "midiAll/port1", "preset", "midiControl"
port.connectedtrue for everything except a USB host port with no device assigned to it
port.interfacethe interface number: 0 MIDI IO, 1 USB device, 2 USB host, 3 all, 240 preset, 241 MIDI control
port.portthe 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:

lua
local rev2 = ports.find("Rev2")

if rev2 then
  m:to(rev2)
end

It 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:

lua
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:

lua
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.

PortWhat it does
ports.presetthe message, as the script has changed it, enters normal preset handling — devices, parameter map, the control that listens for it
ports.midiControlthe 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:

lua
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
end

A 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.preset arrives as though it came from a MIDI IO port — the DIN socket whose number matches the cable the original arrived on, so a message from ports.usbDev2 appears 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 own midi.onX callbacks, the clock tracker and the status bar are not run for it.
  • A message sent to ports.midiControl goes 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:

lua
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:

lua
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()
end

Your 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:

FunctionEffect on the rest of the messageReturns
m:claimStream(port, …)forward it all to those ports, up to four of themfalse
m:passStream()hand it all to the routing matrixtrue
m:dropStream()throw it all awayfalse

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.

lua
-- 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)
end

A few more rules of the stream handling:

  • A stream starts at the packet carrying F0. Packets of a message whose F0 was 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 F0 is treated as a fragment.
  • from narrows system exclusive the same way it narrows everything else: a script watching one input is not shown dumps arriving on another. channels and types have 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.header is {} outside onSysex(), and m.bytes is {} for anything that is not SysEx.
  • first is true on the packet carrying the F0; last is true on the packet that ends the message.
  • If onSysex is not defined, SysEx goes to the matrix whatever router.watch asked 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:

lua
-- 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
end

router.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:

lua
-- main.lua
function onTransposeChange(control, value)
  router.set("transpose", value - 64)
end

function onMutePad(control, value)
  router.set("mute", value > 0)
end

When 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:

lua
-- router.lua
router.emit("voices", voicesInUse)
lua
-- main.lua
function onRouterEvent(name, value)
  if name == "voices" then
    controls.get(VOICE_METER):getValue("value"):overrideValue(value)
  end
end

onRouterEvent 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:

lua
-- 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
end

router.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.

FunctionWhat 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.

lua
-- 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
FunctionWhat 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:

lua
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:

lua
-- No router behind this one in the chain runs
function init()
  router.exclusive()
end

router.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.

LimitValue
Instructions per call into the scriptrouter.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 onParam32
Failures in a row before the router stops16
Parameters, and the length of a name16, 20 characters
Scheduled functions32
SysEx streams at once9
Destinations for m:claimStream()4
Header bytes kept12
Routers running at once8
Event queue to the preset32, 8 delivered per application pass
router.log() linesabout 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.

lua
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
end

Every 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.

lua
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
end

Only 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.

lua
-- /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
lua
-- /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)
end

mute 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.

lua
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
end

Note 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.

lua
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
end

Here 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.

lua
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
end

Clock 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.

lua
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
end

The 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.

lua
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
end

Round-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.

lua
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
end

If 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.

lua
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
end

The 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.

lua
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
end

Writing 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

ports.find(pattern)
Looks through the connected USB host devices for one whose product name contains `pattern`, and returns the USB host port that device's first cable is assigned to. Sending to that port reaches every USB instrument assigned to it.

A device that is not plugged in yet gives nil, so test the result before using it.

Parameters
pattern
string, text contained in the product name of a USB host device. Case sensitive.
Returns
a port, or nil when no matching device is connected.
ports.set(port, ...)
Groups several destinations so that one `m:to()` reaches all of them.

Only m:to() takes a set. m:claimStream(), the port send functions and router.watch{ from = ... } all want ports themselves.

Parameters
port
a port. As many as needed may be given. Anything that is not a port raises.
Returns
a port set: a plain Lua array of ports, which m:to() accepts.
<port>:send()
Sends the message the hook is handling, as it stands right now, to this port. The same as `m:to(port)`, written the other way round.

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
the port, so that calls can be chained.
<port>:sendNoteOn(channel, note, velocity)
Sends a Note On message to this port.
Parameters
channel
number, the MIDI channel, 1 to 16.
note
number, the note number, 0 to 127.
velocity
number, the velocity, 0 to 127.
<port>:sendNoteOff(channel, note [, velocity])
Sends a Note Off message to this port.
Parameters
channel
number, the MIDI channel, 1 to 16.
note
number, the note number, 0 to 127.
velocity
number, the release velocity, 0 to 127. Optional, 0 when left out.
<port>:sendControlChange(channel, controller, value)
Sends a Control Change message to this port.
Parameters
channel
number, the MIDI channel, 1 to 16.
controller
number, the controller number, 0 to 127.
value
number, the controller value, 0 to 127.
<port>:sendProgramChange(channel, program)
Sends a Program Change message to this port.
Parameters
channel
number, the MIDI channel, 1 to 16.
program
number, the program number, 0 to 127.
<port>:sendPitchBend(channel, value)
Sends a Pitch Bend message to this port. The value is the signed bend, the same as `m.bend` reads.
Parameters
channel
number, the MIDI channel, 1 to 16.
value
number, the bend, -8192 to 8191, where 0 is centre.
<port>:sendAfterTouchChannel(channel, pressure)
Sends a Channel Pressure (channel aftertouch) message to this port.
Parameters
channel
number, the MIDI channel, 1 to 16.
pressure
number, the pressure, 0 to 127.
<port>:sendAfterTouchPoly(channel, note, pressure)
Sends a Polyphonic Pressure (polyphonic aftertouch) message to this port.
Parameters
channel
number, the MIDI channel, 1 to 16.
note
number, the note number, 0 to 127.
pressure
number, the pressure, 0 to 127.

Router callbacks

The firmware calls these in router.lua, and a script defines only the ones it needs.

Functions

init()
Called once, when the router starts: on loading the preset, on reloading it, after `router.reload()`, and after a new `main.lua` is uploaded to the running preset. Switching back to a pinned preset does **not** call it again, because that router never stopped.

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.

onMidi(m)
Called for every message that passes the watch filters, on the router thread. The main hook.

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
m
the message, see the Message section below. Valid only for the duration of the call.
Returns
the boolean false to take ownership of the message; anything else, including nil and true, passes it on.
onSysex(m, first, last)
Called for **each packet** of a system exclusive message, until the script latches a decision with `m:claimStream()`, `m:passStream()` or `m:dropStream()`. Only called when `router.watch()` asked for SysEx with the `sysex` key.

A latch set during the call decides the packet in hand as well, whatever the hook returns.

Parameters
m
the message; m.bytes is this packet, m.header is the message so far.
first
boolean, true on the packet carrying the F0.
last
boolean, true on the packet that ends the message.
Returns
false to own this packet, anything else to pass it on; or the result of one of the stream functions.
onParam(name, value)
Called on the router thread when a parameter is changed from the preset side, before the next message is handled. `router.params` has already been updated when it runs.

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
name
string, the parameter that changed.
value
number or boolean, its new value, in the type the parameter was declared with.

Router functions

These are available in router.lua only.

Functions

router.watch(options)
Declares which messages the script wants to see. Messages that do not match are passed on without calling the script at all.

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
options
table, with optional keys types, from, channels and sysex. A key left out keeps its current setting.
router.params(defaults)
Declares the parameters this router accepts and their default values. The set of names is fixed by this call: the preset side can set these and nothing else.

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
defaults
table, parameter names with their starting values. Numbers and booleans; at most 16 entries, names cut to 20 characters.
router.emit(name [, value])
Reports a value to the preset side, where the preset's `onRouterEvent()` receives it as a number. Delivered on the application thread.

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
name
string, the name to report under, cut to 20 characters.
value
number or boolean, the value to report. Optional, 0 when left out. A boolean is delivered as 1 or 0.
router.after(ms, fn, ...)
Runs a function once, after a delay, on the router thread.

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
ms
number, milliseconds to wait. Must not be negative.
fn
function to run.
...
any further arguments are passed to fn.
Returns
number, a handle that can be given to router.cancel().
router.every(ms, fn, ...)
Runs a function repeatedly until it is cancelled. The next run is due a period after the last one finished, so a late run delays the ones after it. A run that raises drops the entry.
Parameters
ms
number, milliseconds between runs. Anything below 1 is treated as 1.
fn
function to run.
...
any further arguments are passed to fn.
Returns
number, a handle that can be given to router.cancel().
router.cancel(handle)
Cancels a scheduled function. A scheduled function may cancel itself and schedule another in the same call.
Parameters
handle
number, a handle returned by router.after() or router.every().
Returns
boolean, true when something was cancelled.
router.now()
A steadily increasing millisecond counter, for measuring intervals.
Returns
number, milliseconds since the controller started.
router.exclusive([on])
Stops the chain after this router: no router behind it is asked about a packet, whether or not this one wanted it. Routers **ahead** of it — presets activated more recently — still run first.

May be called at any time, not only from init().

Parameters
on
number, optional. Anything but 0 switches it on, 0 switches it off. Defaults to 1. A boolean raises.
router.log(message)
Writes a line to the controller log. Rate limited to one line every 200 ms — about five a second — shared by every running router, because a line logged from a hook is logged at whatever rate MIDI arrives. Lines that do not fit the rate are dropped silently.
Parameters
message
string, the line to write. Use string.format() to build one. A number is accepted and converted.
router.stats()
Reports what this router has done since it started. Useful with `router.emit()` to put the numbers on screen.

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
table, with the keys seen, owned, sends, overruns and errors.

Message

The m a hook is given is a live view of the MIDI packet it is handling. Fields, all readable, most writable:

FieldAccessDescription
m.typereadthe message type, one of the message type globals
m.sourcereadthe port it arrived on; a new port object on every read
m.channelread, write1 to 16, nil for messages with no channel. Writing it on one of those raises
m.data1, m.data2read, writethe two data bytes, 0 to 127, whatever the message is
m.note, m.velocityread, writethe same two bytes, for note messages
m.controller, m.valueread, writethe same two bytes, for control change
m.programread, writethe first data byte, for program change
m.pressureread, writethe first data byte for channel pressure, the second for polyphonic
m.bendread, writepitch bend, −8192 to 8191
m.bytesreadSysEx only: this packet's one to three bytes, 1-based. A new table on every read
m.headerreadSysEx 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.rawread, writethe raw four-byte packet as a number. Writing it is not checked
m.cinreadthe 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

m:to(destination, ...)
Sends the message as it stands right now. Later changes to the message do not affect what was already sent. Each port counts as one send against the limit of 32.
Parameters
destination
a port, or a port set built with ports.set(). More than one may be given. Anything that is not a port or a table raises.
Returns
the message, so that calls can be chained.
m:is(type, ...)
Tests the message type.
Parameters
type
a message type global, such as NOTE_ON. More than one may be given. A string raises.
Returns
boolean, true when the message is one of the given types.
m:reset()
Undoes every change made to the message in this hook, restoring it as it arrived. Messages already sent are not affected.
m:claimStream(destination, ...)
SysEx only. Forwards the rest of this message to the given destinations, without calling the script again for it, and sends the packet in hand to them as well.

The packet in hand counts as one send per destination; the packets that follow are forwarded without being counted. Raises outside onSysex().

Parameters
destination
a port. Up to four may be given; further ones are ignored. Anything that is not a port raises.
Returns
false, so that it can be returned directly from onSysex().
m:passStream()
SysEx only. Passes the rest of this message on to the routing matrix, without calling the script again for it. Raises outside `onSysex()`.
Returns
true, so that it can be returned directly from onSysex().
m:dropStream()
SysEx only. Discards the rest of this message. Raises outside `onSysex()`.
Returns
false, so that it can be returned directly from onSysex().

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

router.set(name, value)
Sets a parameter of this preset's router. Returns immediately; the router applies the change before it handles its next message, and calls its `onParam()` if it has one.

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
name
string, a parameter this preset's router declared.
value
number or boolean. A parameter declared with true or false reads back as a boolean.
router.get(name)
Reads a parameter's current value.
Parameters
name
string, a parameter name.
Returns
number or boolean, in the type the router declared; nil when there is no such parameter or the router is not running.
router.params()
Useful for putting a router's whole state on screen, and for finding out what a router you did not write accepts. Each value has the type the router declared it with.
Returns
table, every parameter name with its current value. Empty when the router is not running.
router.isActive()
False when the preset has no `router.lua`, when preset routers are switched off in the configuration, when the script failed to load, when it stopped itself after 16 failures in a row, and in the moments between the preset coming up and its router starting — which includes the whole of `onLoad()`, `onReady()` and `onEnter()`.
Returns
boolean, true when this preset's router is loaded and running.
router.reload()
Re-reads `router.lua` and restarts the router, leaving the preset alone. Its parameters go back to what `init()` declares, anything the script was keeping in its own variables is lost, and everything it had scheduled is cancelled. The router also moves to the front of the chain.

The request is handled on the router thread, so the restart has not happened yet when the call returns. Mostly useful while developing.

Returns
boolean, true when the request was made.
onRouterEvent(name, value)
A preset callback, defined in `main.lua` like `onNoteOn`. Called on the application thread whenever this preset's router calls `router.emit()`, so it may do anything a preset script may do. Up to 8 events are delivered per pass of the application loop.
Parameters
name
string, the name the router emitted under.
value
number, the value it reported. A boolean emitted by the router arrives as 1 or 0.

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.

GlobalValueMessageIn the default watch
NOTE_OFF128Note Offyes
NOTE_ON144Note Onyes
POLY_PRESSURE160Polyphonic Aftertouchyes
CONTROL_CHANGE176Control Changeyes
PROGRAM_CHANGE192Program Changeyes
CHANNEL_PRESSURE208Channel Aftertouchyes
PITCH_BEND224Pitch Bendyes
SYSEX240System Exclusiveno, asked for with sysex
TIME_CODE_QUARTER_FRAME241MIDI Time Code Quarter Frameyes
SONG_POSITION242Song Position Pointeryes
SONG_SELECT243Song Selectyes
TUNE_REQUEST246Tune Requestno
CLOCK248Timing Clockno
START250Startno
CONTINUE251Continueno
STOP252Stopno
ACTIVE_SENSING254Active Sensingno
RESET255System Resetno

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.remotelet a router move a remote knob. ports.preset and ports.midiControl are built
saved parameterskeep 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 sendsgenerate 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

Electra One proudly uses Lua and ArduinoJson.
For support contact info@electra.one · © 2019-2026 Electra One