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.
midi
(one batteries-included API)
│
┌───────────┴────────────┐
midiio midilib
(transport: raw bytes (codec + Standard MIDI
⇄ OS ports; a NIF) Files; pure Erlang)
│
minimidio.h
(vendored C library;
the OS MIDI backends)
Read top-down as "is built on": midi combines midiio (I/O) and midilib
(codec) behind one API; midiio wraps the vendored minimidio.h; midilib is
independent, pure Erlang. midiio is codec-free and does not depend on
midilib.
| 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) |
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.
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
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.
{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).{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).{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.
midiio vendors (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.
