midiio
Cross-platform realtime MIDI I/O for the BEAM
midiio is an Erlang NIF over the single-header
minimidio C library. It is the
transport layer: it discovers MIDI devices and moves raw MIDI bytes to and
from the operating system's MIDI ports in real time. v0.1.0 is built and tested on
CoreMIDI (macOS) and the ALSA sequencer (Linux); minimidio's WinMM
(Windows) and Web MIDI (Emscripten) backends are on the roadmap but not yet
exercised by midiio.
It is deliberately codec-free. midiio has no opinion about what the bytes
mean and does not depend on midilib — if you want raw realtime MIDI in the
BEAM and bring your own message representation, this is all you need. If you
want messages encoded/decoded for you and Standard MIDI File support, use the
midi umbrella instead.
Where it sits
minimidio.h ─► midiio (transport: raw bytes ⇄ OS ports) midilib (codec + .mid files, pure Erlang)
└────────────────┬────────────────────┘
midi (one batteries-included API)
| Library | Layer | Native build? |
|---|---|---|
| midilib | message codec + Standard MIDI File read/write | no (pure Erlang) |
| midiio | realtime device I/O (NIF) | yes (vendors minimidio) |
| midi | the whole enchilada: codec + I/O behind one API | yes (via midiio) |
Status
0.1.0 — the MIDI 1.0 transport layer is complete and tested: device
discovery, output (open / send / close, including SysEx), and inbound (open /
start / stop / close with per-process ownership), byte-exact with no
normalization. Built and tested on macOS (CoreMIDI) and Linux (ALSA), OTP 24–29.
midiio 0.1.0 provides a NIF implementation for roughly 75% of minimidio's
public API — the full MIDI 1.0 transport surface, including device discovery,
MIDI output (open / send / close, including SysEx), and inbound MIDI with
per-process ownership. The remaining ~25% is UMP / MIDI 2.0 and MIDI Time Code
helpers, deferred by design to the codec layer above. See
minimidio API coverage for exactly what is
covered and what is deferred (UMP / MIDI 2.0, MTC helpers, WinMM / Web MIDI).
The next milestone (v0.2.0) swaps the interim send/recv adapter for minimidio's native raw-bytes API — merged upstream from midiio's own proposal — with no change to the Erlang API above the seam.
Build
midiio builds a NIF, so it needs a C toolchain. On Linux you also need the ALSA
sequencer development headers:
# Debian/Ubuntu
sudo apt-get install libasound2-dev
Then build, and (optionally) run the full gate — eunit + PropEr + dialyzer + xref, plus the AddressSanitizer lifecycle harness:
rebar3 compile
rebar3 as test check
make asan
Usage
midiio deals in raw MIDI bytes — you bring the message representation. A
status-complete message goes out as a binary; inbound messages arrive as
{midi_in, Device, Bytes, TimestampNanos} to the owning process.
Discover devices
{ok, Ctx} = midiio:context_open(),
Outputs = midiio:list_outputs(Ctx), %% [{Index, Name}], e.g. [{0, <<"IAC Driver Bus 1">>}]
Inputs = midiio:list_inputs(Ctx), %% same shape
#{backend := Backend} = midiio:caps(Ctx),
ok = midiio:context_close(Ctx).
Play a note
{ok, Ctx} = midiio:context_open(),
[{Idx, _Name} | _] = midiio:list_outputs(Ctx),
ok = midiio:context_close(Ctx),
{ok, Out} = midiio:open_output(Idx),
ok = midiio:send(Out, <<16#90, 60, 100>>), %% note-on: middle C (60), velocity 100
timer:sleep(500),
ok = midiio:send(Out, <<16#80, 60, 0>>), %% note-off: middle C
ok = midiio:close(Out).
Receive inbound MIDI
{ok, Ctx} = midiio:context_open(),
[{Idx, _Name} | _] = midiio:list_inputs(Ctx),
ok = midiio:context_close(Ctx),
{ok, In} = midiio:open_input(Idx, self()), %% deliver to this process
ok = midiio:start_input(In),
receive
{midi_in, In, Bytes, TsNanos} ->
io:format("MIDI in: ~p at ~p ns~n", [Bytes, TsNanos])
after 5000 ->
io:format("(no MIDI received)~n")
end,
ok = midiio:stop_input(In),
ok = midiio:close(In).
send/2 takes one complete message and routes channel / system / SysEx
internally; it does not normalize (a note-on with velocity 0 stays a note-on).
SysEx is just a binary that starts with 16#F0 and ends with 16#F7.
Updating the vendored minimidio
midiiovendors (does not fork) the single-header
minimidio library — MIT, © Joseph
Stewart / octetta. The copy is pinned to an exact upstream commit and lives in:
| File | Role |
|---|---|
c_src/minimidio.h | the vendored header that gets compiled into the NIF |
c_src/minimidio.LICENSE | upstream's MIT license text (kept per MIT's notice requirement) |
c_src/minimidio.lock | provenance manifest: commit SHA, version, date, sha256, author |
minimidio publishes no git tags or releases, so the deterministic pin is the
commit SHA. The version string (e.g. v0.5.0-dev) is recorded for humans but
is not a reliable handle.
See the current pin:
cat c_src/minimidio.lock
Bump or roll back — find the commit you want on GitHub and pass its SHA (rollbacks work the same way; just pick an older commit):
make vendor-minimidio SHA=<commit-sha> # pin an exact commit
make vendor-minimidio REF=main # pull the latest main, then pin its SHA
This fetches minimidio.h + LICENSE at that commit, updates the lock, and makes
two commits:
- Commit A —
minimidio.h+minimidio.LICENSE, authored as the upstream developer (read from the pinned commit). This keepsgit blame/git logcorrectly attributing the C code to its creator, not to us. - Commit B —
minimidio.lock, authored by you (our metadata, not theirs).
Preview without committing — write the files and print the commit commands:
make vendor-minimidio SHA=<commit-sha> NO_COMMIT=1
Verify integrity — fail if the in-tree header has drifted from the lock
(re-hashes c_src/minimidio.h; offline; also runs in CI):
make minimidio-verify
Re-pinning the commit already in the lock is a no-op. The tooling is
scripts/vendor-minimidio.sh (POSIX sh; needs curl, git, and a sha256 tool);
scripts/test-vendor-minimidio.sh runs its offline unit tests.