# 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
```bash
cargo build --release
```
## Install
### Per-user install
Config and routes live in `~/.config/midi-daemon/`. The daemon runs under
your own account as a systemd user service.
```bash
# Install binary
cargo install --path .
# Create config and routes directories
mkdir -p ~/.config/midi-daemon/routes.d
# Copy example config and routes
cp config.toml ~/.config/midi-daemon/config.toml
cp routes.d/*.lua ~/.config/midi-daemon/routes.d/
# Install and enable systemd user service
mkdir -p ~/.config/systemd/user
cp systemd/midi-daemon.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now midi-daemon
```
### System-wide install
Config and routes live in `/etc/midi-daemon/`. The daemon runs as a
dedicated `midi-daemon` system user.
```bash
# Install binary
sudo cargo install --path . --root /usr/local
# Create a dedicated system user
sudo useradd --system --no-create-home --shell /usr/sbin/nologin midi-daemon
# Add it to the audio group so it can access ALSA/PipeWire
sudo usermod -aG audio midi-daemon
# Create config and routes directories
sudo mkdir -p /etc/midi-daemon/routes.d
sudo chown -R midi-daemon:midi-daemon /etc/midi-daemon
# Copy example config and routes
sudo cp config.toml /etc/midi-daemon/config.toml
sudo cp routes.d/*.lua /etc/midi-daemon/routes.d/
sudo chown midi-daemon:midi-daemon /etc/midi-daemon/config.toml \
/etc/midi-daemon/routes.d/*.lua
# Install and enable systemd system service
sudo cp systemd/midi-daemon-system.service /etc/systemd/system/midi-daemon.service
sudo systemctl daemon-reload
sudo systemctl enable --now midi-daemon
```
## Usage
### Per-user
```bash
systemctl --user status midi-daemon
journalctl --user -u midi-daemon -f
```
Add or remove a route — the daemon hot-reloads automatically:
```bash
cp my-route.lua ~/.config/midi-daemon/routes.d/
rm ~/.config/midi-daemon/routes.d/my-route.lua
```
Edit `config.toml` — the daemon detects the change and reloads all routes
with the updated configuration automatically (no restart needed).
### System-wide
```bash
systemctl status midi-daemon
journalctl -u midi-daemon -f
```
```bash
sudo cp my-route.lua /etc/midi-daemon/routes.d/
sudo rm /etc/midi-daemon/routes.d/my-route.lua
```
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:
```lua
-- Optional: declare named ports and auto-connect patterns (see below).
-- Called once at startup before on_midi/on_tick.
function init() end
-- Called on every timer tick
-- tick: monotonically increasing tick counter
-- bpm: current BPM (float)
-- ppqn: current pulses per quarter note
function on_tick(tick, bpm, ppqn) end
-- 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).
function on_midi(msg) end
```
### 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:
```lua
function init()
return {
inputs = {"keyboard", "pad"},
outputs = {"synth", "drums"},
}
end
```
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:
```lua
function on_midi(msg)
if msg.port == "keyboard" then
send("synth", msg)
elseif msg.port == "pad" then
send("drums", msg)
end
end
```
### `msg` table fields by type
| `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
```lua
send(msg) -- Send msg to the first/only output port
send(port_name, msg) -- Send msg to a named output port (multi-port routes)
send_osc(address, ...) -- Send OSC to the only configured target
send_osc(target, address, ...) -- Send OSC to a named target
set_bpm(bpm) -- Set timer BPM (float)
get_bpm() -- Get current BPM (float)
set_ppqn(ppqn) -- Set pulses per quarter note (integer)
get_ppqn() -- Get current PPQN (integer)
log(message) -- Log a string to the systemd journal / stdout
```
### Global variables
```lua
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:
```lua
send_osc("/" .. ROUTE_NAME .. "/beat", beat, bpm)
on_osc = osc_params("/" .. ROUTE_NAME, { ... })
```
### 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
```lua
send_osc("/addr", v…) -- address-first: uses sole named target
send_osc("name", "/addr", v…) -- named target
send_osc("192.168.1.5:9001", "/addr", v…) -- 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](https://opensoundcontrol.stanford.edu/) (Open Sound
Control) messages over UDP. OSC receive/send is declared in the table returned by
`init()` using the `osc` key.
```lua
function init()
return {
inputs = {"midi"},
outputs = {"midi"},
osc = {
-- UDP port to listen on for incoming OSC messages.
receive = 9000,
-- Named outgoing destinations. Use any name; single-target
-- routes can omit the name when calling send_osc.
send = {
default = "127.0.0.1:9001",
reaper = "192.168.1.5:9002",
},
},
}
end
```
### 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.
```lua
function init()
return {
osc = {
receive = 9000,
send = { default = "127.0.0.1:9001" },
params = {
bpm = {
set = function(v) set_bpm(v) end,
get = get_bpm,
-- CC payload 0–127 mapped linearly to 20–200
midi = {
{ type = "cc", channel = 1, controller = 21,
scale = {20, 200} },
},
},
running = {
set = function(v) set_running(v ~= 0) end,
get = function() return running and 1 or 0 end,
-- CC value ≥ 64 → 1 (start), < 64 → 0 (stop)
midi = {
{ type = "cc", channel = 1, controller = 22,
threshold = 64 },
},
},
start = { set = start_fn, midi = { { type = "start" } } },
stop = { set = function() stop() end, midi = { { type = "stop" } } },
continue = { set = function() cont() end, midi = { { type = "continue" } } },
},
},
}
end
```
**MIDI address (routing key) and payload by message type:**
| `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:**
| *(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`):
| `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>`:**
| 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:
| `address` | string | OSC address pattern, e.g. `"/note/on"` |
| `args` | table (1-indexed) | Typed argument values |
```lua
function on_osc(msg)
log("OSC " .. msg.address .. " args=" .. #msg.args)
if msg.address == "/note/on" and #msg.args >= 2 then
send({ type="note_on", channel=1, note=msg.args[1], velocity=msg.args[2] })
end
end
```
### OSC type mapping (received)
| `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
```lua
-- Single target: omit the target name
send_osc("/address", arg1, arg2, ...)
-- Multiple targets: name the target
send_osc("reaper", "/transport/play", 1)
```
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)
| 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:
```toml
[osc-bridge]
osc_receive_port = 9000
osc_send_addr = "127.0.0.1:9001"
```
```lua
function init()
return {
osc = {
receive = config.osc_receive_port or 9000,
send = { default = config.osc_send_addr or "127.0.0.1:9001" },
},
}
end
```
## 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:
1. `$MIDI_DAEMON_CONFIG` — explicit path via environment variable
2. `~/.config/midi-daemon/config.toml` — per-user
3. `/etc/midi-daemon/config.toml` — system-wide
4. Built-in defaults (routes dir inferred from whichever scope applies)
```toml
# Path to routes directory.
# Default: <config-dir>/routes.d (user or system, whichever was loaded)
# routes_dir = "/custom/path"
default_bpm = 120.0
default_ppqn = 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:
```toml
[my-route]
some_key = "value"
some_number = 42
```
In `my-route.lua`:
```lua
local value = config.some_key or "default"
local num = config.some_number 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()`:
```lua
function init()
return {
inputs = {"keyboard", "pad"},
outputs = {"synth", "drums"},
connect = {
-- per named port (highest priority)
inputs = { keyboard = ".*KeyLab.*", pad = ".*LinnStrument.*" },
outputs = { synth = ".*Surge.*", drums = ".*DrumMachine.*" },
-- OR: one pattern for all inputs / all outputs (singular form)
-- input = ".*My Keyboard.*",
-- output = ".*My Synth.*",
},
}
end
```
`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:
```toml
[transpose]
connect_input = ".*KeyLab.*"
connect_output = ".*Surge.*"
```
**Individual named ports** — use `connect_{portname}-in` / `connect_{portname}-out`
(where `portname` matches the port name declared in `init()`):
```toml
[timing-trainer]
connect_keyboard-in = ".*A-PRO 2.*"
connect_metronome-in = ".*metronome-out.*"
connect_pan-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.
```toml
default_connect_input = ".*KeyLab Essential.*"
default_connect_output = ".*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`:
| `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:
| `/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:
| `/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:
```toml
[metronome]
osc_receive_port = 9000
osc_send_addr = "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:
| `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`:
| `type` | `"cc"` | Message type to match |
| `channel` | 1 | MIDI channel to match |
| `controllers` | `[]` | List of controller numbers to invert |
```toml
[invert_controllers]
type = "cc"
channel = 1
controllers = [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.
```toml
[keyboard-split]
split_note = 60 # split at middle C (C4)
connect_input = ".*KeyLab.*" # auto-connect keyboard input on startup
connect_bass = ".*ZynAddSubFX.*"
connect_lead = ".*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:
```bash
nc -lu 9001 &
oscsend osc.udp://localhost:9000 /note/on ii 60 100
```
## 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`:
| `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.
```toml
[timing-trainer]
max_error_ms = 150 # tighter window for more sensitive feedback
history_size = 4 # react faster to changes in timing
idle_seconds = 5
start_running = false # start disabled; enable via CC or transport start
```