# 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 1.85 or later (the crate uses the Rust 2024 edition)
- ALSA development headers: `sudo pacman -S alsa-lib`
## Build
```bash
cargo build --release
```
### Nix dev shell
The repo ships a `flake.nix` dev shell with the correct Rust toolchain,
`clippy`, `rustfmt`, and the ALSA headers pre-configured:
```bash
nix develop # enter the shell
cargo build # build inside it
cargo clippy # lint
cargo test # run tests
```
## 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 — Arch Linux
Config and routes live in `/etc/midi-daemon/`. The daemon runs as a
dedicated `midi-daemon` system user created via `systemd-sysusers`.
```bash
# Install binary
sudo cargo install --path . --root /usr
# Create the system user and group via systemd-sysusers
sudo install -Dm644 systemd/arch/sysusers.conf \
/usr/lib/sysusers.d/midi-daemon.conf
sudo systemd-sysusers
# 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 the systemd service
sudo cp systemd/arch/midi-daemon.service /etc/systemd/system/midi-daemon.service
sudo systemctl daemon-reload
sudo systemctl enable --now midi-daemon
```
### System-wide install — NixOS
The repo ships a `flake.nix` that both **compiles the binary** and exposes a
**NixOS module** that creates the system user and wires up the systemd service.
#### With flakes (recommended)
Add the repo as a flake input, then import the module:
```nix
# flake.nix (your system config)
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
midi-daemon.url = "github:rickprice/midi-daemon";
};
outputs = { nixpkgs, midi-daemon, ... }: {
nixosConfigurations.mymachine = nixpkgs.lib.nixosSystem {
modules = [
midi-daemon.nixosModules.default
{
services.midi-daemon = {
enable = true;
# package defaults to building from source — no further config needed.
# Supply configFile / routesDir if you want explicit paths:
configFile = /etc/midi-daemon/config.toml;
routesDir = /etc/midi-daemon/routes.d;
};
# Generate the config file declaratively
environment.etc."midi-daemon/config.toml".text = ''
default_bpm = 120.0
default_ppqn = 24
'';
}
];
};
};
}
```
#### Without flakes
```nix
# configuration.nix
{ config, pkgs, ... }:
{
imports = [ /path/to/midi-daemon/nix/module.nix ];
services.midi-daemon = {
enable = true;
configFile = /etc/midi-daemon/config.toml;
routesDir = /etc/midi-daemon/routes.d;
};
environment.etc."midi-daemon/config.toml".text = ''
default_bpm = 120.0
default_ppqn = 24
'';
}
```
In both cases the module:
- Builds the binary from the Nix derivation in `nix/package.nix`
- Creates the `midi-daemon` system user and group
- Adds the user to the `audio` group for ALSA access
- Registers and starts the systemd service
Apply with `sudo nixos-rebuild switch`; the service starts automatically.
## 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).
Stop and start the daemon:
```bash
systemctl --user stop midi-daemon # graceful stop (saves persisted state)
systemctl --user start midi-daemon
systemctl --user restart midi-daemon
```
Re-apply all current route param values (useful after connecting a new device
or recovering from a UI desync):
```bash
systemctl --user reload midi-daemon
# or equivalently:
midi-daemon resync
```
Force an immediate reload of `config.toml` and all route scripts (without restarting):
```bash
midi-daemon reload
```
Show a brief status report from the running daemon:
```bash
midi-daemon status
```
### 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).
```bash
systemctl stop midi-daemon # graceful stop (saves persisted state)
systemctl restart midi-daemon
systemctl reload midi-daemon # resync all route params
midi-daemon resync # equivalent to the above
midi-daemon reload # force reload config + all routes
midi-daemon status # show status report
```
## Daemon control
| Graceful stop | `systemctl --user stop midi-daemon` | `systemctl stop midi-daemon` | *(SIGTERM)* |
| Resync params | `systemctl --user reload midi-daemon` | `systemctl reload midi-daemon` | `midi-daemon resync` |
| Reload config + routes | — | — | `midi-daemon reload` |
| Show status | — | — | `midi-daemon status` |
### Startup flags
These flags are passed when first launching the daemon (not to the running instance):
| `--config <PATH>` | Load config from this exact file. Errors if the file is absent. Overrides `$MIDI_DAEMON_CONFIG` and the default search. |
| `--routes <PATH>` | Use this directory for routes. Overrides `routes_dir` in `config.toml` and survives hot-reloads. |
| `--log-level <LEVEL>` | Set log verbosity: `error`, `warn`, `info` (default), `debug`. |
Example — run with explicit paths (useful for testing or non-standard layouts):
```bash
midi-daemon --config /srv/midi/config.toml --routes /srv/midi/routes.d
```
The service file in `systemd/arch/` and the NixOS module in `nix/module.nix`
pass `--config` and `--routes` explicitly so packagers can control the exact
paths without relying on the environment variable or the default search order.
### Graceful shutdown (SIGTERM)
When the daemon receives SIGTERM — whether from `systemctl stop`, `kill`, or
the system shutting down — it:
1. Sends a shutdown command to every running route's event loop.
2. Waits for each event loop thread to finish (which includes calling
`on_shutdown()`, where a route can call `save_state()` — see
[Route state persistence](#route-state-persistence)).
3. Exits cleanly.
`systemctl stop` will wait up to 30 seconds for this to complete before
sending SIGKILL.
### Control socket
All CLI control commands communicate with the running daemon over a Unix
socket. The socket location depends on the user running the daemon:
| root | `/run/midi-daemon/control.sock` |
| non-root | `$XDG_RUNTIME_DIR/midi-daemon/control.sock` (typically `/run/user/<uid>/midi-daemon/control.sock`) |
The socket is created with mode `0660`. By default only the daemon's owner
(and root) can connect to it.
**Allowing unprivileged users to control a root-run daemon:**
Change the socket's group to one the users belong to (e.g. `audio`). In a
systemd system service unit add:
```ini
[Service]
ExecStartPost=/bin/chgrp audio /run/midi-daemon/control.sock
```
Users in the `audio` group can then run `midi-daemon resync`, `reload`, and
`status` without `sudo`. A non-root user attempting to connect to a
root daemon without the necessary group membership will get a clear
permission error rather than a confusing "daemon not found."
### `resync`
Causes every route to re-apply its current OSC param values:
1. For each param, call `get()` to read the current value.
2. Call `set(value)` to push it through the set function again (re-runs any
clamping, MIDI output, or side-effects).
3. Notify all current OSC subscribers with the post-set value from `get()`.
Useful when a device reconnects and needs to be told the current state, or
when a UI panel has gotten out of sync with the daemon.
```bash
# Both equivalent:
systemctl --user reload midi-daemon
midi-daemon resync
```
SIGUSR1 is still accepted for backward compatibility with existing scripts.
### `reload`
Forces an immediate reload of `config.toml` and all route scripts without
restarting the daemon. Equivalent to touching all watched files at once —
useful when inotify misses a change, or when deploying new route files and
wanting to force a clean reload in one step.
```bash
midi-daemon reload
```
### `status`
Prints a brief status report from the running daemon:
```
pid: 12345
routes: metronome, mixer
osc_recv: 9000
config: /home/user/.config/midi-daemon/config.toml
cache: /home/user/.cache/midi-daemon
socket: /run/user/1000/midi-daemon/control.sock
```
## 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
-- Optional lifecycle hooks: called once right after init(), and once on
-- graceful shutdown, respectively. Commonly used with load_state()/
-- save_state() to persist state across restarts (see "Route state
-- persistence" below), but take no implicit arguments of their own.
function on_startup() end
function on_shutdown() 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
obs_call(conn, request, args) -- Fire-and-forget OBS call
obs_call_sync(conn, request, args, timeout_ms) -- OBS call, blocks this route for a result
```
### 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)
ROUTES_DIR -- string: absolute path to this install's routes directory
```
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, { ... })
```
### Sharing code between routes
Lua's `debug` library isn't loaded into a route's Lua state, so a route can't
locate its own file to derive a path relative to itself; and nothing pins the
daemon's working directory to `routes_dir`, so a plain relative `dofile`
won't reliably resolve either. Use the `ROUTES_DIR` global instead — the
daemon sets it to this install's actual routes directory, so the same line
works whether deployed per-user or system-wide. Put shared helpers in a
`lib/` subdirectory under `routes_dir` (it's scanned non-recursively for
`*.lua` routes, so anything under `lib/` is never itself loaded as a route)
and `dofile` it from there:
```lua
local shared = dofile(ROUTES_DIR .. "/lib/mylib.lua")
```
A `dofile`d module runs in the same Lua state as the route that loaded it,
so it can read that route's globals directly — e.g. `ROUTE_NAME` — without
the caller having to pass them in explicitly.
See `routes.d/lib/nmxt.lua` and the Non-Mixer-XT bridge in the
[VolumePanMuteControl example](#non-mixer-xt-bridge) below for a real
multi-route library.
### 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.
The UDP receive loop retries automatically on transient socket errors (with a
1-second back-off), so a temporary network hiccup does not permanently stop
the listener.
**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` | `[port [timeout_secs]]` | Sent by subscribers to renew their subscription; the daemon also sends this address to subscribers at the configured `osc_heartbeat_interval` (default 5 s), so a UI can show "connected". A heartbeat from a sender the daemon doesn't currently have on file (e.g. it restarted while the client kept beating) is treated as an implicit `subscribe` — re-registered and immediately sent the current state |
**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
```
## OBS support
midi-daemon can drive OBS Studio (via the obs-websocket plugin, OBS 28+) and
react to its events, with support for multiple independent OBS connections.
Connections are declared globally in `config.toml` and addressed by name from
any route — they are not owned by a single route.
```toml
[obs.main]
host = "127.0.0.1"
port = 4455
password = "changeme" # omit if authentication is disabled in OBS
[obs.streaming-pc]
host = "192.168.1.50"
port = 4455
```
The daemon owns one reconnecting background task per connection; a route
never blocks waiting for OBS to come back online.
### Calling OBS
```lua
obs_call(conn, request, args) -- fire-and-forget, returns immediately
obs_call_sync(conn, request, args, timeout_ms) -- blocks this route only, up to timeout_ms
```
`obs_call` is the default — it queues the request and returns without
waiting, so a slow or unreachable OBS connection never stalls MIDI/OSC/timer
handling. `obs_call_sync` blocks the **calling route's own thread only** (via
a plain OS-level wait with a timeout, not an async await) for the rare case a
script needs a result immediately; other routes and other OBS connections
keep running normally while it waits. It returns `ok, result, err`:
```lua
local ok, result, err = obs_call_sync("main", "scenes.current", {}, 1000)
if ok then
log("current scene: " .. result.currentProgramSceneName)
else
log("obs_call_sync failed: " .. tostring(err))
end
```
Supported `request` names today (more are added to `src/obs.rs` as routes
need them):
| `scenes.set_current` | `{ name = "Scene 2" }` | — |
| `scenes.current` | `{}` | current program scene |
| `scenes.list` | `{}` | all scenes |
| `inputs.set_mute` | `{ name, muted = true }` | — |
| `inputs.toggle_mute` | `{ name }` | `{ muted = true/false }` |
### Receiving OBS events
A route opts in to a connection's events (scene changed, mute toggled, stream
state changed, …) by declaring it in `init()`:
```lua
function init()
return { obs = { connections = {"main"} } } -- or a single name as a string
end
function on_obs_event(conn, event)
-- `event` is the obws Event enum serialized to a Lua table; its shape
-- depends on the event type. Inspect it once to find the fields you need:
for k, v in pairs(event) do
log(conn .. " event key: " .. tostring(k))
end
end
```
Declaring `obs.connections` only controls event delivery — `obs_call`/`obs_call_sync`
can address any connection configured in `config.toml`, whether or not the
route declared it here.
## 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. `--config <PATH>` — command-line flag (errors if the file is absent)
2. `$MIDI_DAEMON_CONFIG` — explicit path via environment variable
3. `~/.config/midi-daemon/config.toml` — per-user
4. `/etc/midi-daemon/config.toml` — system-wide
5. 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)
# Overridden at startup by the --routes flag (survives hot-reload).
# 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
# Named OBS websocket connections — add as many [obs.<name>] sections as needed.
# [obs.main]
# host = "127.0.0.1"
# port = 4455
# password = "changeme"
```
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.
### Route state persistence
Any route can call `save_state(table)` and `load_state()` to persist
arbitrary runtime state across restarts — no `osc.params` required. These are
the only two state primitives; the script never touches the filesystem
directly, and nothing is persisted unless the script explicitly calls one of
them.
```lua
local volume = 1.0
-- on_startup/on_shutdown are plain lifecycle hooks — they don't receive or
-- expect a state argument. Call load_state()/save_state() yourself.
function on_startup()
local state = load_state()
if state.volume ~= nil then volume = state.volume end
end
function on_shutdown()
save_state({ volume = volume })
end
```
- `load_state()` returns a plain Lua table ("hash") read from
`<state_dir>/<route-name>/state.json` — an empty table if the file doesn't
exist yet.
- `save_state(table)` writes a table to that same file immediately.
- Both can be called from anywhere, not just `on_startup`/`on_shutdown` — a
route can checkpoint periodically (e.g. from `on_tick`) or right after a
change (e.g. from `on_midi`) instead of, or in addition to, relying on a
clean shutdown:
```lua
function on_midi(msg)
if msg.type == "cc" and msg.controller == 7 then
save_state({ volume = msg.value }) -- persisted right away
end
end
```
State is only saved when the script calls `save_state()` — a graceful
shutdown does **not** implicitly persist anything on its own (though calling
`save_state()` from `on_shutdown()`, as above, is the common pattern).
The state root directory is configurable server-wide in `config.toml`:
```toml
state_dir = "/var/lib/midi-daemon/state" # default: <cache_dir>/lua-state
```
| system service (via systemd `CacheDirectory=`) | `$CACHE_DIRECTORY/lua-state/<name>/state.json` |
| root | `/var/cache/midi-daemon/lua-state/<name>/state.json` |
| interactive user (non-root) | `~/.cache/midi-daemon/lua-state/<name>/state.json` |
State is **not** saved on SIGKILL or a crash — always use `systemctl stop`
(or `kill -TERM`) to preserve state.
## 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: VolumePanMuteControl
See `routes.d/VolumePanMuteControl.lua`. Bidirectional OSC ↔ MIDI bridge for
channel volume, pan, and mute. OSC controllers (e.g. TouchOSC faders and toggles)
drive the three MIDI CCs; incoming MIDI CCs update the internal state and notify
OSC subscribers, keeping hardware and software UIs in sync.
Configurable via `[VolumePanMuteControl]` in `config.toml`:
| `channel` | 1 | Shared MIDI channel for all CCs (per-param keys take precedence) |
| `volume_channel` | 1 | MIDI channel for the volume CC |
| `volume_controller`| 7 | CC number for volume (7 = MIDI Channel Volume) |
| `pan_channel` | 1 | MIDI channel for the pan CC |
| `pan_controller` | 10 | CC number for pan (10 = MIDI Pan; 0=left, 64=center, 127=right) |
| `mute_channel` | 1 | MIDI channel for the mute CC |
| `mute_controller` | 118 | CC number for mute (no universal standard; configure for your DAW) |
| `osc_receive_port` | *(none)*| UDP port for incoming OSC (falls back to global `osc_receive_port`) |
| `osc_send_addr` | *(none)*| UDP `"host:port"` for OSC output (falls back to global `osc_send_addr`) |
| `nmxt_strip` | *(none)*| Non-Mixer-XT strip name this route also drives, e.g. `"Guitar"` — bridge disabled when unset |
| `nmxt_pan` | `false` | Also bridge pan — only if this strip has a Pan plugin inserted in Non-Mixer-XT |
| `nmxt_osc_addr` | `"127.0.0.1:9500"` | Non-Mixer-XT's OSC server address |
### OSC interface
| `/VolumePanMuteControl/volume` | float 0–1 | Channel volume (0 = silent, 1 = full) |
| `/VolumePanMuteControl/pan` | float −1–1 | Stereo pan (−1=left, 0=center, 1=right) |
| `/VolumePanMuteControl/mute` | int/bool 0\|1 | Mute toggle (1 = muted, 0 = unmuted) |
| `/VolumePanMuteControl/subscribe`| — | Register for change notifications |
### MIDI mapping
| volume | CC 7 | 0–127 | 0.0–1.0 |
| pan | CC 10 | 0–127 | −1.0–1.0 (CC 64 = 0.0) |
| mute | CC 118 | ≥64=muted | 0 or 1 |
For mute, incoming CC ≥ 64 triggers muted (1) and < 64 triggers unmuted (0).
Outgoing MIDI sends 127 for muted and 0 for unmuted.
### Non-Mixer-XT bridge
Set `nmxt_strip` to also drive a [Non-Mixer-XT](https://github.com/Stazed/non-mixer-xt)
strip's Gain (volume/mute) over OSC, using the signal-subscription protocol
described in [its OSC.md](https://github.com/Stazed/non-mixer-xt/blob/main/OSC.md).
Volume/Pan/Mute changes from MIDI or OSC are pushed out to Non-Mixer-XT;
changes made directly in its own GUI flow back out to MIDI and any
subscribed OSC controller, the same as a hardware CC would.
Deploy one copy of this route per strip (e.g. `VolumePanMuteControlGuitar.lua`,
`VolumePanMuteControlVocals.lua`), each with its own `[section]` in
`config.toml` setting `nmxt_strip` to that strip's name — the copies can
otherwise be byte-identical, since all strip-specific behavior comes from
`config`.
Pan only works if the target strip actually has a Pan plugin inserted in
Non-Mixer-XT (most strips don't, by default) — set `nmxt_pan = true` only
then; otherwise leave it unset and pan stays MIDI/OSC-only.
The shared logic lives in `routes.d/lib/nmxt.lua`, loaded via
`dofile(ROUTES_DIR .. "/lib/nmxt.lua")` (see
[Sharing code between routes](#sharing-code-between-routes) above). It
registers one controller per
daemon — so multiple strips collapse into a single Non-Mixer-XT peer instead
of each stealing the others' registration, since Non-Mixer-XT identifies a
peer by name and a repeated `/signal/hello` with the same name overwrites
the previous peer's address — subscribes to feedback for this strip's
Gain/Pan signals, and converts between Non-Mixer-XT's normalized 0.0–1.0
range and this route's native units.
The hello/subscribe registration, and this route's current volume/pan/mute,
are resent together every 5 seconds from `on_tick` (not just once at
startup), since it's one-way UDP with no delivery confirmation — if
Non-Mixer-XT isn't listening yet when the daemon first starts (e.g. both
processes launched together and Non-Mixer-XT is still loading its project),
the initial registration and state push are silently dropped. midi-daemon
is the source of truth for these values, so the periodic resend both heals
a dropped startup race and re-syncs Non-Mixer-XT's GUI (which always comes
up showing its own plugin defaults, not midi-daemon's state) if it restarts
on its own without the daemon restarting too.
## 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
```