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)
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
config.toml
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.*"
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
One pattern applied to all input ports of the route, and one for all output
ports. Overrides the global default; overridden by Lua init().
[]
= ".*KeyLab.*"
= ".*Surge.*"
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 |
BPM is clamped to the range 20–200 regardless of source.
The start/stop CC uses the value to determine state: value ≥ 64 starts the
metronome, value < 64 stops it. MIDI Transport messages (start, stop,
continue) are also honoured and override the CC.
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: Transpose
See routes.d/transpose.lua. Shifts all notes up by a configurable interval,
passes everything else through unchanged.