midiio

Build StatusErlang VersionsTag

Project Logo

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)
LibraryLayerNative build?
midilibmessage codec + Standard MIDI File read/writeno (pure Erlang)
midiiorealtime device I/O (NIF)yes (vendors minimidio)
midithe whole enchilada: codec + I/O behind one APIyes (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:

FileRole
c_src/minimidio.hthe vendored header that gets compiled into the NIF
c_src/minimidio.LICENSEupstream's MIT license text (kept per MIT's notice requirement)
c_src/minimidio.lockprovenance 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:

  1. Commit Aminimidio.h + minimidio.LICENSE, authored as the upstream developer (read from the pinned commit). This keeps git blame/git log correctly attributing the C code to its creator, not to us.
  2. Commit Bminimidio.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.