%%% @doc Cross-platform realtime MIDI I/O for the BEAM via a NIF over the %%% minimidio C library (raw transport; no message codec). %%% %%% Surface (v0.1.0): an opaque per-call MIDI context and read-only discovery — %%% `list_inputs/1', `list_outputs/1', `caps/1'; output devices — `open_output/1', %%% `send/2' (byte-exact, no normalization), `close/1'; and inbound — %%% `open_input/2', `start_input/1', `stop_input/1', `set_owner/2', with messages %%% delivered to the owner process as `{midi_in, Device, Bytes, TsNanos}'. %%% Built and tested on CoreMIDI (macOS) and ALSA (Linux). %%% @end -module(midiio). -on_load(init/0). %% NOTE: result_atom/1, uninit_count/0, and seam_roundtrip/1 are test-only NIFs. %% Gating them out of the shipped surface behind -DMIDIIO_TEST is not robust under %% the single shared .so (L18; see rebar.config). They are kept in the surface but %% are memory-safe — in particular seam_roundtrip's seam self-defends on length %% (S2 remediation Fix 1), so it cannot read OOB for any input. -nifs([context_open/0, context_close/1, result_atom/1, uninit_count/0, seam_roundtrip/1, list_inputs/1, list_outputs/1, caps/1, open_output/1, open_output_virtual/0, close/1, send/2, open_input/2, start_input/1, stop_input/1, set_owner/2]). -export([context_open/0, context_close/1, result_atom/1, uninit_count/0, seam_roundtrip/1, list_inputs/1, list_outputs/1, caps/1, open_output/1, open_output_virtual/0, close/1, send/2, open_input/2, start_input/1, stop_input/1, set_owner/2]). %% NOTE (F2, disclosed-deferred in arc1/slice5): result_atom/1, uninit_count/0, %% and open_output_virtual/0 are test-only introspection/scaffolding NIFs. Gating %% them out of the default surface via -ifdef(TEST)/-DMIDIIO_TEST was attempted %% and reverted — pc builds one shared .so across profiles, so a test-only NIF set %% makes load_nif order-dependent. See arc1/slice5/closing-report.md for the %% re-entry path. They are harmless (S3). -export_type([context/0, device/0, backend/0, caps/0]). %% Opaque handle to a native mm_context. Only meaningful when passed back to a %% midiio NIF; callers must not inspect it. -type context() :: term(). %% Opaque handle to a native mm_device (distinct from context()); the identity of %% an open device, passed back to close/1 (and send/recv in later arcs). -type device() :: term(). %% The MIDI backend, chosen at compile time by minimidio's platform macro. -type backend() :: coremidi | winmm | alsa | webmidi. %% Decoded capability map for a context (mm_context_caps flags + backend). -type caps() :: #{backend := backend(), midi1 := boolean(), ump := boolean(), midi2 := boolean(), virtual_in := boolean(), virtual_out := boolean()}. -define(NOT_LOADED, erlang:nif_error(nif_not_loaded)). %% @doc Loaded automatically via -on_load when the module is loaded. -spec init() -> ok | {error, term()}. init() -> Lib = filename:join(code:priv_dir(midiio), "midiio_nif"), erlang:load_nif(Lib, 0). %% @doc Open a MIDI context, returning an opaque resource handle. -spec context_open() -> {ok, context()} | {error, atom()}. context_open() -> ?NOT_LOADED. %% @doc Close a context opened with {@link context_open/0}. Returns %% `{error, not_open}' if the context was already closed. -spec context_close(context()) -> ok | {error, not_open}. context_close(_Ctx) -> ?NOT_LOADED. %% @doc Test/introspection NIF: map an mm_result integer code to its atom. %% Used by the eunit suite to verify the full result-code mapping. -spec result_atom(integer()) -> atom(). result_atom(_Code) -> ?NOT_LOADED. %% @doc Test/introspection NIF: the process-global count of native context %% uninit calls. Used by the eunit suite to verify destructor behaviour on GC. -spec uninit_count() -> non_neg_integer(). uninit_count() -> ?NOT_LOADED. %% @doc Test NIF: round-trip `Bytes' through both raw seams (outbound parse + %% inbound build) with no I/O. Used by the PropEr bytes⇄message bridge property. %% Memory-safe for any input (the seam self-defends on length); a truncated %% fixed-length status returns `{error, unsupported_status}'. -spec seam_roundtrip(binary()) -> {ok, binary()} | {error, unsupported_status}. seam_roundtrip(_Bytes) -> ?NOT_LOADED. %% @doc List the currently-visible MIDI input ports as `{Index, Name}' pairs, %% in ascending index order. `Index' is a display-only snapshot ordinal (it %% shifts on hotplug and is not a stable identity); `Name' is the UTF-8 name %% minimidio reports, unmodified. Fresh query each call; may be empty. -spec list_inputs(context()) -> [{non_neg_integer(), binary()}]. list_inputs(_Ctx) -> ?NOT_LOADED. %% @doc List the currently-visible MIDI output ports. See {@link list_inputs/1}. -spec list_outputs(context()) -> [{non_neg_integer(), binary()}]. list_outputs(_Ctx) -> ?NOT_LOADED. %% @doc The backend atom and decoded capability flags for the context. -spec caps(context()) -> caps(). caps(_Ctx) -> ?NOT_LOADED. %% @doc Open the MIDI output port at `Index' (from {@link list_outputs/1}), %% returning an opaque device handle. The device owns its own MIDI context; %% {@link close/1} (or dropping the handle) reclaims both. Out-of-range index → %% `{error, out_of_range}'. -spec open_output(non_neg_integer()) -> {ok, device()} | {error, atom()}. open_output(_Index) -> ?NOT_LOADED. %% @doc Test/scaffolding NIF: open a virtual output device (no destination %% needed) to exercise the device lifecycle headlessly. Not a public %% virtual-port API. -spec open_output_virtual() -> {ok, device()} | {error, atom()}. open_output_virtual() -> ?NOT_LOADED. %% @doc Close a device opened with {@link open_output/1}. Returns %% `{error, not_open}' if it was already closed. -spec close(device()) -> ok | {error, not_open}. close(_Dev) -> ?NOT_LOADED. %% @doc Send one complete MIDI message to an open output device, byte-exact and %% with no normalization (R6). `Bytes' is a single status-complete message, status %% byte first; `send/2' routes normal-vs-SysEx internally (the split is not in the %% API, R4): %% ``` %% midiio:send(Dev, <<16#90, 60, 100>>). %% note-on, channel 0 %% midiio:send(Dev, <<16#F0, 16#7E, 16#F7>>). %% a complete SysEx %% ''' %% Runs on a dirty I/O scheduler so a blocking backend drain never ties up a %% normal scheduler. Returns `{error, not_open}' if the device is closed or is an %% input; `{error, {unsupported_status, B}}' for a reserved/unframable leading %% status byte (`16#F4', `16#F5', `16#F7', `16#F9', `16#FD'); and %% `{error, invalid_arg}' for a SysEx larger than the 4096-byte backend buffer. %% Malformed input — an empty binary, a leading data byte, or a known status with %% the wrong length — raises (let-it-crash; the encoder upstream must not produce %% it). -spec send(device(), binary()) -> ok | {error, not_open} | {error, {unsupported_status, byte()}} | {error, invalid_arg}. send(_Dev, _Bytes) -> ?NOT_LOADED. %% @doc Open the MIDI input port at `Index' (from {@link list_inputs/1}), with %% `Owner' as the process that will receive inbound messages. Returns an opaque %% device handle owning its own MIDI context. After {@link start_input/1}, each %% complete inbound message is delivered to the owner as %% `{midi_in, Dev :: device(), Bytes :: binary(), TsNanos :: integer()}' — %% one message per delivery, byte-exact (no normalization), with a host-monotonic %% nanosecond timestamp (`0' when the backend gives none). The handle `Dev' is the %% device's identity, echoed in every message. Out-of-range index → %% `{error, out_of_range}'. -spec open_input(non_neg_integer(), pid()) -> {ok, device()} | {error, atom()}. open_input(_Index, _Owner) -> ?NOT_LOADED. %% @doc Start delivering inbound messages from an input device to its owner. -spec start_input(device()) -> ok | {error, atom()}. start_input(_Dev) -> ?NOT_LOADED. %% @doc Stop delivering inbound messages. After this no further `{midi_in, ...}' %% arrives; the device stays open until {@link close/1}. -spec stop_input(device()) -> ok | {error, atom()}. stop_input(_Dev) -> ?NOT_LOADED. %% @doc Redirect a device's inbound delivery to a new owner process. The handoff %% is atomic: for an input device the new owner is monitored before the old is %% released, so if `Pid' is already dead the call returns `{error, owner_not_alive}' %% and the device keeps serving its current owner (the previous monitor is left %% intact). For an output device it simply records the owner. -spec set_owner(device(), pid()) -> ok | {error, owner_not_alive}. set_owner(_Dev, _Pid) -> ?NOT_LOADED.