midi-daemon
A Lua-scriptable MIDI routing daemon for Linux. Each .lua file in routes.d/
gets its own pair of virtual ALSA MIDI ports and a configurable BPM timer.
Drop in or edit a .lua file and the daemon hot-reloads it automatically.
Requirements
- Rust (stable)
- ALSA development headers:
sudo pacman -S alsa-lib
Build
Install
Per-user install
Config and routes live in ~/.config/midi-daemon/. The daemon runs under
your own account as a systemd user service.
# Install binary
# Create config and routes directories
# Copy example config and routes
# Install and enable systemd user service
System-wide install
Config and routes live in /etc/midi-daemon/. The daemon runs as a
dedicated midi-daemon system user.
# Install binary
# Create a dedicated system user
# Add it to the audio group so it can access ALSA/PipeWire
# Create config and routes directories
# Copy example config and routes
# Install and enable systemd system service
Usage
Per-user
Add or remove a route — the daemon hot-reloads automatically:
Edit config.toml — the daemon detects the change and reloads all routes
with the updated configuration automatically (no restart needed).
System-wide
Edit config.toml — the daemon detects the change and reloads all routes
with the updated configuration automatically (no restart needed).
Lua API
Each script can define these callback functions:
-- Optional: declare named ports and auto-connect patterns (see below).
-- Called once at startup before on_midi/on_tick.
-- Called on every timer tick
-- tick: monotonically increasing tick counter
-- bpm: current BPM (float)
-- ppqn: current pulses per quarter note
-- Called on every incoming MIDI message on this route's input port.
-- msg.port holds the input port name when the route has multiple inputs.
-- Other msg fields vary by type (see below).
Named ports via init()
By default each route gets one input and one output port. Return a table from
init() to declare multiple named ports:
ALSA ports created (visible in aconnect -l):
midi-daemon:my-route/keyboard-in
midi-daemon:my-route/pad-in
midi-daemon:my-route/synth-out
midi-daemon:my-route/drums-out
In on_midi, msg.port tells you which input fired. In send, the first
argument selects the output:
msg table fields by type
| type | fields |
|---|---|
note_on |
channel, note, velocity |
note_off |
channel, note, velocity |
cc |
channel, controller, value |
program_change |
channel, program |
pitch_bend |
channel, value (-8192..8191) |
clock |
(no extra fields) |
start |
(no extra fields) |
stop |
(no extra fields) |
continue |
(no extra fields) |
raw |
data (1-indexed byte array) |
Global functions available in Lua
send -- Send msg to the first/only output port
send -- Send msg to a named output port (multi-port routes)
send_osc -- Send OSC to the only configured target
send_osc -- Send OSC to a named target
set_bpm -- Set timer BPM (float)
get_bpm -- Get current BPM (float)
set_ppqn -- Set pulses per quarter note (integer)
get_ppqn -- Get current PPQN (integer)
log -- Log a string to the systemd journal / stdout
Global variables
ROUTE_NAME -- string: the route's filename stem, e.g. "metronome" for metronome.lua
OSC_SEND_ENABLED -- bool: true when a send target is configured (global or per-route)
Useful for building OSC address prefixes that automatically match the route name:
send_osc
on_osc = osc_params
Stdlib helpers
The following helpers are available in every route without any require or dofile.
msg.from
Every on_osc message includes msg.from = "ip:port" — the sender's UDP
address. Use it for direct replies outside of osc_params, or pass it to
send_osc for ad-hoc responses.
send_osc calling forms
send_osc -- address-first: uses sole named target
send_osc -- named target
send_osc -- ad-hoc IP:port (subscriber replies)
The ad-hoc form is what osc_params uses internally for subscriber
notifications — it requires only that any OSC receive is active (no named
send target needed).
OSC support
Routes can receive and send OSC (Open Sound
Control) messages over UDP. OSC receive/send is declared in the table returned by
init() using the osc key.
Unified MIDI + OSC parameter dispatch via osc.params
Add a params subtable inside osc to declare named parameters that respond
to both OSC messages and MIDI — without writing on_midi or on_osc
callbacks by hand. Each parameter has optional set / get functions and an
optional midi array of MIDI bindings.
MIDI address (routing key) and payload by message type:
type |
Address fields | Payload field | Raw range |
|---|---|---|---|
cc |
channel + controller |
value |
0–127 |
note_on |
channel + note |
velocity |
0–127 |
note_off |
channel + note |
velocity |
0–127 |
program_change |
channel |
program |
0–127 |
pitch_bend |
channel |
value |
−8192..8191 |
start / stop / continue / clock |
(type alone) | (no-arg trigger) | — |
Value modifiers on each binding:
| Field | Effect |
|---|---|
| (none) | Pass raw MIDI value to set() unchanged |
scale = {min, max} |
Linearly map the full payload range to [min, max] |
threshold = N |
raw ≥ N → set(1.0), raw < N → set(0.0) |
When a MIDI message matches a param binding, the daemon calls set() and then
notifies all OSC subscribers via get() — identical to what happens when the
same param is updated over OSC. Hardware knob changes are automatically
reflected on connected TouchOSC / Lemur panels.
on_midi and on_osc are still called after param dispatch and can coexist
with params for any logic that doesn't map cleanly to a single parameter.
Automatically handled OSC addresses (no entries needed in params):
| Address | Arguments | Effect |
|---|---|---|
prefix/subscribe |
[port [timeout_secs]] |
Register (or renew) sender; sends current state of all get-able params immediately |
prefix/unsubscribe |
[port] |
Remove subscriber immediately |
prefix/heartbeat |
— | Sent by the daemon to subscribers at the configured osc_heartbeat_interval (default 5 s) |
OSC dispatch rules for prefix/<param>:
| Message | get |
set |
Action |
|---|---|---|---|
| no arguments | yes | — | calls get(), replies to msg.from only |
| no arguments | no | yes | calls set() |
| with arguments | — | yes | calls set(v…), then notifies all subscribers via get() |
Post-set notification uses get() rather than echoing the raw args, so
clamped or coerced values are always what gets reported.
on_osc(msg) callback
Called for every incoming OSC message. msg contains:
| Field | Type | Description |
|---|---|---|
address |
string | OSC address pattern, e.g. "/note/on" |
args |
table (1-indexed) | Typed argument values |
OSC type mapping (received)
| OSC type | Lua value |
|---|---|
Int32 |
integer |
Int64 |
integer |
Float32 |
number |
Float64 |
number |
String |
string |
Blob |
string (raw bytes) |
Bool |
boolean |
Nil |
nil |
Inf |
math.huge |
Char |
string (single character) |
Time |
number (NTP seconds as float) |
Color |
table { r, g, b, a } (0–255 each) |
Array |
table (1-indexed, recursive) |
Midi |
table { port, status, data1, data2 } |
send_osc function
-- Single target: omit the target name
send_osc
-- Multiple targets: name the target
send_osc
When only one target is configured its name can be omitted and send_osc takes
the OSC address as the first argument (detected by the leading /). When
multiple targets are configured the target name must come first.
OSC argument type mapping (sent)
| Lua type | OSC type |
|---|---|
| integer | Int32 |
| number | Float32 |
| string | String |
| boolean | Bool |
| nil | Nil |
Configuring OSC ports from config.toml
Expose the address or port through the route's config table so it can be
changed without editing the script:
[]
= 9000
= "127.0.0.1:9001"
config.toml
Config is split into two parts: global defaults (top-level keys) and
per-route sections ([route-name]). A route's Lua init() can override
anything declared in either place, so the priority order is:
global defaults < per-route [section] values < init() return value
The daemon searches for a config file in this order:
$MIDI_DAEMON_CONFIG— explicit path via environment variable~/.config/midi-daemon/config.toml— per-user/etc/midi-daemon/config.toml— system-wide- Built-in defaults (routes dir inferred from whichever scope applies)
# Path to routes directory.
# Default: <config-dir>/routes.d (user or system, whichever was loaded)
# routes_dir = "/custom/path"
= 120.0
= 24
# Auto-connect: regex matched against "ClientName:PortName" of ALSA ports.
# Applied to every route input/output that has no per-route pattern.
# default_connect_input = ".*My Keyboard.*"
# default_connect_output = ".*My Synth.*"
# Global OSC root — one UDP port shared by all routes.
# Incoming messages are dispatched by address prefix: /route-name/... → that route.
# All routes' send_osc() calls go to osc_send_addr unless the route declares its own
# osc.send target in init().
# osc_receive_port = 9000
# osc_send_addr = "127.0.0.1:9001"
# How often (in seconds) the daemon sends /route/heartbeat to OSC subscribers.
# osc_heartbeat_interval = 5.0
Changes to config.toml are picked up automatically and all routes are
reloaded with the new values. The one exception is routes_dir — changing
it requires a daemon restart.
Per-route configuration
Add a TOML section named after the route file (without .lua) to pass
configuration into that route's config global table:
[]
= "value"
= 42
In my-route.lua:
local value = config. or "default"
local num = config. or 0
Any TOML type is supported: strings, integers, floats, booleans, arrays, and nested tables.
Auto-connect
The daemon can automatically wire its virtual ALSA ports to physical or
software devices when it starts, and also when a device is plugged in later.
Patterns are regular expressions matched against the full ALSA address string
"ClientName:PortName" (the same strings shown by aconnect -l).
There are three levels of configuration, applied from highest to lowest priority:
1. Per-port — init() in Lua
The most specific level. Use the connect field in the table returned by
init():
connect.inputs / connect.outputs are tables of port_name = "pattern".
connect.input / connect.output (singular) apply to every input or output
of that route.
2. Per-route — config.toml
Patterns set here override the global default and are overridden by Lua init().
All ports of a route — one pattern for every input, one for every output:
[]
= ".*KeyLab.*"
= ".*Surge.*"
Individual named ports — use connect_{portname}-in / connect_{portname}-out
(where portname matches the port name declared in init()):
[]
= ".*A-PRO 2.*"
= ".*metronome-out.*"
= ".*MyPlugin.*"
When any per-port or per-route connect pattern is present for a route, the
global default_connect_input / default_connect_output is not applied to
that route at all.
3. Global default — config.toml
Applies to every port of every route that has no higher-priority pattern. The simplest option when a single controller drives all routes.
= ".*KeyLab Essential.*"
= ".*Surge XT.*"
Hot-plug
A background thread subscribes to ALSA sequencer announcements. When a device appears after the daemon has started, any matching route ports are connected to it automatically — no restart needed.
Example: Simple Metronome
See routes.d/metronome.lua. Plays GM percussion clicks and accepts
configurable MIDI control signals to start/stop playback and change BPM in
real time.
Configurable via [metronome] in config.toml:
| Key | Default | Description |
|---|---|---|
bpm |
120.0 | Initial BPM |
ppqn |
24 | Pulses per quarter note |
beat_1_note |
37 | MIDI note for beat 1 (GM: Side Stick) |
beat_n_note |
56 | MIDI note for other beats (GM: Cowbell) |
channel |
10 | MIDI output channel (GM: percussion) |
velocity |
100 | Note velocity |
beats_per_bar |
4 | Beats per bar |
note_len_ms |
20 | Note duration in ms (fixed, independent of BPM) |
cc_type |
"cc" |
Incoming message type that controls BPM |
cc_channel |
1 | Incoming MIDI channel that controls BPM |
cc_controller |
21 | CC controller number that controls BPM |
start_stop_channel |
1 | MIDI channel for the start/stop CC |
start_stop_controller |
22 | CC controller that starts/stops the metronome |
start_running |
true |
Whether the metronome starts playing immediately on launch |
osc_send_addr |
(none) | UDP "host:port" to send beat/transport OSC messages to |
osc_receive_port |
(none) | UDP port to listen on for incoming OSC control messages |
BPM is clamped to the range 20–200 regardless of source.
Metronome OSC interface
When osc_send_addr is configured, the metronome emits:
| Message | Arguments | When |
|---|---|---|
/metronome/beat |
beat (int), beats_per_bar (int), bpm (float) | Every quarter-note click |
/metronome/running |
1 or 0 | On any start/stop transition |
When osc_receive_port is configured, the metronome responds to:
| Message | Argument | Effect |
|---|---|---|
/metronome/bpm |
value (float/int) | Set BPM, same 20–200 range as the CC input |
/metronome/start |
— | Reset to beat 1 and start (same as MIDI Transport Start) |
/metronome/stop |
— | Stop and reset (same as MIDI Transport Stop) |
/metronome/continue |
— | Resume from current position (same as MIDI Transport Continue) |
Example config.toml for a TouchOSC or Lemur controller on the same machine:
[]
= 9000
= "127.0.0.1:9001"
The start/stop CC uses the value to determine state: value ≥ 64 starts the metronome, value < 64 stops it.
MIDI Transport messages are also honoured:
| Message | Behaviour |
|---|---|
start |
Reset to beat 1 and begin playing (re-syncs if running) |
continue |
Resume from the current beat position without resetting |
stop |
Stop and reset beat position to beat 1 |
Example: Invert Controllers
See routes.d/invert_controllers.lua. Forwards all MIDI, inverting the
value (0–127 → 127–0) for a configured set of controllers.
Configurable via [invert_controllers] in config.toml:
| Key | Default | Description |
|---|---|---|
type |
"cc" |
Message type to match |
channel |
1 | MIDI channel to match |
controllers |
[] |
List of controller numbers to invert |
[]
= "cc"
= 1
= [7, 11] # volume, expression
Example: Keyboard Split
See routes.d/keyboard-split.lua. Splits a single keyboard at a configurable
note: notes below go to a "bass" output, notes at or above go to a "lead"
output. Non-note messages (CC, pitch bend, …) are broadcast to both. Demonstrates
per-port auto-connect driven from config.toml.
[]
= 60 # split at middle C (C4)
= ".*KeyLab.*" # auto-connect keyboard input on startup
= ".*ZynAddSubFX.*"
= ".*Surge.*"
The connect_* keys are all optional — omit any you don't need and connect
that port manually with aconnect, or rely on default_connect_input /
default_connect_output in the top-level config.
Example: OSC Bridge
See routes.d/osc-bridge.lua. Listens for OSC messages on UDP port 9000 and
maps /note/on <note> <vel> and /note/off <note> to MIDI note events.
Conversely, incoming MIDI note events are forwarded as /midi/note_on and
/midi/note_off OSC messages to 127.0.0.1:9001.
Quick loopback test:
&
Example: Transpose
See routes.d/transpose.lua. Shifts all notes up by a configurable interval,
passes everything else through unchanged.
Example: Timing Trainer
See routes.d/timing-trainer.lua. Gives real-time feedback on how well you
are keeping time with a metronome by outputting a pan CC that shifts left when
you play early and right when you play late.
Connect the metronome-in port to any source that sends a note_on on each
beat (e.g. the metronome.lua output), and connect keyboard-in to your
keyboard. Route pan-out to a panning plugin in your audio chain. The
output is CC #10 (standard MIDI pan) on channel 1:
- 0 (hard left) — playing ahead of the beat
- 64 (center) — on time
- 127 (hard right) — playing behind the beat
The CC value reflects a rolling average of recent hits, so the pan homes in on your overall tendency to rush or drag rather than reacting to every individual note. After a configurable period of silence the average resets and pan returns to center.
Configurable via [timing-trainer] in config.toml:
| Key | Default | Description |
|---|---|---|
pan_channel |
1 | MIDI channel for the pan CC output |
pan_controller |
10 | CC number (10 = standard MIDI pan) |
max_error_ms |
200 | ±ms of timing error that maps to fully left or right |
history_size |
8 | Number of recent hits included in the running average |
idle_seconds |
3 | Seconds of silence before resetting the average to center |
start_stop_channel |
1 | MIDI channel for the enable/disable CC |
start_stop_controller |
22 | CC number that enables (≥ 64) or disables (< 64) training |
start_running |
true |
Whether training is active immediately on launch |
MIDI Transport messages (start, continue, stop) are also honoured and
will enable or disable training regardless of which input they arrive on.
[]
= 150 # tighter window for more sensitive feedback
= 4 # react faster to changes in timing
= 5
= false # start disabled; enable via CC or transport start