diff --git a/CHANGELOG.md b/CHANGELOG.md
index a19727f6d..f462c02b5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,10 @@ All notable changes to scriptc will be documented in this file.
## Unreleased
+### Features
+
+- **Native MIDI messaging.** `node:midi` (API-compatible with node-midi/@julusian/midi) enumerates ports, opens inputs and outputs including virtual ports, sends raw messages, and receives time-stamped messages through the `"message"` event on the dependency-free event loop. Backends bind each platform's MIDI stack — ALSA on Linux, CoreMIDI on macOS, WinMM on Windows — and are linked only into binaries that use the surface. An open input holds the loop alive like a bound `dgram` socket; the runtime is byte-transparent and does not parse MIDI semantics. `openVirtualPort` is POSIX-only (WinMM has no user-space virtual ports), and any MIDI surface on `wasm32-wasi` refuses before linking with `SC3002`.
+
## 0.0.30
diff --git a/docs/src/app/how-it-works/page.mdx b/docs/src/app/how-it-works/page.mdx
index 0ded6b60c..deea595c9 100644
--- a/docs/src/app/how-it-works/page.mdx
+++ b/docs/src/app/how-it-works/page.mdx
@@ -31,7 +31,7 @@ fib.ir.json
- **Memory** — values are reference-counted; an acyclic value is freed the moment its last reference drops. Reference cycles are collected at deterministic points by a cycle collector, not a concurrent GC. There are no GC pauses and no tracing heap.
- **Concurrency** — `async`/`await` runs on stackful fibers with JS-exact scheduling: microtasks drain in the same order Node's do, timers fire in the same order, and the event loop (kqueue on macOS, epoll on Linux) has no external dependencies.
-- **The server stack** — `net`, `http`, `https`, `tls` (vendored mbedTLS), `dgram`, `dns` are native implementations on that same loop.
+- **The server stack** — `net`, `http`, `https`, `tls` (vendored mbedTLS), `dgram`, `dns` are native implementations on that same loop, as is `midi` (ALSA/CoreMIDI/WinMM, linked only into binaries that use it).
- **Numbers** — JS-exact f64 semantics, including shortest-roundtrip number-to-string formatting fuzz-verified against Node's output.
- **Regular expressions** — the same ECMAScript-exact bytecode interpreter QuickJS uses, linked only into regex-using binaries.
diff --git a/docs/src/app/introduction/page.mdx b/docs/src/app/introduction/page.mdx
index d473423d2..4e32553a9 100644
--- a/docs/src/app/introduction/page.mdx
+++ b/docs/src/app/introduction/page.mdx
@@ -51,7 +51,7 @@ The static surface covers the language and the standard library real programs us
- **The language** — classes with single inheritance and dynamic dispatch, closures with JS capture semantics, generic function declarations (monomorphized), discriminated unions driven by TypeScript's own narrowing, `async`/`await` with JS-exact scheduling, exceptions with `finally`, destructuring, spread, optional/default/rest parameters, getters and setters, iterators, template literals, bitwise operators with JS-exact ToInt32 semantics, and the static slice of regular expressions.
- **The standard library** — strings with UTF-16-exact surface semantics, arrays, `Map` and `Set` with JS-exact ordering, read-only `Date` values and calendar getters, `JSON` with runtime-validated casts, `Math`, typed arrays and `Buffer`, `Error` hierarchies with typed `catch`.
-- **Node's API surface** — `fs` (sync and promises), `path`, `process`, `child_process`, `os`, `crypto`, `url`/`URL`, `zlib`, timers and signal handlers on a dependency-free event loop, and the server stack: `net`, `http`, `https`, `tls`, `dgram`, `dns`, `readline`. Real servers compile:
+- **Node's API surface** — `fs` (sync and promises), `path`, `process`, `child_process`, `os`, `crypto`, `url`/`URL`, `zlib`, timers and signal handlers on a dependency-free event loop, the server stack: `net`, `http`, `https`, `tls`, `dgram`, `dns`, `readline`, and native `midi` (raw MIDI messaging over the same loop). Real servers compile:
```ts:server.ts
import { createServer } from "node:http";
diff --git a/docs/src/app/limitations/page.mdx b/docs/src/app/limitations/page.mdx
index 90333063e..c784a011b 100644
--- a/docs/src/app/limitations/page.mdx
+++ b/docs/src/app/limitations/page.mdx
@@ -87,6 +87,16 @@ const who = process.argv.length > 2 ? process.argv[2] : "world";
The production wasm32-wasi target supports the complete executable language tier through LLVM: async/await, promises, generators, timers and other portable event-loop work, stdin/readline, filesystem callbacks and promises, and the --dynamic island. Portable WASI Preview 1 has no socket, process-spawn, OS-signal, network-interface, or filesystem-notification capabilities, so networking/fetch, child processes, signal APIs, os.networkInterfaces(), and fs.watch are rejected before linking with SC3002. --sanitize, native FFI, and library-mode archive builds are also unavailable. Filesystem access is bounded by the host's preopens; scriptc run exposes the current working directory and /tmp. See [Platform Support](/platforms) for build and run details.
+## MIDI limits
+
+`node:midi` is raw MIDI messaging, modeled on node-midi/@julusian/midi — enumerate ports, open input/output (including virtual ports), send raw messages, and receive time-stamped messages via the `"message"` event.
+
+- **The runtime is byte-transparent.** It carries raw message bytes (Note On/Off, CC, Program Change, Pitch Bend, SysEx as a byte run) and neither parses nor validates MIDI semantics. Higher-level semantic events (`noteon`, `cc`, …), MIDI file parsing, sequencing/clock scheduling, and MIDI 2.0 / UMP are out of scope.
+- **Virtual ports are POSIX-only.** `openVirtualPort` works on Linux (ALSA) and macOS (CoreMIDI); on Windows WinMM it fails at runtime with a clear error, because WinMM has no user-space virtual ports. See [Platform Support](/platforms).
+- **No MIDI on WASI.** WASI Preview 1 has no MIDI capability, so any `node:midi` surface is rejected before linking with `SC3002`. Browser Web MIDI is a separate runtime the WASI target does not cover.
+- **`on`/`once` accept only the `"message"` event**, with a `(deltaTime, message)` listener. `deltaTime` is seconds since the previous message on that input (`0` for the first) and is inherently nondeterministic — a differential test must never print it.
+- **A Linux host without ALSA** (many CI containers) has no MIDI backend: the runtime enumerates zero ports and throws on open. The hardware-free loopback tests use a virtual-port pair on a capable host.
+
## Tooling gaps
- `scriptc run` does not forward extra CLI arguments to the program — `build` and invoke the binary directly.
diff --git a/docs/src/app/platforms/page.mdx b/docs/src/app/platforms/page.mdx
index 2f3d86b72..ca9289339 100644
--- a/docs/src/app/platforms/page.mdx
+++ b/docs/src/app/platforms/page.mdx
@@ -63,6 +63,44 @@ WASI is a production LLVM target with the same language tiers as the native targ
The remaining executable boundary is host capability, not language coverage. WASI Preview 1 has no portable socket, process-spawn, OS-signal, network-interface, or filesystem-notification APIs. Networking/fetch, child processes, signal APIs, os.networkInterfaces(), and fs.watch therefore fail before linking with diagnostic SC3002. --sanitize, native FFI, and library-mode archive builds are unavailable too. Filesystem behavior is bounded by the host's preopens, and process/OS introspection follows WASI's reduced model.
+## MIDI (`node:midi`)
+
+Raw MIDI messaging (`node:midi`, API-compatible with node-midi/@julusian/midi) is a native runtime unit linked only into binaries that use it. Each platform binds its own MIDI stack, so the availability is per target:
+
+
+
+
+ | Platform |
+ Backend |
+ Virtual ports |
+
+
+
+
+ | Linux |
+ ALSA sequencer (libasound, linked as -lasound) |
+ Yes — a native ALSA port other clients connect to |
+
+
+ | macOS |
+ CoreMIDI (-framework CoreMIDI) |
+ Yes — MIDISourceCreate/MIDIDestinationCreate |
+
+
+ | Windows |
+ WinMM (winmm.lib) |
+ No — WinMM has no user-space virtual ports; openVirtualPort fails at runtime with a clear error |
+
+
+ | WASI |
+ None |
+ No — any midi surface fences before linking with SC3002 |
+
+
+
+
+An open Input is a live pollable source that holds the event loop alive (like a bound `dgram` socket); an Output is fire-and-forget. Port enumeration (`getPortCount`/`getPortName`) works on a fresh handle before `openPort`. The runtime is byte-transparent — it neither parses nor validates MIDI message semantics. Note that a Linux host without an ALSA sound stack (many CI containers) enumerates zero ports and throws on open.
+
## Cross-target limits
- `--sanitize` is a host-build lane.
diff --git a/package.json b/package.json
index fdf35d415..9e773cd0e 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,7 @@
"devDependencies": {
"@types/node": "^24.0.0",
"eslint": "^9.20.0",
+ "midi": "npm:@julusian/midi@^3.8.1",
"tsx": "^4.19.0",
"typescript": "5.9.3",
"typescript-eslint": "^8.24.0",
diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts
index fee214132..c50a96327 100644
--- a/packages/compiler/ambient/scriptc-node-fallback.d.ts
+++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts
@@ -3032,6 +3032,47 @@ declare module "node:dns" {
export * from "dns";
}
+/* node:midi — raw MIDI messaging over the event loop (scr_midi.c, linked
+ * only into using binaries — the moduleUsesMidi switch). API-compatible
+ * with node-midi/@julusian/midi so the Node differential baseline is a
+ * real, installable package. Input is a live pollable source (an open
+ * port holds the loop alive, like a bound dgram socket); Output is
+ * fire-and-forget (send never holds the loop). Port enumeration
+ * (getPortCount/getPortName) works on a fresh handle before openPort —
+ * enumerate then open, like node-midi. openVirtualPort is POSIX-only and
+ * fences at runtime on Windows (WinMM has no user-space virtual ports).
+ * on/once accept ONLY the "message" event with a (deltaTime, message)
+ * handler; message bytes arrive as a number[] with deltaTime (seconds
+ * since the previous message, 0 for the first) as the leading argument —
+ * the node-midi callback shape. sendMessage takes an array literal or a
+ * Uint8Array; the runtime is byte-transparent (it neither parses nor
+ * validates MIDI semantics). */
+declare module "midi" {
+ export class Input {
+ getPortCount(): number;
+ getPortName(port: number): string;
+ openPort(port: number): void;
+ openVirtualPort(name: string): void;
+ closePort(): void;
+ isPortOpen(): boolean;
+ ignoreTypes(sysex: boolean, timing: boolean, activeSensing: boolean): void;
+ on(event: "message", listener: (deltaTime: number, message: number[]) => void): void;
+ once(event: "message", listener: (deltaTime: number, message: number[]) => void): void;
+ }
+ export class Output {
+ getPortCount(): number;
+ getPortName(port: number): string;
+ openPort(port: number): void;
+ openVirtualPort(name: string): void;
+ closePort(): void;
+ isPortOpen(): boolean;
+ sendMessage(message: number[] | Uint8Array): void;
+ }
+}
+declare module "node:midi" {
+ export * from "midi";
+}
+
/* node:worker_threads — the MAIN-THREAD slice only. A compiled binary is
* always the main thread (no JS-engine thread machinery exists), so
* isMainThread lowers to `true` and threadId to 0 — Node's main-thread
@@ -3133,7 +3174,9 @@ declare module "node:async_hooks" {
* checks annotated listener parameters against it (unannotated non-empty
* parameter lists have no static types and fence at the registration). */
declare module "events" {
- class EventEmitter {
+ // Node 24 makes EventEmitter generic; the native surface remains
+ // intentionally event-name agnostic, but accepts that type argument.
+ class EventEmitter {
constructor();
static defaultMaxListeners: number;
on(eventName: string, listener: (...args: any[]) => void): this;
diff --git a/packages/compiler/src/backend/cc.ts b/packages/compiler/src/backend/cc.ts
index 94a00daa2..15c962c17 100644
--- a/packages/compiler/src/backend/cc.ts
+++ b/packages/compiler/src/backend/cc.ts
@@ -341,6 +341,12 @@ export interface CcOptions {
* on the IR): compiles scr_dgram.c into the binary — the net gating
* precedent, so dgram-free binaries keep their exact link line. */
dgram?: boolean;
+ /** The program uses the node:midi surface (moduleUsesMidi on the IR):
+ * compiles scr_midi.c into the binary and links the platform MIDI stack
+ * (ALSA seq on Linux where libasound is present, CoreMIDI on macOS, WinMM
+ * on Windows) — the dgram gating precedent, so midi-free binaries keep
+ * their exact link line. */
+ midi?: boolean;
/** The program uses fs.watch (moduleUsesFsWatch on the IR): compiles
* scr_watch.c into the binary — the net gating precedent, so watch-free
* binaries keep their exact link line. */
@@ -3595,7 +3601,7 @@ export async function compileC(opts: CcOptions): Promise {
// platform, so all three link whenever a poller-using unit does and
// the others cost nothing (ws2_32 rides the unconditional win32 libs
// above).
- ...(net || opts.dgram
+ ...(net || opts.dgram || opts.midi
? [
rt(join(rtDir, "scr_loop_kqueue.c")),
rt(join(rtDir, "scr_loop_epoll.c")),
@@ -3606,6 +3612,26 @@ export async function compileC(opts: CcOptions): Promise {
...(http ? [rt(join(rtDir, "scr_http.c"))] : []),
...(opts.http2 ?? false ? [rt(join(rtDir, "scr_http2.c"))] : []),
...(opts.dgram ? [rt(join(rtDir, "scr_dgram.c"))] : []),
+ // node:midi (scr_midi.c) + the platform MIDI stack. The runtime's ALSA
+ // backend is guarded by __has_include(): on a Linux
+ // host with libasound-dev it compiles the ALSA seq path and needs
+ // -lasound; without the header it compiles a stub that references no
+ // snd_* symbols, so -lasound must be withheld or the link fails. The
+ // host header probe below matches that compile-time guard (the default
+ // host-target path; a cross-compile to Linux keys off the target sysroot
+ // header at compile time and may need the flag threaded explicitly).
+ ...(opts.midi
+ ? [
+ rt(join(rtDir, "scr_midi.c")),
+ ...(targetPlatform(driver) === "darwin"
+ ? ["-framework", "CoreMIDI", "-framework", "CoreFoundation"]
+ : targetPlatform(driver) === "win32"
+ ? ["-lwinmm"]
+ : targetPlatform(driver) === "linux" && existsSync("/usr/include/alsa/asoundlib.h")
+ ? ["-lasound"]
+ : []),
+ ]
+ : []),
...(opts.watch ? [rt(join(rtDir, "scr_watch.c"))] : []),
...(opts.nodeTest ? [rt(join(rtDir, "scr_test.c"))] : []),
// The CA-store unit rides its own gate OR the tls one: scr_tls.c
diff --git a/packages/compiler/src/backend/emission/emit-exprs.ts b/packages/compiler/src/backend/emission/emit-exprs.ts
index 6093c0a6d..8174a574b 100644
--- a/packages/compiler/src/backend/emission/emit-exprs.ts
+++ b/packages/compiler/src/backend/emission/emit-exprs.ts
@@ -4296,6 +4296,54 @@ export function emitExpr(E: CEmitter, e: IrExpr): Temp {
E.line(`scr_dns_lookup(${arg(0)}, ${arg(1)}, ${cb.name}, &${adapter});${E.srcComment(e.loc)}`);
return { name: "", type: e.type };
}
+ // node:midi (scr_midi.c + the loop's midi hook — linked only when
+ // these appear on the IR; moduleUsesMidi is the switch). Handles
+ // and byte payloads are BORROWED; the onMessage CALLBACK MOVES into
+ // the input's registry. An open input port holds the loop live
+ // (usesTimers) — a source of pending messages, like a bound socket.
+ case "midi.newInput":
+ return finish(`scr_midi_input_new()`);
+ case "midi.newOutput":
+ return finish(`scr_midi_output_new()`);
+ case "midi.portCount":
+ return finish(`scr_midi_port_count(${arg(0)}, ${arg(1)})`);
+ case "midi.portName":
+ return finish(`scr_midi_port_name(${arg(0)}, ${arg(1)})`);
+ case "midi.openPort":
+ // Opening an INPUT makes the loop live; an OUTPUT does not.
+ if (e.args[0]!.type.kind === "midiInput") E.usesTimers = true;
+ return finish(`scr_midi_open_port(${arg(0)}, ${arg(1)})`);
+ case "midi.openVirtual":
+ if (e.args[0]!.type.kind === "midiInput") E.usesTimers = true;
+ return finish(`scr_midi_open_virtual(${arg(0)}, ${arg(1)})`);
+ case "midi.closePort":
+ E.line(`scr_midi_close_port(${arg(0)});${E.srcComment(e.loc)}`);
+ return { name: "", type: e.type };
+ case "midi.isOpen":
+ return finish(`scr_midi_is_open(${arg(0)})`);
+ case "midi.ignoreTypes":
+ E.line(`scr_midi_ignore_types(${arg(0)}, ${arg(1)}, ${arg(2)}, ${arg(3)});${E.srcComment(e.loc)}`);
+ return { name: "", type: e.type };
+ case "midi.sendArray":
+ return finish(`scr_midi_send_array(${arg(0)}, ${arg(1)})`);
+ case "midi.sendBytes":
+ return finish(`scr_midi_send_bytes(${arg(0)}, ${arg(1)})`);
+ case "midi.onMessage": {
+ // The message listener receives (deltaTime: f64, message:
+ // number[]); the runtime invokes the moved-in closure through
+ // the per-arity adapter picked by the declared param count.
+ E.usesTimers = true; // a listening input holds the loop open
+ const cbT = e.args[1]!.type;
+ if (cbT.kind !== "func") throw new Error("emitter bug: midi.onMessage callback not a func");
+ const cb = args[1]!;
+ E.moveTemp(cb);
+ const adapter =
+ cbT.params.length === 0 ? "scr_midi_msg_thunk0"
+ : cbT.params.length === 1 ? "scr_midi_msg_thunk1"
+ : "scr_midi_msg_thunk2";
+ E.line(`scr_midi_on_message(${arg(0)}, ${cb.name}, &${adapter}, ${arg(2)});${E.srcComment(e.loc)}`);
+ return { name: "", type: e.type };
+ }
// node:test (scr_test.c — linked only when these appear on the
// IR; moduleUsesNodeTest is the switch). Strings borrowed,
// callbacks MOVE. Registrations keep the loop-run emitted
diff --git a/packages/compiler/src/backend/emission/emit-types.ts b/packages/compiler/src/backend/emission/emit-types.ts
index 59eba2b91..04b3a90e3 100644
--- a/packages/compiler/src/backend/emission/emit-types.ts
+++ b/packages/compiler/src/backend/emission/emit-types.ts
@@ -60,6 +60,10 @@ export function cType(t: IrType): string {
return "ScrH2Stream *";
case "dgramSocket":
return "ScrDgramSocket *";
+ case "midiInput":
+ return "ScrMidiInput *";
+ case "midiOutput":
+ return "ScrMidiOutput *";
case "testCtx":
return "ScrTestCtx *";
case "httpReq":
@@ -164,6 +168,10 @@ export function retainCallC(type: IrType, expr: string): string {
return `scr_http2_stream_retain(${expr})`;
case "dgramSocket":
return `scr_dgram_retain(${expr})`;
+ case "midiInput":
+ return `scr_midi_input_retain(${expr})`;
+ case "midiOutput":
+ return `scr_midi_output_retain(${expr})`;
case "testCtx":
return `scr_testctx_retain(${expr})`;
case "httpReq":
@@ -245,6 +253,10 @@ export function releaseCallC(type: IrType, expr: string): string {
return `scr_http2_stream_release(${expr})`;
case "dgramSocket":
return `scr_dgram_release(${expr})`;
+ case "midiInput":
+ return `scr_midi_input_release(${expr})`;
+ case "midiOutput":
+ return `scr_midi_output_release(${expr})`;
case "testCtx":
return `scr_testctx_release(${expr})`;
case "httpReq":
@@ -320,6 +332,8 @@ export function boxKindC(t: IrType): string {
case "http2Session":
case "http2Stream":
case "dgramSocket":
+ case "midiInput":
+ case "midiOutput":
case "testCtx":
case "httpReq":
case "httpRes":
@@ -403,6 +417,10 @@ export function vAdapters(t: IrType): { retain: string; release: string } {
return { retain: "scr_http2_stream_retain_v", release: "scr_http2_stream_release_v" };
case "dgramSocket":
return { retain: "scr_dgram_retain_v", release: "scr_dgram_release_v" };
+ case "midiInput":
+ return { retain: "scr_midi_input_retain_v", release: "scr_midi_input_release_v" };
+ case "midiOutput":
+ return { retain: "scr_midi_output_retain_v", release: "scr_midi_output_release_v" };
case "testCtx":
return { retain: "scr_testctx_retain_v", release: "scr_testctx_release_v" };
case "httpReq":
@@ -526,6 +544,8 @@ export function elemKindC(elem: IrType): string {
case "http2Session":
case "http2Stream":
case "dgramSocket":
+ case "midiInput":
+ case "midiOutput":
case "testCtx":
case "httpReq":
case "httpRes":
diff --git a/packages/compiler/src/backend/emission/emitter.ts b/packages/compiler/src/backend/emission/emitter.ts
index 8820dbb90..29188c9b9 100644
--- a/packages/compiler/src/backend/emission/emitter.ts
+++ b/packages/compiler/src/backend/emission/emitter.ts
@@ -38,7 +38,7 @@ import type {
IrUnionDef,
SrcLoc,
} from "../../ir/nodes.js";
-import { ffiCallbackType, funcOf, isFfiCallbackParam, isFfiContextParam, isRefCounted, isUnitType, mapOf, moduleEmbedsCompressedNpm, moduleUsesDgram, moduleUsesDynInvoke, moduleEmbedsBuiltin, moduleUsesFetch, moduleUsesFsWatch, moduleUsesHttp2, moduleUsesHttpServer, moduleUsesNet, moduleUsesNodeTest, moduleUsesProcessEvents, moduleUsesStream, moduleUsesTls, moduleUsesTlsCa, RUNTIME_EMITTER_CLASS, STRING, VOID } from "../../ir/nodes.js";
+import { ffiCallbackType, funcOf, isFfiCallbackParam, isFfiContextParam, isRefCounted, isUnitType, mapOf, moduleEmbedsCompressedNpm, moduleUsesDgram, moduleUsesDynInvoke, moduleEmbedsBuiltin, moduleUsesFetch, moduleUsesFsWatch, moduleUsesHttp2, moduleUsesHttpServer, moduleUsesMidi, moduleUsesNet, moduleUsesNodeTest, moduleUsesProcessEvents, moduleUsesStream, moduleUsesTls, moduleUsesTlsCa, RUNTIME_EMITTER_CLASS, STRING, VOID } from "../../ir/nodes.js";
import { allocateFfiCallbackAdapters, type FfiCallbackAdapter } from "../ffi-callbacks.js";
import {
mangleAsyncSpawn,
@@ -924,6 +924,9 @@ export class CEmitter {
// Dgram/dns-surface programs fill the loop's dgram hooks the same
// way — scr_dgram.c links only when this line is emitted.
...(moduleUsesDgram(this.mod) ? [` scr_dgram_install();`] : []),
+ // node:midi programs fill the loop's midi hook the same way —
+ // scr_midi.c links only when this line is emitted.
+ ...(moduleUsesMidi(this.mod) ? [` scr_midi_install();`] : []),
// fs.watch programs fill the loop's watch hooks the same way —
// scr_watch.c links only when this line is emitted.
...(moduleUsesFsWatch(this.mod) ? [` scr_watch_install();`] : []),
@@ -1905,6 +1908,8 @@ export class CEmitter {
case "http2Session":
case "http2Stream":
case "dgramSocket":
+ case "midiInput":
+ case "midiOutput":
case "testCtx":
case "httpReq":
case "httpRes":
diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts
index 8667621c8..a9036cf4b 100644
--- a/packages/compiler/src/frontend/lowering/lower-calls.ts
+++ b/packages/compiler/src/frontend/lowering/lower-calls.ts
@@ -3561,6 +3561,11 @@ export function lowerCall(L: Lowerer, expr: ts.CallExpression): IrExpr {
// The dgram spoke (lower-dgram.ts) owns dgram and dns the same way.
const dgramServed = L.lowerDgramDnsModuleCall(expr, bi, loc);
if (dgramServed) return dgramServed;
+ // The midi spoke (lower-midi.ts) owns node:midi — fence-only here
+ // (the ports are `new`-constructed, so a call on a midi binding has
+ // no lowering); construction rides the lowerNew chain.
+ const midiServed = L.lowerMidiModuleCall(expr, bi, loc);
+ if (midiServed) return midiServed;
// The assert spoke (lower-assert.ts) owns node:assert the same way
// (`import { strictEqual } from "node:assert"` and the destructured
// require twin land here).
@@ -4156,6 +4161,10 @@ export function lowerCall(L: Lowerer, expr: ts.CallExpression): IrExpr {
L.lowerDcTracingChannelMethodCall(expr, expr.expression) ??
L.lowerServerMethodCall(expr, expr.expression) ??
L.lowerDgramMethodCall(expr, expr.expression) ??
+ // midi.Input / midi.Output receivers — the port method surface
+ // (getPortCount/getPortName/openPort/openVirtualPort/closePort/
+ // isPortOpen, ignoreTypes, sendMessage) and the "message" listener.
+ L.lowerMidiMethodCall(expr, expr.expression) ??
// node:test — skip/todo/only twins on named import bindings, the
// TestContext surface (t.test/t.skip/t.diagnostic), t.assert.*.
L.lowerTestMethodCall(expr, expr.expression) ??
diff --git a/packages/compiler/src/frontend/lowering/lower-midi.ts b/packages/compiler/src/frontend/lowering/lower-midi.ts
new file mode 100644
index 000000000..fe1bf66e6
--- /dev/null
+++ b/packages/compiler/src/frontend/lowering/lower-midi.ts
@@ -0,0 +1,334 @@
+/* The midi-surface lowering (node:midi — a spoke module like lower-dgram.ts,
+ * on which it is modeled part for part): the port-handle CONSTRUCTORS
+ * (`new Input()` / `new Output()`, the node-midi/@julusian shape) and the
+ * method surface on midiInput/midiOutput receivers (getPortCount/
+ * getPortName/openPort/openVirtualPort/closePort/isPortOpen, ignoreTypes on
+ * inputs, sendMessage on outputs, and the on/once "message" listener).
+ * Construction is via `new` — the classes are the module's only exports, so
+ * there is NO module-function surface (unlike dgram's createSocket); a CALL
+ * on a midi import binding fences module-qualified. Everything the lib
+ * declares beyond these shapes fences member-qualified — never a generic
+ * rejection, never silence. */
+import * as ts from "../ts7/adapter.js";
+import type { Lowerer } from "./lowerer.js";
+import { locOf } from "../program.js";
+import { BOOL, F64, funcOf, IrExpr, IrLibFn, IrType, MIDIIN_T, MIDIOUT_T, SrcLoc, STRING, VOID } from "../../ir/nodes.js";
+
+const MIDI_SURFACE_HINT =
+ "getPortCount, getPortName, openPort, openVirtualPort, closePort, " +
+ "isPortOpen, ignoreTypes (Input), sendMessage (Output), and on/once of " +
+ '"message" are the supported midi Input/Output members';
+
+/** The midi lib-fn ids the runtime implements (scr_midi.c). These are NOT
+ * in the frozen IrLibFn union yet — the emitter cases land with the runtime
+ * TU (Phase 3/4); moduleUsesMidi already detects them by the "midi."
+ * prefix (its `typeof node.fn === "string"` guard is written for exactly
+ * this). The spoke casts through this alias so the lowering emits the frozen
+ * §4 ABI ids without touching the shared IR/emission front-matter. */
+type MidiLibFn =
+ | "midi.newInput"
+ | "midi.newOutput"
+ | "midi.portCount"
+ | "midi.portName"
+ | "midi.openPort"
+ | "midi.openVirtual"
+ | "midi.closePort"
+ | "midi.isOpen"
+ | "midi.ignoreTypes"
+ /** sendMessage's two marshalers, picked by argument type — the dgram
+ * sendStr/sendBytes split retargeted: a number[] literal/array rides
+ * sendArray (scr_midi_send_array over ScrArr*), a Uint8Array rides
+ * sendBytes (scr_midi_send_bytes over ScrBytes*). */
+ | "midi.sendArray"
+ | "midi.sendBytes"
+ /** on/once("message", (deltaTime, message) => …) — the trailing bool is
+ * once; the emitter picks the msg_thunk0/1/2 adapter by the listener's
+ * declared parameter count (the dgram.onMessage story exactly). */
+ | "midi.onMessage";
+const midiFn = (fn: MidiLibFn): IrLibFn => fn as unknown as IrLibFn;
+
+/** The module's lowered value members — the surfaces.ts twin. EMPTY: the
+ * two exports are classes reached through `new` (lowerMidiNew), so there is
+ * no module-function to table. The set exists to mirror the dgram spoke and
+ * to name the "recognized module, unlowered member" fence. */
+export const MIDI_MODULE_FNS: ReadonlySet = new Set();
+
+/** VOID-result port calls are usable as statements and as concise arrow
+ * bodies; anything consuming the result (Node returns void here too, but
+ * the fence keeps parity with the dgram stance) is fenced — the lower-dgram
+ * rule verbatim. */
+function requireStatementPosition(L: Lowerer, call: ts.CallExpression, what: string): void {
+ if (ts.isExpressionStatement(call.parent) || ts.isArrowFunction(call.parent)) return;
+ L.unsupported(
+ "SC1090",
+ call,
+ `using the result of ${what} (the result is void here — call it as its own statement)`,
+ );
+}
+
+/** Lowers a listener/callback argument, pinning the closure shape: void
+ * return, at most `maxParams` parameters, each parameter's IR kind
+ * satisfying `paramOk` (indexed). The lower-dgram helper's shape, re-stated
+ * here so the spoke stays self-contained. */
+function lowerCallbackArg(
+ L: Lowerer,
+ node: ts.Expression,
+ what: string,
+ maxParams: number,
+ paramOk: (p: IrType, i: number) => boolean,
+ paramHint: string,
+): { cb: IrExpr; nparams: number } {
+ let cb = L.lowerExpr(node);
+ // A checked-dynamic callback (test/common's mustCall wrapper — a dyn
+ // value): the zero-parameter slots adapt through the dynCheck function
+ // boundary, the lower-dgram listen-callback precedent.
+ if (cb.type.kind === "dyn" && maxParams === 0) {
+ cb = { kind: "dynCheck", value: cb, type: funcOf([], VOID), loc: locOf(node) };
+ }
+ if (cb.type.kind !== "func" || cb.type.params.length > maxParams) {
+ L.unsupported(
+ "SC1090",
+ node,
+ `${what} with more than ${maxParams} parameter${maxParams === 1 ? "" : "s"} (${paramHint})`,
+ );
+ }
+ if (cb.type.ret.kind !== "void") {
+ L.unsupported(
+ "SC1090",
+ node,
+ "listeners returning a value (make the callback body a block, or return nothing)",
+ );
+ }
+ for (let i = 0; i < cb.type.params.length; i++) {
+ if (!paramOk(cb.type.params[i]!, i)) {
+ L.unsupported("SC1090", node, `${what} whose parameter is not supported (${paramHint})`);
+ }
+ }
+ return { cb, nparams: cb.type.params.length };
+}
+
+const boolLit = (value: boolean, loc: SrcLoc): IrExpr => ({ kind: "boolLit", value, type: BOOL, loc });
+
+/** `new Input()` / `new Output()` — the port-handle constructors, one entry
+ * in lowerer.ts's lowerNew chain (the AbortController/Response precedent).
+ * The mapped instance type IS the discriminator: types.ts pins Input/Output
+ * declared inside `declare module "midi"` to midiInput/midiOutput (a user's
+ * local `class Input {}` never maps there), so the type answer both selects
+ * the constructor AND proves stdlib provenance. Null for any other `new`.
+ * Both ctors take no arguments (node-midi's `new midi.Input()`); an argument
+ * fences. */
+export function lowerMidiNew(L: Lowerer, expr: ts.NewExpression): IrExpr | null {
+ const kind = L.mapTypeOf(L.typeOf(expr))?.kind;
+ if (kind !== "midiInput" && kind !== "midiOutput") return null;
+ const isInput = kind === "midiInput";
+ const cls = isInput ? "Input" : "Output";
+ const args = expr.arguments ?? [];
+ const loc = locOf(expr);
+ if (args.length !== 0) {
+ L.noLowering(
+ `new ${cls} with ${args.length} argument${args.length === 1 ? "" : "s"}`,
+ expr,
+ `the supported form is new ${cls}() — the port constructors take no arguments`,
+ );
+ }
+ return {
+ kind: "libCall",
+ fn: midiFn(isInput ? "midi.newInput" : "midi.newOutput"),
+ args: [],
+ type: isInput ? MIDIIN_T : MIDIOUT_T,
+ loc,
+ };
+}
+
+/** Module-function calls on midi import bindings (named imports AND
+ * namespace members). node:midi has NO callable exports — Input/Output are
+ * classes reached through `new` — so every call fences module-qualified.
+ * Null for other modules (the caller falls through). */
+export function lowerMidiModuleCall(L: Lowerer, expr: ts.CallExpression,
+ bi: { module: string; member: string },
+ loc: SrcLoc,): IrExpr | null {
+ void loc;
+ if (bi.module !== "midi") return null;
+ L.noLowering(
+ `midi.${bi.member}`,
+ expr,
+ "node:midi has no callable exports — construct ports with new Input() / new Output()",
+ ts.isIdentifier(expr.expression) ? L.resolveValueSymbol(expr.expression) : undefined,
+ );
+}
+
+/** Method calls on midi.Input / midi.Output receivers — one entry in
+ * lower-calls.ts's intrinsic chain (after lowerDgramMethodCall). Null for
+ * other receivers. */
+export function lowerMidiMethodCall(L: Lowerer, call: ts.CallExpression,
+ access: ts.PropertyAccessExpression,): IrExpr | null {
+ if (call.questionDotToken || access.questionDotToken) return null;
+ const recvKind = L.mapTypeOf(L.typeOf(access.expression))?.kind;
+ if (recvKind !== "midiInput" && recvKind !== "midiOutput") return null;
+ if (!L.isStdlibMember(access)) return null;
+ const isInput = recvKind === "midiInput";
+ const name = access.name.text;
+ const loc = locOf(call);
+ const args = call.arguments;
+ // getPortCount() — enumeration works on a fresh handle before openPort
+ // (node-midi's enumerate-then-open, the ambient decl's promise). The
+ // frozen ABI passes the input/output discriminator so the shared C
+ // symbol reads the right stack. Value-returning: no statement fence.
+ if (name === "getPortCount") {
+ if (args.length !== 0) {
+ L.noLowering(`getPortCount with ${args.length} arguments`, call, "getPortCount() takes no arguments");
+ }
+ const receiver = L.lowerExpr(access.expression);
+ return { kind: "libCall", fn: midiFn("midi.portCount"), args: [receiver, boolLit(isInput, loc)], type: F64, loc };
+ }
+ if (name === "getPortName") {
+ if (args.length !== 1) {
+ L.noLowering(`getPortName with ${args.length} arguments`, call, "the supported form is getPortName(port)");
+ }
+ const receiver = L.lowerExpr(access.expression);
+ const port = L.lowerExprExpecting(args[0]!, F64);
+ return { kind: "libCall", fn: midiFn("midi.portName"), args: [receiver, port], type: STRING, loc };
+ }
+ if (name === "openPort") {
+ requireStatementPosition(L, call, "port.openPort(...)");
+ if (args.length !== 1) {
+ L.noLowering(`openPort with ${args.length} arguments`, call, "the supported form is openPort(port)");
+ }
+ const receiver = L.lowerExpr(access.expression);
+ const port = L.lowerExprExpecting(args[0]!, F64);
+ return { kind: "libCall", fn: midiFn("midi.openPort"), args: [receiver, port], type: VOID, loc };
+ }
+ if (name === "openVirtualPort") {
+ requireStatementPosition(L, call, "port.openVirtualPort(...)");
+ if (args.length !== 1) {
+ L.noLowering(`openVirtualPort with ${args.length} arguments`, call, "the supported form is openVirtualPort(name)");
+ }
+ const receiver = L.lowerExpr(access.expression);
+ const nm = L.lowerExprExpecting(args[0]!, STRING);
+ return { kind: "libCall", fn: midiFn("midi.openVirtual"), args: [receiver, nm], type: VOID, loc };
+ }
+ if (name === "closePort") {
+ requireStatementPosition(L, call, "port.closePort(...)");
+ if (args.length !== 0) {
+ L.noLowering(`closePort with ${args.length} arguments`, call, "closePort() takes no arguments");
+ }
+ const receiver = L.lowerExpr(access.expression);
+ return { kind: "libCall", fn: midiFn("midi.closePort"), args: [receiver], type: VOID, loc };
+ }
+ if (name === "isPortOpen") {
+ if (args.length !== 0) {
+ L.noLowering(`isPortOpen with ${args.length} arguments`, call, "isPortOpen() takes no arguments");
+ }
+ const receiver = L.lowerExpr(access.expression);
+ return { kind: "libCall", fn: midiFn("midi.isOpen"), args: [receiver], type: BOOL, loc };
+ }
+ if (name === "ignoreTypes") {
+ // Input-only (the ambient decl only puts it on Input); the type guard
+ // would already have refused an Output receiver at the checker, but the
+ // fence keeps the honest hint if the fallback surface ever widens.
+ if (!isInput) {
+ L.noLowering(
+ "midi.Output.ignoreTypes",
+ call,
+ `ignoreTypes is an Input member (${MIDI_SURFACE_HINT})`,
+ L.checker.getSymbolAtLocation(access.name),
+ );
+ }
+ requireStatementPosition(L, call, "input.ignoreTypes(...)");
+ if (args.length !== 3) {
+ L.noLowering(
+ `ignoreTypes with ${args.length} arguments`,
+ call,
+ "the supported form is ignoreTypes(sysex, timing, activeSensing) — three booleans",
+ );
+ }
+ const receiver = L.lowerExpr(access.expression);
+ const sysex = L.lowerExprExpecting(args[0]!, BOOL);
+ const timing = L.lowerExprExpecting(args[1]!, BOOL);
+ const sense = L.lowerExprExpecting(args[2]!, BOOL);
+ return { kind: "libCall", fn: midiFn("midi.ignoreTypes"), args: [receiver, sysex, timing, sense], type: VOID, loc };
+ }
+ if (name === "sendMessage") {
+ // Output-only. The runtime is byte-transparent: a number[] literal/
+ // array marshals through sendArray (ScrArr*), a Uint8Array through
+ // sendBytes (ScrBytes*) — the dgram sendStr/sendBytes split, one
+ // marshaler per static argument type.
+ if (isInput) {
+ L.noLowering(
+ "midi.Input.sendMessage",
+ call,
+ `sendMessage is an Output member (${MIDI_SURFACE_HINT})`,
+ L.checker.getSymbolAtLocation(access.name),
+ );
+ }
+ requireStatementPosition(L, call, "output.sendMessage(...)");
+ if (args.length !== 1) {
+ L.noLowering(
+ `sendMessage with ${args.length} arguments`,
+ call,
+ "the supported form is sendMessage(message) — one number[] or Uint8Array",
+ );
+ }
+ if (ts.isSpreadElement(args[0]!)) {
+ L.noLowering(
+ "sendMessage with a spread argument",
+ args[0]!,
+ "pass the message as a single number[] or Uint8Array value",
+ );
+ }
+ const receiver = L.lowerExpr(access.expression);
+ const data = L.lowerExpr(args[0]!);
+ const dt = data.type;
+ if (dt.kind === "array" && dt.elem.kind === "f64") {
+ return { kind: "libCall", fn: midiFn("midi.sendArray"), args: [receiver, data], type: VOID, loc };
+ }
+ if (dt.kind === "bytes" && dt.elem === "u8") {
+ return { kind: "libCall", fn: midiFn("midi.sendBytes"), args: [receiver, data], type: VOID, loc };
+ }
+ L.noLowering(
+ "sendMessage with a message that is not a number[] or Uint8Array",
+ args[0]!,
+ "the supported message shapes are a number[] (array literal) and a Uint8Array",
+ );
+ }
+ if ((name === "on" || name === "once") && args.length === 2) {
+ // The "message" listener — input-only (Output declares no events). The
+ // (deltaTime: number, message: number[]) node-midi shape; the trailing
+ // bool is once, and the emitter picks msg_thunk0/1/2 by the listener's
+ // declared parameter count (the dgram.onMessage discipline).
+ if (!isInput) {
+ L.noLowering(
+ `midi.Output.${name}`,
+ call,
+ `on/once are Input members (${MIDI_SURFACE_HINT})`,
+ L.checker.getSymbolAtLocation(access.name),
+ );
+ }
+ requireStatementPosition(L, call, `input.${name}(...)`);
+ const once = boolLit(name === "once", loc);
+ const evT = L.typeOf(args[0]!);
+ const event = evT.isStringLiteralType() ? evT.value : null;
+ const receiver = L.lowerExpr(access.expression);
+ if (event === "message") {
+ const { cb } = lowerCallbackArg(
+ L, args[1]!, "message listeners", 2,
+ (p, i) =>
+ i === 0 ? p.kind === "f64"
+ : p.kind === "array" && p.elem.kind === "f64",
+ "use (deltaTime: number, message: number[]) or (deltaTime) or ()",
+ );
+ return { kind: "libCall", fn: midiFn("midi.onMessage"), args: [receiver, cb, once], type: VOID, loc };
+ }
+ L.noLowering(
+ `input.${name}(${event === null ? "non-literal event" : `"${event}"`}, ...)`,
+ args[0]!,
+ '"message" is the supported midi Input event (as a literal)',
+ );
+ }
+ L.noLowering(
+ `midi.${isInput ? "Input" : "Output"}.${name}`,
+ call,
+ MIDI_SURFACE_HINT,
+ L.checker.getSymbolAtLocation(access.name),
+ );
+}
diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts
index dd7d162e7..d8a196c8b 100644
--- a/packages/compiler/src/frontend/lowering/lowerer.ts
+++ b/packages/compiler/src/frontend/lowering/lowerer.ts
@@ -63,6 +63,7 @@ import {
fallbackDtsPath,
isCjsExportTableLiteral,
isJsSourceFile,
+ isMidiTypesPath,
isNodeEsmFile,
isNodeTypesPath,
locOf,
@@ -100,6 +101,7 @@ import { builtinImportOf, createRequireBindingDecl, createRequireNamespaceDecl,
import { fenceFetchObjectAssignment, fenceFetchObjectBinding, fenceStaticAbortControllerMemberRead, fenceStaticHeadersIteration, fenceStaticHeadersMember, fenceStaticReadableStreamMember, fenceStaticResponseMember, fenceUnsupportedFetchConstructorMember, isIslandExpr, islandFuncValueFence, islandRegexpOf, jsvalIn, requireDynamicApi, islandGlobalFnOf, lowerAbortControllerNew, lowerDynamicHeadersIteratorCall, lowerDynamicHeadersSpread, lowerDynamicImportCall, lowerFetchCall, lowerFetchElementMethodCall, lowerResponseNew, lowerStaticFetchCompanionCall, lowerStaticAbortControllerCall, lowerStaticAbortSignalListenerCall, lowerStaticReadableStreamCancelCall, lowerStaticReadableStreamControllerCall, lowerStaticReadableStreamNew, lowerStaticReadableStreamReaderCall, lowerStaticResponseCall, lowerIslandMethodCall, lowerMathProperty, npmPackageOf, npmMemberFence, npmPackageOfSymbol } from "./lower-island.js";
import { lowerHttpHeadersElement, lowerNetModuleCall, lowerServerMethodCall, lowerServerProperty, lowerTlsRootCertificates } from "./lower-server.js";
import { lowerDgramDnsModuleCall, lowerDgramMethodCall } from "./lower-dgram.js";
+import { lowerMidiModuleCall, lowerMidiMethodCall, lowerMidiNew } from "./lower-midi.js";
import { lowerNodeTestModuleCall, lowerTestDirectCall, lowerTestMethodCall, lowerTestCtxProperty } from "./lower-test.js";
import { lowerAssertModuleCall, lowerAssertDirectCall } from "./lower-assert.js";
import { lowerUtilModuleCall } from "./lower-inspect.js";
@@ -7445,7 +7447,7 @@ export class Lowerer {
sf.fileName === this.overridesAmbient ||
sf.fileName === this.fallbackAmbient ||
this.program.isSourceFileDefaultLibrary(sf) ||
- (sf.isDeclarationFile && isNodeTypesPath(sf.fileName));
+ (sf.isDeclarationFile && (isNodeTypesPath(sf.fileName) || isMidiTypesPath(sf.fileName)));
nodeTypesOnlySymbol(sym: ts.Symbol | null | undefined): boolean {
return nodeTypesOnlySymbol(this, sym);
@@ -7799,7 +7801,7 @@ export class Lowerer {
const arg = this.lowerExpr(expr.arguments[0]!);
return { kind: "jsOp", op: "construct", args: [ctor, arg], type: JSVAL, loc };
}
- return lowerAbortControllerNew(this, expr) ?? lowerResponseNew(this, expr) ?? lowerStaticReadableStreamNew(this, expr) ?? lowerNew(this, expr);
+ return lowerAbortControllerNew(this, expr) ?? lowerResponseNew(this, expr) ?? lowerStaticReadableStreamNew(this, expr) ?? lowerMidiNew(this, expr) ?? lowerNew(this, expr);
}
lowerFieldRead(expr: ts.PropertyAccessExpression): IrExpr | null {
@@ -8018,6 +8020,11 @@ export class Lowerer {
// shape is special-cased there, so it never rides the param tables.
const dgramServed = this.lowerDgramDnsModuleCall(call, bi, locOf(access));
if (dgramServed) return dgramServed;
+ // The midi spoke owns node:midi for namespace members too — the module
+ // has no callable exports (ports are `new`-constructed), so this only
+ // ever fences a call on a midi binding module-qualified.
+ const midiServed = this.lowerMidiModuleCall(call, bi, locOf(access));
+ if (midiServed) return midiServed;
// The server-surface spoke owns net and http wholesale — the same
// dispatch the named-import path takes (`net.createServer(...)` via
// `import * as net` is portless's own spelling).
@@ -8244,6 +8251,20 @@ export class Lowerer {
return lowerDgramMethodCall(this, call, access);
}
+ // The midi spoke (lower-midi.ts): the node:midi module call (fence-only —
+ // no callable exports) and the midiInput/midiOutput method surface. The
+ // Input/Output constructors ride the lowerNew chain (lowerMidiNew).
+ lowerMidiModuleCall(expr: ts.CallExpression,
+ bi: { module: string; member: string },
+ loc: SrcLoc,): IrExpr | null {
+ return lowerMidiModuleCall(this, expr, bi, loc);
+ }
+
+ lowerMidiMethodCall(call: ts.CallExpression,
+ access: ts.PropertyAccessExpression,): IrExpr | null {
+ return lowerMidiMethodCall(this, call, access);
+ }
+
// The node:test spoke (lower-test.ts): registrations, suites, hooks,
// and the TestContext surface.
lowerNodeTestModuleCall(expr: ts.CallExpression,
diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts
index 4ccf0c2e5..f6749598a 100644
--- a/packages/compiler/src/frontend/lowering/surfaces.ts
+++ b/packages/compiler/src/frontend/lowering/surfaces.ts
@@ -805,6 +805,13 @@ export const BUILTIN_MODULE_FNS: Record
+ (ts.isInterfaceDeclaration(d) || ts.isClassDeclaration(d)) &&
+ ctx.isStdlibFile(d.getSourceFile()) &&
+ (isDeclaredInAmbientModule(d, "midi") || isMidiTypesPath(d.getSourceFile().fileName)),
+ )
+ ) {
+ return { kind: "midiInput" };
+ }
+ if (
+ psym?.name === "Output" &&
+ checker.declarationsOf(psym).some(
+ (d) =>
+ (ts.isInterfaceDeclaration(d) || ts.isClassDeclaration(d)) &&
+ ctx.isStdlibFile(d.getSourceFile()) &&
+ (isDeclaredInAmbientModule(d, "midi") || isMidiTypesPath(d.getSourceFile().fileName)),
+ )
+ ) {
+ return { kind: "midiOutput" };
+ }
// node:test's TestContext — the test-body parameter (`test('x', (t) =>
// ...)`). @types/node's `class TestContext` and the fallback
// declarations' interface both live inside `declare module "node:test"`
diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts
index 5f614973c..1e71d6d3a 100644
--- a/packages/compiler/src/index.ts
+++ b/packages/compiler/src/index.ts
@@ -21,7 +21,7 @@ import {
import { validateSidecar } from "./library/sidecar-validate.js";
import { entryFunctionExports, type EntryExportInfo } from "./frontend/lib-exports.js";
import { entryContractFacts, type ContractFacts } from "./frontend/lib-contract.js";
-import { moduleLibAsyncSurface, moduleLibNondeterministicSurface, moduleEmbedsBuiltin, moduleEmbedsCompressedNpm, moduleUsesAssert, moduleUsesCopying, moduleUsesDc, moduleUsesDgram, moduleUsesDynAsync, moduleUsesDynInvoke, moduleUsesEmitter, moduleUsesFetch, moduleUsesFileHandle, moduleUsesFsWatch, moduleUsesHttp2, moduleUsesHttpServer, moduleUsesInspect, moduleUsesLegacyTextDecoder, moduleUsesNet, moduleUsesNodeTest, moduleUsesParseArgs, moduleUsesProcessEvents, moduleUsesQs, moduleUsesRegex, moduleUsesSearchParams, moduleUsesStream, moduleUsesSymbol, moduleUsesTls, moduleUsesTlsCa, moduleUsesZlib, type IrFfiImport, type IrLibSection, type IrModule, type IrRecordShape, type IrType, type SrcLoc } from "./ir/nodes.js";
+import { moduleLibAsyncSurface, moduleLibNondeterministicSurface, moduleEmbedsBuiltin, moduleEmbedsCompressedNpm, moduleUsesAssert, moduleUsesCopying, moduleUsesDc, moduleUsesDgram, moduleUsesDynAsync, moduleUsesDynInvoke, moduleUsesEmitter, moduleUsesFetch, moduleUsesFileHandle, moduleUsesFsWatch, moduleUsesHttp2, moduleUsesHttpServer, moduleUsesInspect, moduleUsesLegacyTextDecoder, moduleUsesMidi, moduleUsesNet, moduleUsesNodeTest, moduleUsesParseArgs, moduleUsesProcessEvents, moduleUsesQs, moduleUsesRegex, moduleUsesSearchParams, moduleUsesStream, moduleUsesSymbol, moduleUsesTls, moduleUsesTlsCa, moduleUsesZlib, type IrFfiImport, type IrLibSection, type IrModule, type IrRecordShape, type IrType, type SrcLoc } from "./ir/nodes.js";
import { serializeModule } from "./ir/serialize.js";
import { validateModule } from "./ir/validate.js";
import { canonicalBuiltinModule, checkPreflight, isNodeTypesPath, loadProgram, locOf, requiresOf, resolveNpmImport, type LoadResult } from "./frontend/program.js";
@@ -230,6 +230,7 @@ function moduleWasiUnavailableSurface(mod: IrModule): { surface: string; loc: Sr
["h2.", "network sockets (WASI Preview 1 has no socket API)"],
["dgram.", "network sockets (WASI Preview 1 has no socket API)"],
["dns.", "network sockets (WASI Preview 1 has no socket API)"],
+ ["midi.", "MIDI devices (WASI Preview 1 has no MIDI API)"],
["tls.", "network sockets (WASI Preview 1 has no socket API)"],
["fetch.", "network-backed fetch (WASI Preview 1 has no socket API)"],
["fs.watch", "filesystem watching (WASI Preview 1 has no notification API)"],
@@ -244,6 +245,8 @@ function moduleWasiUnavailableSurface(mod: IrModule): { surface: string; loc: Sr
["http2Session", "network sockets (WASI Preview 1 has no socket API)"],
["http2Stream", "network sockets (WASI Preview 1 has no socket API)"],
["dgramSocket", "network sockets (WASI Preview 1 has no socket API)"],
+ ["midiInput", "MIDI devices (WASI Preview 1 has no MIDI API)"],
+ ["midiOutput", "MIDI devices (WASI Preview 1 has no MIDI API)"],
["fsWatcher", "filesystem watching (WASI Preview 1 has no notification API)"],
["httpReq", "network sockets (WASI Preview 1 has no socket API)"],
["httpRes", "network sockets (WASI Preview 1 has no socket API)"],
@@ -432,11 +435,10 @@ function detectAutoPackages(
}
for (const { spec, loc } of edges) {
if (isRelativeSpecifier(spec) || spec.startsWith("node:") || spec.startsWith("#")) continue;
- // Bare builtin names ("fs", "path") are the builtin machinery's
- // business (and the SC4005 async_free gate's, in library mode) —
- // never npm candidates. Auto keeps its original path (the
- // @types/node answer skips them below), byte-for-byte.
- if (mode === "lib" && canonicalBuiltinModule(spec) !== null) continue;
+ // Bare builtin names ("fs", "path", and the Node-compatible "midi"
+ // package surface) are the builtin machinery's business — never npm
+ // candidates, even when a package supplies the declarations.
+ if (canonicalBuiltinModule(spec) !== null) continue;
const npm = resolveNpmImport(sf.fileName, spec);
if (npm !== null && isNodeTypesPath(npm.typesFile)) continue;
if (npm === null) {
@@ -1053,6 +1055,8 @@ export async function compile(entryPath: string, opts: CompileOptions): Promise<
http2: moduleUsesHttp2(lowered.module),
// The link switch for scr_dgram.c: dgram.* or dns.* libCalls on the IR.
dgram: moduleUsesDgram(lowered.module),
+ // The link switch for scr_midi.c: midi.* libCalls on the IR.
+ midi: moduleUsesMidi(lowered.module),
// The link switch for scr_watch.c: fs.watch/watcher.* libCalls on the IR.
watch: moduleUsesFsWatch(lowered.module),
// The link switch for scr_test.c: test.* libCalls on the IR.
diff --git a/packages/compiler/src/ir/nodes.ts b/packages/compiler/src/ir/nodes.ts
index 9335ac1c5..3dc0cb61f 100644
--- a/packages/compiler/src/ir/nodes.ts
+++ b/packages/compiler/src/ir/nodes.ts
@@ -157,6 +157,20 @@ export type IrType =
* lean allocation, no trace header. Same container rules: union arms
* fine, arrays/maps/JSON fenced. */
| { kind: "dgramSocket" }
+ /** A node:midi input port handle (scr_midi.c — linked only when the IR
+ * uses the midi surface, the moduleUsesMidi switch). Heap, refcounted,
+ * MUTABLE like dgramSocket: the loop's midi hook delivers time-stamped
+ * messages and fires its listeners. An OPEN input is a live source that
+ * holds the loop alive (the bound-socket story); listeners are held only
+ * until the handle settles (closePort, or the exit-time cleanup) — the
+ * dgramSocket ownership story, so lean allocation, no trace header. Same
+ * container rules: union arms fine, arrays/maps/JSON fenced. */
+ | { kind: "midiInput" }
+ /** A node:midi output port handle (scr_midi.c — same unit as midiInput).
+ * Heap, refcounted like midiInput, but an output NEVER holds the loop
+ * alive (sendMessage is fire-and-forget, like a connected dgram send).
+ * No listeners — lean, no trace header. */
+ | { kind: "midiOutput" }
/** A node:test TestContext handle (scr_test.c — linked only when the
* IR uses the node:test surface). Heap, refcounted, no cycles (the
* runner tree owns the children; the parent edge is a borrowed
@@ -320,7 +334,7 @@ export const REF_TRUTHY_KINDS: ReadonlySet = new Set([
// constant-true answer.
"symbol",
"date", "array", "map", "set", "regex", "url", "searchParams", "stats", "fileHandle", "spawnRes", "child",
- "netServer", "netSocket", "http2Session", "http2Stream", "dgramSocket", "testCtx", "httpReq", "httpRes", "httpClientReq",
+ "netServer", "netSocket", "http2Session", "http2Stream", "dgramSocket", "midiInput", "midiOutput", "testCtx", "httpReq", "httpRes", "httpClientReq",
"secureCtx", "fsWatcher", "childStream", "procStream", "bytes", "func", "object", "record", "promise",
// A generator object is a JS object: always truthy.
"generator",
@@ -346,6 +360,8 @@ export const NETSOCKET_T: IrType = { kind: "netSocket" };
export const HTTP2SESSION_T: IrType = { kind: "http2Session" };
export const HTTP2STREAM_T: IrType = { kind: "http2Stream" };
export const DGRAMSOCK_T: IrType = { kind: "dgramSocket" };
+export const MIDIIN_T: IrType = { kind: "midiInput" };
+export const MIDIOUT_T: IrType = { kind: "midiOutput" };
export const TESTCTX_T: IrType = { kind: "testCtx" };
export const HTTPREQ_T: IrType = { kind: "httpReq" };
export const HTTPRES_T: IrType = { kind: "httpRes" };
@@ -529,6 +545,8 @@ export function typeKey(t: IrType): string {
case "http2Session":
case "http2Stream":
case "dgramSocket":
+ case "midiInput":
+ case "midiOutput":
case "testCtx":
case "httpReq":
case "httpRes":
@@ -648,6 +666,10 @@ export function isRefCounted(t: IrType): boolean {
t.kind === "http2Session" ||
t.kind === "http2Stream" ||
t.kind === "dgramSocket" ||
+ // midi input/output handles are refcounted like dgramSocket (listeners
+ // drop at closePort, so lean allocation — see the IrType comment).
+ t.kind === "midiInput" ||
+ t.kind === "midiOutput" ||
// TestContext handles are refcounted like dgramSocket (the runner
// tree owns children; no cycles through the handle).
t.kind === "testCtx" ||
@@ -2407,6 +2429,26 @@ export type IrLibFn =
| "dgram.onClose"
| "dgram.onConnect"
| "dns.lookup"
+ /** node:midi (scr_midi.c + the loop's midi hook — linked only when one
+ * of these appears on the IR; moduleUsesMidi is the switch). Input and
+ * Output handles construct through new*; the port surface enumerates,
+ * opens (real or virtual), and closes; sendArray/sendBytes marshal a
+ * number[] or Uint8Array to the wire; onMessage MOVES its callback into
+ * the input's registry and fires it (deltaTime, number[]) on the loop
+ * thread through the per-arity adapter (scr_midi_msg_thunk0/1/2). Opens
+ * and sends may-throw (bad index, closed port, no backend). */
+ | "midi.newInput"
+ | "midi.newOutput"
+ | "midi.portCount"
+ | "midi.portName"
+ | "midi.openPort"
+ | "midi.openVirtual"
+ | "midi.closePort"
+ | "midi.isOpen"
+ | "midi.ignoreTypes"
+ | "midi.sendArray"
+ | "midi.sendBytes"
+ | "midi.onMessage"
/** node:test (scr_test.c — linked only when one of these appears on
* the IR; moduleUsesNodeTest is the switch, and the main epilogue asks
* scr_test_exit_code() for the process's exit status). Strings are
@@ -5337,6 +5379,8 @@ function isJsonSafeAt(
case "http2Session":
case "http2Stream":
case "dgramSocket":
+ case "midiInput":
+ case "midiOutput":
case "testCtx":
case "httpReq":
case "httpRes":
@@ -6472,6 +6516,37 @@ export function moduleUsesDgram(mod: IrModule): boolean {
return found;
}
+/** True when the module contains any midi.* libCall — the link switch
+ * that pulls scr_midi.c into the binary and has the emitted main call the
+ * midi install/dispatch hook (cc.ts + emitter; the moduleUsesDgram shape,
+ * with the ALSA/CoreMIDI/WinMM link flags gated on the same answer).
+ * midi-free programs pay zero bytes and keep their exact link line. Same
+ * generic-walk shape as moduleUsesDgram. */
+export function moduleUsesMidi(mod: IrModule): boolean {
+ let found = false;
+ const visit = (v: unknown): void => {
+ if (found || v === null || typeof v !== "object") return;
+ if (Array.isArray(v)) {
+ for (const item of v) visit(item);
+ return;
+ }
+ const node = v as { kind?: unknown; fn?: unknown };
+ if (node.kind === "libCall" && typeof node.fn === "string" && node.fn.startsWith("midi.")) {
+ found = true;
+ return;
+ }
+ // A midi HANDLE TYPE left behind by a fenced statement still emits a
+ // release call — the unit must link (the moduleUsesDgram type story).
+ if (node.kind === "midiInput" || node.kind === "midiOutput") {
+ found = true;
+ return;
+ }
+ for (const key of Object.keys(v)) visit((v as Record)[key]);
+ };
+ visit(mod);
+ return found;
+}
+
/** True when the module contains any http.* libCall — the link switch
* that pulls scr_http.c into the binary (cc.ts; moduleUsesNet already
* answers true for these, so scr_net.c comes along). */
@@ -6670,6 +6745,8 @@ const LIB_MODE_REFUSED_KINDS: ReadonlyMap = new Map([
["http2Session", "the node:http2 surface"],
["http2Stream", "the node:http2 surface"],
["dgramSocket", "the node:dgram surface"],
+ ["midiInput", "the node:midi surface"],
+ ["midiOutput", "the node:midi surface"],
["fsWatcher", "fs.watch"],
["testCtx", "the node:test surface"],
["httpReq", "the node:http surface"],
@@ -6731,6 +6808,7 @@ export function moduleLibAsyncSurface(mod: IrModule): { surface: string; loc: Sr
[moduleUsesHttpServer(mod), "the node:http surface"],
[moduleUsesHttp2(mod), "the node:http2 surface"],
[moduleUsesDgram(mod), "the node:dgram surface"],
+ [moduleUsesMidi(mod), "the node:midi surface"],
[moduleUsesFsWatch(mod), "fs.watch"],
[moduleUsesStream(mod), "the node:stream surface"],
[moduleUsesTls(mod), "the node:tls surface"],
@@ -7163,6 +7241,16 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([
"dgram.address",
"dgram.close",
"dgram.closeCb",
+ // node:midi synchronous throws: allocation failure on construct, a bad
+ // port index or absent backend on open, a virtual port where the platform
+ // has none (WinMM), and send on a closed output.
+ "midi.newInput",
+ "midi.newOutput",
+ "midi.portName",
+ "midi.openPort",
+ "midi.openVirtual",
+ "midi.sendArray",
+ "midi.sendBytes",
// The assert surface: every entry point except sameValue, bytesDeepEq,
// and the shape accumulator's begin/slot/test calls throws the
// catchable AssertionError on failure.
diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts
index f7f99e815..3db4f795c 100644
--- a/packages/compiler/src/ir/validate.ts
+++ b/packages/compiler/src/ir/validate.ts
@@ -18,7 +18,7 @@ import type {
IrUnionDef,
SrcLoc,
} from "./nodes.js";
-import { arrayOf, BOOL, BYTES_U8, bytesOf, canAdaptDynFuncTo, canConvertToDyn, canExitIslandToType, canMarshalIntoIsland, canMarshalTypedFuncIntoIsland, CHILD_T, CHILDSTREAM_T, DATE_T, DGRAMSOCK_T, DYN, DYN_HANDLE_KINDS, F64, ffiClassType, ffiSourceParamTypes, FILEHANDLE_T, FSWATCHER_T, HTTP2SESSION_T, HTTP2STREAM_T, HTTPCLIENTREQ_T, HTTPREQ_T, HTTPRES_T, islandPromisePayloadTag, isJsonSafeType, isRefCounted, isSupportedIndexValue, isSupportedMapKey, isSupportedMapValue, isSupportedSetElem, isUnitType, jsOpResultKind, JSVAL, NETSERVER_T, NETSOCKET_T, PROCSTREAM_T, REF_TRUTHY_KINDS, REGEX, RUNTIME_EMITTER_CLASS, RUNTIME_ERROR_CLASSES, RUNTIME_STREAM_CLASSES, SEARCH_PARAMS_T, SECURECTX_T, SPAWNRES_T, STATS_T, STRING, SYMBOL_T, TESTCTX_T, typeEquals, typeKey, unionFuncSetArmsOk, URL_T, VOID } from "./nodes.js";
+import { arrayOf, BOOL, BYTES_U8, bytesOf, canAdaptDynFuncTo, canConvertToDyn, canExitIslandToType, canMarshalIntoIsland, canMarshalTypedFuncIntoIsland, CHILD_T, CHILDSTREAM_T, DATE_T, DGRAMSOCK_T, DYN, DYN_HANDLE_KINDS, F64, ffiClassType, ffiSourceParamTypes, FILEHANDLE_T, FSWATCHER_T, HTTP2SESSION_T, HTTP2STREAM_T, HTTPCLIENTREQ_T, HTTPREQ_T, HTTPRES_T, islandPromisePayloadTag, MIDIIN_T, MIDIOUT_T, isJsonSafeType, isRefCounted, isSupportedIndexValue, isSupportedMapKey, isSupportedMapValue, isSupportedSetElem, isUnitType, jsOpResultKind, JSVAL, NETSERVER_T, NETSOCKET_T, PROCSTREAM_T, REF_TRUTHY_KINDS, REGEX, RUNTIME_EMITTER_CLASS, RUNTIME_ERROR_CLASSES, RUNTIME_STREAM_CLASSES, SEARCH_PARAMS_T, SECURECTX_T, SPAWNRES_T, STATS_T, STRING, SYMBOL_T, TESTCTX_T, typeEquals, typeKey, unionFuncSetArmsOk, URL_T, VOID } from "./nodes.js";
/** Per-method signature for strIntrinsic: `argTypes` lists every argument
* position (optional ones included); `minArgs` is how many may be omitted
@@ -404,6 +404,21 @@ export const LIB_FN_SIGS: Record result — the spoke pinned the
// shape): null slots. sub's result is the settled Promise the
diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json
index 7158ba9f5..0654fe846 100644
--- a/packages/compiler/surface-manifest.json
+++ b/packages/compiler/surface-manifest.json
@@ -1119,6 +1119,13 @@
"status": "static",
"note": "recognized module (bare and node:-prefixed specifiers)"
},
+ {
+ "id": "node-builtin.midi",
+ "kind": "node-builtin",
+ "name": "midi",
+ "status": "static",
+ "note": "recognized module (bare and node:-prefixed specifiers)"
+ },
{
"id": "node-builtin.module",
"kind": "node-builtin",
diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json
index 2fab13504..edeb44b79 100644
--- a/packages/compiler/test/ts7/baselines/order-parity.json
+++ b/packages/compiler/test/ts7/baselines/order-parity.json
@@ -7346,6 +7346,12 @@
],
"diags": []
},
+ "/tests/diagnostics/midi.ts": {
+ "order": [
+ "/tests/diagnostics/midi.ts"
+ ],
+ "diags": []
+ },
"/tests/diagnostics/mixed-compare.ts": {
"order": [
"/tests/diagnostics/mixed-compare.ts"
diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c
index f95c67b2a..d168ff9f6 100644
--- a/packages/runtime/src/scr_async.c
+++ b/packages/runtime/src/scr_async.c
@@ -2185,6 +2185,19 @@ void scr_loop_set_dgram(bool (*pending)(void), void (*dispatch)(void), int (*pol
scr_dgram_pollfd_fn = pollfd;
}
+/* The midi hook (scr_midi.c, when linked) — the dgram hook's exact shape:
+ * one more set of nullable slots, byte-identical loop behavior when
+ * unset. */
+static bool (*scr_midi_pending_fn)(void) = NULL;
+static void (*scr_midi_dispatch_fn)(void) = NULL;
+static int (*scr_midi_pollfd_fn)(void) = NULL;
+
+void scr_loop_set_midi(bool (*pending)(void), void (*dispatch)(void), int (*pollfd)(void)) {
+ scr_midi_pending_fn = pending;
+ scr_midi_dispatch_fn = dispatch;
+ scr_midi_pollfd_fn = pollfd;
+}
+
/* The fs.watch hook (scr_watch.c, when linked) — the net hook's exact
* shape: one more set of nullable slots, byte-identical loop behavior
* when unset. */
@@ -2333,6 +2346,13 @@ bool scr_loop_run(ScrPromise *top_level) {
if (scr_exc_pending()) return false; /* uncaught throw in a listener */
if (scr_ready_len > 0) continue;
}
+ /* MIDI dispatch (scr_midi.c, when linked): arrived MIDI messages fire
+ * their 'message' listeners now — the dgram hook's exact station. */
+ if (scr_midi_dispatch_fn != NULL) {
+ scr_midi_dispatch_fn();
+ if (scr_exc_pending()) return false; /* uncaught throw in a listener */
+ if (scr_ready_len > 0) continue;
+ }
/* Watch dispatch (scr_watch.c, when linked): file events queued on
* the unit's event backend fire their FSWatcher listeners now — the
* net hook's exact station. */
@@ -2362,6 +2382,7 @@ bool scr_loop_run(ScrPromise *top_level) {
(scr_events_pending_fn != NULL && scr_events_pending_fn()) ||
(scr_net_pending_fn != NULL && scr_net_pending_fn()) ||
(scr_dgram_pending_fn != NULL && scr_dgram_pending_fn()) ||
+ (scr_midi_pending_fn != NULL && scr_midi_pending_fn()) ||
(scr_watch_pending_fn != NULL && scr_watch_pending_fn()) ||
scr_fs_renames_pending();
if (held) {
@@ -2381,6 +2402,7 @@ bool scr_loop_run(ScrPromise *top_level) {
bool events = scr_events_pending_fn != NULL && scr_events_pending_fn();
bool net = scr_net_pending_fn != NULL && scr_net_pending_fn();
bool dgram = scr_dgram_pending_fn != NULL && scr_dgram_pending_fn();
+ bool midi = scr_midi_pending_fn != NULL && scr_midi_pending_fn();
bool watch = scr_watch_pending_fn != NULL && scr_watch_pending_fn();
bool renames = scr_fs_renames_pending();
/* Timer liveness counts only REF'd timers: an unref'd timer stays in
@@ -2389,7 +2411,7 @@ bool scr_loop_run(ScrPromise *top_level) {
* Children follow the same rule: an unref'd child is still REAPED
* while the loop runs (kids drives the sweeps and sleeps above) but
* only reffed ones keep the process alive. */
- if (scr_reffed_timers == 0 && scr_reffed_immediates == 0 && !scr_children_reffed_pending() && !io && !events && !net && !dgram && !watch && !renames) break;
+ if (scr_reffed_timers == 0 && scr_reffed_immediates == 0 && !scr_children_reffed_pending() && !io && !events && !net && !dgram && !midi && !watch && !renames) break;
/* Sleep to the earliest deadline, then run every due timer (each may
* enqueue microtasks, which the next iteration drains first). Who
* sleeps depends on what is pending:
@@ -2430,11 +2452,11 @@ bool scr_loop_run(ScrPromise *top_level) {
* on EINTR), so they re-impose a coarser cap — bounded Ctrl-C and
* socket latency during a fetch, without the reap-granularity
* cost. */
- else if ((evw || net || dgram || watch) && due > now + SCR_SIGNAL_POLL_MS) due = now + SCR_SIGNAL_POLL_MS;
+ else if ((evw || net || dgram || midi || watch) && due > now + SCR_SIGNAL_POLL_MS) due = now + SCR_SIGNAL_POLL_MS;
scr_io_poll_fn(due > now ? due - now : 0);
now = scr_now_ms();
if (scr_ready_len > 0) continue; /* io callbacks woke fibers */
- } else if (evw || net || dgram || watch) {
+ } else if (evw || net || dgram || midi || watch) {
#if defined(_WIN32) || defined(__wasi__)
/* The win32 arm, and WASI hosts whose poll_oneoff adapters do not
* reliably wake for a closed inherited stdin pipe: the sleep is a capped nanosleep and
@@ -2448,7 +2470,7 @@ bool scr_loop_run(ScrPromise *top_level) {
* show up in a profile, the upgrade is a real waitable arm —
* WaitForMultipleObjects over WSAEVENTs, or IOCP. */
if (evw && due > now + SCR_SIGNAL_POLL_MS) due = now + SCR_SIGNAL_POLL_MS;
- if ((net || dgram || watch) && due > now + SCR_CHILD_POLL_MS) due = now + SCR_CHILD_POLL_MS;
+ if ((net || dgram || midi || watch) && due > now + SCR_CHILD_POLL_MS) due = now + SCR_CHILD_POLL_MS;
if (kids && due > now + SCR_CHILD_POLL_MS) due = now + SCR_CHILD_POLL_MS;
if (due > now) {
double wait = due - now;
@@ -2465,7 +2487,7 @@ bool scr_loop_run(ScrPromise *top_level) {
* events are pending); unrepresentable children keep the ~1ms reap cap
* instead. Dispatch happens at the next turn's top — the poll only
* decides how long to sleep. */
- struct pollfd fds[6];
+ struct pollfd fds[7];
int nfds = 0;
int evfds[2];
int nev = evw && scr_events_pollfds_fn != NULL ? scr_events_pollfds_fn(evfds) : 0;
@@ -2499,6 +2521,17 @@ bool scr_loop_run(ScrPromise *top_level) {
due = now + SCR_SIGNAL_POLL_MS;
}
}
+ if (midi) {
+ /* The midi unit's poller fd — the net slot's exact story. */
+ int mfd = scr_midi_pollfd_fn != NULL ? scr_midi_pollfd_fn() : -1;
+ if (mfd >= 0) {
+ fds[nfds].fd = mfd;
+ fds[nfds].events = POLLIN;
+ fds[nfds++].revents = 0;
+ } else if (due > now + SCR_SIGNAL_POLL_MS) {
+ due = now + SCR_SIGNAL_POLL_MS;
+ }
+ }
if (watch) {
/* The watch unit's event fd — the net slot's exact story. */
int wfd = scr_watch_pollfd_fn != NULL ? scr_watch_pollfd_fn() : -1;
diff --git a/packages/runtime/src/scr_midi.c b/packages/runtime/src/scr_midi.c
new file mode 100644
index 000000000..43e1b0e06
--- /dev/null
+++ b/packages/runtime/src/scr_midi.c
@@ -0,0 +1,1423 @@
+/* node:midi — MIDI input/output ports over the event loop's readiness
+ * poller (the scr_platform.h contract — kqueue on macOS/BSD, epoll on
+ * Linux, WSAPoll on win32; scr_dgram.c has the seam's full story). The
+ * de-facto Node surface is node-midi / @julusian/midi (RtMidi under the
+ * hood); this unit ports its CORE messaging shape — enumerate, open
+ * (incl. virtual ports), receive time-stamped messages via 'message',
+ * send raw bytes — modeled touchpoint-for-touchpoint on scr_dgram.c.
+ *
+ * ── Design note ──────────────────────────────────────────────────────
+ *
+ * Object model. Two refcounted handle kinds, LEAN allocations (the
+ * ScrDgramSocket precedent, no cycle header): ScrMidiInput (a live,
+ * pollable source, like a bound socket) and ScrMidiOutput (fire-and-
+ * forget, like a connected UDP sender). Both start with a `kind` tag as
+ * their first member, so the shared ABI symbols take a void* handle and
+ * route on that tag (scr_net.c's leading-int-in-udata technique). A
+ * 'message' listener MOVES in (+1) and is released when the input closes
+ * or at the exit-time cleanup — the dgram ownership story verbatim, so a
+ * listener capturing its own input cannot cycle past close.
+ *
+ * Event dispatch. One poller owned by this unit (lazily created). The
+ * loop (scr_async.c) calls scr_midi_dispatch() at every turn top — the
+ * dgram hook's exact shape — draining the poller (a zero-timeout pass)
+ * then firing 'message' emits macrotask-style on the MAIN stack, stopping
+ * early when a listener enqueued microtasks or threw. Between turns the
+ * loop's idle poll(2) watches this unit's poller fd.
+ *
+ * The off-thread bridge (the mandatory rule). CoreMIDI and WinMM deliver
+ * their read callbacks on a PLATFORM thread, never the loop thread. Those
+ * callbacks are forbidden from touching the runtime heap (no ScrArr /
+ * ScrStr / closures, no refcounts) — they only COPY the raw bytes into a
+ * per-input, lock-guarded ring (plain libc malloc, which is thread-safe
+ * and is NOT the GC heap) and write ONE byte to a self-pipe whose read
+ * end is registered with the poller. All JS-visible work — building the
+ * number[], computing deltaTime, firing listeners — happens later in
+ * scr_midi_dispatch on the loop thread. ALSA's fds are pollable directly,
+ * so its "callback" is just the loop-thread decode in the same pump; it
+ * uses the same ring for one drain path.
+ *
+ * Read model. Consumer-like: the input's platform source stays open once
+ * opened (node-midi keeps the port live regardless of listeners), but the
+ * ring only fills while the source runs; messages fire in arrival order,
+ * one 'message' emit per message, the byte run delivered as a number[]
+ * (the node-midi shape) with deltaTime the leading f64. `once` listeners
+ * leave the live list before firing (the dgram snapshot discipline).
+ *
+ * Delta-time. Each input tracks the timestamp of its previous delivered
+ * message and reports deltaTime in SECONDS (node-midi's unit). The first
+ * message after open reports 0. The timestamp is captured at enqueue with
+ * a monotonic clock (the platform packet time where a backend has it).
+ *
+ * ignoreTypes(sysex, timing, activeSensing). Applied at fire time on the
+ * loop thread by inspecting the status byte (RtMidi's filter): sysex =
+ * 0xF0, timing = 0xF8 clock and 0xF1 MTC quarter-frame, activeSensing =
+ * 0xFE. node-midi's default is (true, true, true) — set at construction.
+ *
+ * Send model. sendMessage writes immediately — a MIDI message either goes
+ * out or it doesn't; there is no buffering. Short channel/system messages
+ * take the platform short path (midiOutShortMsg / a 3-byte packet); a
+ * SysEx run takes the long path (midiOutLongMsg / snd_midi_event / a
+ * variable packet).
+ *
+ * Virtual ports (the hardware-free loopback §5 relies on). POSIX only:
+ * ALSA creates a native sequencer port other clients subscribe to;
+ * CoreMIDI creates a MIDISource (an input's virtual is a destination we
+ * publish, an output's virtual is a source we publish). WinMM has NO
+ * user-space virtual ports, so openVirtualPort THROWS a clear runtime
+ * error there (a documented divergence). A test opens a virtual output
+ * named e.g. "scriptc-test", opens an input on that same virtual port,
+ * sends a deterministic sequence, and compares — no hardware needed.
+ *
+ * Loop liveness. An OPEN input holds the loop alive until closePort (a
+ * live source, like a bound socket). An output holds nothing (send is
+ * fire-and-forget). Inputs abandoned open at exit are released by the
+ * atexit cleanup, so the RC audit stays clean. There is no unref surface
+ * — node-midi's Input exposes none.
+ *
+ * State errors. sendMessage / openPort semantics follow node-midi: an
+ * out-of-range port index is a clear thrown Error; openVirtualPort on
+ * WinMM throws; opening an already-open handle re-opens (node-midi closes
+ * the previous port first — mirrored). */
+#include "scr_platform.h"
+#include "scr_runtime.h"
+
+#include
+#include
+#include
+#include
+#include
+
+#if !defined(_WIN32)
+#include
+#include
+#include
+#endif
+
+/* ── backend selection (the scr_dgram.c platform-arm stance) ───────────
+ * macOS → CoreMIDI, Linux → ALSA sequencer (only when its dev headers are
+ * present; this container has none, so the header-less build falls through
+ * to the stub and still compiles), Windows → WinMM. Anything else, and a
+ * Linux host without libasound-dev, links the STUB: enumeration answers
+ * empty, opening a port throws "no MIDI backend", so a non-MIDI platform
+ * build stays clean. */
+#if defined(_WIN32)
+#define SCR_MIDI_WINMM 1
+#elif defined(__APPLE__)
+#define SCR_MIDI_COREMIDI 1
+#elif defined(__linux__) && defined(__has_include)
+#if __has_include()
+#define SCR_MIDI_ALSA 1
+#endif
+#endif
+
+#if SCR_MIDI_WINMM
+#include
+#include
+#include /* the self-pipe socketpair emulation */
+#elif SCR_MIDI_COREMIDI
+#include
+#include
+#elif SCR_MIDI_ALSA
+#include
+#include
+#endif
+
+static void scr_midi_oom(void) {
+ fputs("scriptc: out of memory\n", stderr);
+ abort();
+}
+
+/* Monotonic milliseconds — the deltaTime clock. Heap-free and thread-safe
+ * (clock_gettime / QueryPerformanceCounter), so an off-thread producer may
+ * timestamp its enqueue without touching the runtime. */
+static double scr_midi_now_ms(void) {
+#if SCR_MIDI_WINMM
+ static LARGE_INTEGER freq;
+ static bool have_freq = false;
+ if (!have_freq) {
+ QueryPerformanceFrequency(&freq);
+ have_freq = true;
+ }
+ LARGE_INTEGER c;
+ QueryPerformanceCounter(&c);
+ return (double)c.QuadPart * 1000.0 / (double)freq.QuadPart;
+#else
+ struct timespec ts;
+ clock_gettime(CLOCK_MONOTONIC, &ts);
+ return (double)ts.tv_sec * 1000.0 + (double)ts.tv_nsec / 1e6;
+#endif
+}
+
+/* ── the cross-thread lock (inputs only — producers may be off-thread) ── */
+#if SCR_MIDI_WINMM
+typedef CRITICAL_SECTION ScrMidiLock;
+#define SCR_MIDI_LOCK_INIT(l) InitializeCriticalSection(l)
+#define SCR_MIDI_LOCK(l) EnterCriticalSection(l)
+#define SCR_MIDI_UNLOCK(l) LeaveCriticalSection(l)
+#define SCR_MIDI_LOCK_FINI(l) DeleteCriticalSection(l)
+#else
+typedef pthread_mutex_t ScrMidiLock;
+#define SCR_MIDI_LOCK_INIT(l) pthread_mutex_init((l), NULL)
+#define SCR_MIDI_LOCK(l) pthread_mutex_lock(l)
+#define SCR_MIDI_UNLOCK(l) pthread_mutex_unlock(l)
+#define SCR_MIDI_LOCK_FINI(l) pthread_mutex_destroy(l)
+#endif
+
+/* ── the arrival ring (the off-thread hand-off) ───────────────────────
+ * A FIFO of raw messages the producer fills under the lock; the loop
+ * thread drains it in scr_midi_dispatch. Bytes are plain malloc (libc,
+ * not the GC heap), so the realtime producer never allocates a runtime
+ * object. */
+typedef struct ScrMidiMsg {
+ unsigned char *bytes; /* malloc'd */
+ size_t len;
+ double ts_ms;
+ struct ScrMidiMsg *next;
+} ScrMidiMsg;
+
+/* ── listener list (the dgram snapshot discipline, restated so this unit
+ * links standalone) ─────────────────────────────────────────────────── */
+typedef struct {
+ ScrClosure *cb;
+ void *fn; /* the message adapter thunk (scr_midi_msg_thunk0/1/2) */
+ bool once;
+} ScrMidiL;
+
+typedef struct {
+ ScrMidiL *ls;
+ size_t n, cap;
+} ScrMidiLs;
+
+static void scr_midi_ls_add(ScrMidiLs *l, ScrClosure *cb, void *fn, bool once) {
+ if (l->n == l->cap) {
+ l->cap = l->cap ? l->cap * 2 : 2;
+ l->ls = realloc(l->ls, l->cap * sizeof *l->ls);
+ if (!l->ls) scr_midi_oom();
+ }
+ l->ls[l->n].cb = cb;
+ l->ls[l->n].fn = fn;
+ l->ls[l->n].once = once;
+ l->n++;
+}
+
+static void scr_midi_ls_drop(ScrMidiLs *l) {
+ for (size_t i = 0; i < l->n; i++) scr_closure_release(l->ls[i].cb);
+ free(l->ls);
+ l->ls = NULL;
+ l->n = l->cap = 0;
+}
+
+/* Snapshot for a firing pass: entries retained; `once` entries leave the
+ * LIVE list before their callback runs (the dgram spelling). */
+static size_t scr_midi_ls_snapshot(ScrMidiLs *l, ScrMidiL **out) {
+ size_t n = l->n;
+ if (n == 0) {
+ *out = NULL;
+ return 0;
+ }
+ ScrMidiL *snap = malloc(n * sizeof *snap);
+ if (!snap) scr_midi_oom();
+ for (size_t i = 0; i < n; i++) {
+ snap[i] = l->ls[i];
+ scr_closure_retain(snap[i].cb);
+ }
+ size_t w = 0;
+ for (size_t i = 0; i < l->n; i++) {
+ if (l->ls[i].once) scr_closure_release(l->ls[i].cb);
+ else l->ls[w++] = l->ls[i];
+ }
+ l->n = w;
+ *out = snap;
+ return n;
+}
+
+/* ── the handles ─────────────────────────────────────────────────────── */
+
+typedef enum { SCR_MIDI_IN = 0, SCR_MIDI_OUT = 1 } ScrMidiKind;
+
+struct ScrMidiInput {
+ ScrMidiKind kind; /* SCR_MIDI_IN — FIRST member (the void* tag) */
+ size_t rc;
+ bool open;
+ bool is_virtual;
+ bool ign_sysex, ign_timing, ign_sense; /* node-midi default: all true */
+ bool have_last_ts;
+ double last_ts_ms;
+ ScrMidiLs msg_ls;
+ /* the arrival ring (lock-guarded head/tail; the loop drains it) */
+ ScrMidiLock lock;
+ ScrMidiMsg *ring_head, *ring_tail;
+ bool lock_ready;
+ /* registry (open inputs hold the loop) */
+ bool in_registry;
+ struct ScrMidiInput *next;
+ /* platform state */
+#if SCR_MIDI_ALSA
+ snd_seq_t *seq;
+ int seq_port;
+ int seq_dest_client, seq_dest_port; /* the connected source (openPort) */
+ snd_midi_event_t *decoder;
+ int *pfds; /* registered poll fds, forgotten before close */
+ int npfds;
+#elif SCR_MIDI_COREMIDI
+ MIDIClientRef client;
+ MIDIPortRef port; /* the input port (openPort) */
+ MIDIEndpointRef endpoint; /* the connected source, or the virtual dest */
+ int pipe_r, pipe_w; /* self-pipe: producer pokes, poller watches r */
+#elif SCR_MIDI_WINMM
+ HMIDIIN h;
+ int pipe_r, pipe_w;
+ char sysex_buf[1024];
+ MIDIHDR sysex_hdr;
+#endif
+};
+
+struct ScrMidiOutput {
+ ScrMidiKind kind; /* SCR_MIDI_OUT — FIRST member (the void* tag) */
+ size_t rc;
+ bool open;
+ bool is_virtual;
+#if SCR_MIDI_ALSA
+ snd_seq_t *seq;
+ int seq_port;
+ int seq_dest_client, seq_dest_port;
+ snd_midi_event_t *encoder;
+#elif SCR_MIDI_COREMIDI
+ MIDIClientRef client;
+ MIDIPortRef port; /* the output port (openPort) */
+ MIDIEndpointRef endpoint; /* the connected destination, or virtual source */
+ bool endpoint_is_virtual;
+#elif SCR_MIDI_WINMM
+ HMIDIOUT h;
+#endif
+};
+
+#ifdef SCR_RC_AUDIT
+static long scr_midi_live = 0;
+long scr_midi_live_count(void) { return scr_midi_live; }
+#endif
+
+static ScrMidiInput *scr_midi_inputs = NULL; /* registry: +1 each */
+static ScrPoller *scr_midi_poller = NULL;
+
+/* ── poller plumbing (the scr_platform.h seam) ───────────────────────── */
+
+static bool scr_midi_poller_init(void) {
+ if (scr_midi_poller != NULL) return true;
+ scr_midi_poller = scrp_poller_new();
+ return scr_midi_poller != NULL;
+}
+
+static void scr_midi_watch_read(int fd, void *udata, bool on) {
+ if (scr_midi_poller == NULL || fd < 0) return;
+ (void)scrp_watch_read(scr_midi_poller, fd, udata, on);
+}
+
+/* Forget-then-close — the epoll obligation (scr_platform.h); a no-op
+ * forget on the kqueue side keeps macOS byte-identical. */
+static void scr_midi_forget_fd(int fd) {
+ if (fd < 0) return;
+ if (scr_midi_poller != NULL) scrp_forget(scr_midi_poller, fd);
+}
+
+/* ── registry ────────────────────────────────────────────────────────── */
+
+ScrMidiInput *scr_midi_input_retain(ScrMidiInput *s) {
+ if (s->rc != SIZE_MAX) s->rc++;
+ return s;
+}
+void scr_midi_input_release(ScrMidiInput *s); /* fwd */
+
+static void scr_midi_register(ScrMidiInput *s) {
+ if (s->in_registry) return;
+ s->in_registry = true;
+ s->next = NULL;
+ ScrMidiInput **link = &scr_midi_inputs;
+ while (*link) link = &(*link)->next;
+ *link = scr_midi_input_retain(s);
+}
+
+static void scr_midi_unregister(ScrMidiInput *s) {
+ if (!s->in_registry) return;
+ ScrMidiInput **link = &scr_midi_inputs;
+ while (*link && *link != s) link = &(*link)->next;
+ if (*link) {
+ *link = s->next;
+ s->next = NULL;
+ s->in_registry = false;
+ scr_midi_input_release(s);
+ }
+}
+
+/* ── the arrival ring ────────────────────────────────────────────────── */
+
+/* Producer side (may be OFF-THREAD on CoreMIDI/WinMM): copy the bytes and
+ * link them under the lock. NEVER touches the runtime heap — libc malloc
+ * only. Returns true if a poller poke is warranted (pipe backends write
+ * one byte after this). */
+static void scr_midi_ring_push(ScrMidiInput *s, const unsigned char *bytes, size_t len,
+ double ts_ms) {
+ if (len == 0) return;
+ ScrMidiMsg *m = malloc(sizeof *m);
+ if (!m) return; /* drop on exhaustion, like a full kernel MIDI queue */
+ m->bytes = malloc(len);
+ if (!m->bytes) {
+ free(m);
+ return;
+ }
+ memcpy(m->bytes, bytes, len);
+ m->len = len;
+ m->ts_ms = ts_ms;
+ m->next = NULL;
+ SCR_MIDI_LOCK(&s->lock);
+ if (s->ring_tail) s->ring_tail->next = m;
+ else s->ring_head = m;
+ s->ring_tail = m;
+ SCR_MIDI_UNLOCK(&s->lock);
+}
+
+/* Consumer side (LOOP THREAD only): pop one message, ownership to caller. */
+static ScrMidiMsg *scr_midi_ring_pop(ScrMidiInput *s) {
+ SCR_MIDI_LOCK(&s->lock);
+ ScrMidiMsg *m = s->ring_head;
+ if (m) {
+ s->ring_head = m->next;
+ if (!s->ring_head) s->ring_tail = NULL;
+ }
+ SCR_MIDI_UNLOCK(&s->lock);
+ return m;
+}
+
+static bool scr_midi_ring_nonempty(ScrMidiInput *s) {
+ SCR_MIDI_LOCK(&s->lock);
+ bool has = s->ring_head != NULL;
+ SCR_MIDI_UNLOCK(&s->lock);
+ return has;
+}
+
+static void scr_midi_ring_clear(ScrMidiInput *s) {
+ ScrMidiMsg *m;
+ while ((m = scr_midi_ring_pop(s)) != NULL) {
+ free(m->bytes);
+ free(m);
+ }
+}
+
+/* The ignoreTypes filter (RtMidi's status-byte test), applied on the loop
+ * thread so the realtime producer stays branch-free. */
+static bool scr_midi_filtered(const ScrMidiInput *s, const unsigned char *b, size_t len) {
+ if (len == 0) return true;
+ unsigned char st = b[0];
+ if (s->ign_sysex && st == 0xF0) return true;
+ if (s->ign_timing && (st == 0xF8 || st == 0xF1)) return true;
+ if (s->ign_sense && st == 0xFE) return true;
+ return false;
+}
+
+/* ── the message adapters (the dgram thunk family) ───────────────────── */
+
+/* The adapter signature: deltaTime as the leading f64, the byte run as a
+ * number[] (SCR_ELEM_F64). BORROWED to the adapter (multiple listeners see
+ * one message); the two-param adapter retains for its listener's owned
+ * param, per the universal convention. */
+void scr_midi_msg_thunk0(ScrClosure *cb, double dt, ScrArr *msg) {
+ (void)dt;
+ (void)msg;
+ ((void (*)(ScrClosure *))cb->fn)(cb);
+}
+void scr_midi_msg_thunk1(ScrClosure *cb, double dt, ScrArr *msg) {
+ (void)msg;
+ ((void (*)(ScrClosure *, double))cb->fn)(cb, dt);
+}
+void scr_midi_msg_thunk2(ScrClosure *cb, double dt, ScrArr *msg) {
+ ((void (*)(ScrClosure *, double, ScrArr *))cb->fn)(cb, dt, scr_arr_retain(msg));
+}
+
+/* ── platform backend forward declarations ───────────────────────────── */
+
+static int scr_midi_plat_count(bool is_input);
+static bool scr_midi_plat_name(bool is_input, int idx, char *buf, size_t bufsz);
+static const char *scr_midi_plat_in_open(ScrMidiInput *s, int idx, const char *vname);
+static void scr_midi_plat_in_close(ScrMidiInput *s);
+static void scr_midi_plat_in_pump(ScrMidiInput *s); /* drain the source into the ring */
+static const char *scr_midi_plat_out_open(ScrMidiOutput *s, int idx, const char *vname);
+static void scr_midi_plat_out_close(ScrMidiOutput *s);
+static void scr_midi_plat_out_send(ScrMidiOutput *s, const unsigned char *bytes, size_t len);
+
+/* ── RC ──────────────────────────────────────────────────────────────── */
+
+void scr_midi_input_release(ScrMidiInput *s) {
+ if (!s || s->rc == SIZE_MAX) return;
+ if (--s->rc == 0) {
+ if (s->open) scr_midi_plat_in_close(s);
+ scr_midi_ls_drop(&s->msg_ls);
+ scr_midi_ring_clear(s);
+ if (s->lock_ready) SCR_MIDI_LOCK_FINI(&s->lock);
+#ifdef SCR_RC_AUDIT
+ scr_midi_live--;
+#endif
+ free(s);
+ }
+}
+
+ScrMidiOutput *scr_midi_output_retain(ScrMidiOutput *s) {
+ if (s->rc != SIZE_MAX) s->rc++;
+ return s;
+}
+
+void scr_midi_output_release(ScrMidiOutput *s) {
+ if (!s || s->rc == SIZE_MAX) return;
+ if (--s->rc == 0) {
+ if (s->open) scr_midi_plat_out_close(s);
+#ifdef SCR_RC_AUDIT
+ scr_midi_live--;
+#endif
+ free(s);
+ }
+}
+
+/* The void* RC entry points the compiler stores per handle kind. */
+void *scr_midi_input_retain_v(void *p) { return scr_midi_input_retain((ScrMidiInput *)p); }
+void scr_midi_input_release_v(void *p) { scr_midi_input_release((ScrMidiInput *)p); }
+void *scr_midi_output_retain_v(void *p) { return scr_midi_output_retain((ScrMidiOutput *)p); }
+void scr_midi_output_release_v(void *p) { scr_midi_output_release((ScrMidiOutput *)p); }
+
+/* ── the surface: construction ───────────────────────────────────────── */
+
+ScrMidiInput *scr_midi_input_new(void) {
+ ScrMidiInput *s = calloc(1, sizeof *s);
+ if (!s) scr_midi_oom();
+ s->kind = SCR_MIDI_IN;
+ s->rc = 1;
+ s->ign_sysex = s->ign_timing = s->ign_sense = true; /* node-midi default */
+ SCR_MIDI_LOCK_INIT(&s->lock);
+ s->lock_ready = true;
+#if SCR_MIDI_COREMIDI || SCR_MIDI_WINMM
+ s->pipe_r = s->pipe_w = -1;
+#endif
+#ifdef SCR_RC_AUDIT
+ scr_midi_live++;
+#endif
+ return s;
+}
+
+ScrMidiOutput *scr_midi_output_new(void) {
+ ScrMidiOutput *s = calloc(1, sizeof *s);
+ if (!s) scr_midi_oom();
+ s->kind = SCR_MIDI_OUT;
+ s->rc = 1;
+#ifdef SCR_RC_AUDIT
+ scr_midi_live++;
+#endif
+ return s;
+}
+
+static void scr_midi_throw(const char *msg) {
+ scr_throw_error_msg(0 /* Error */, msg, strlen(msg));
+}
+
+/* getPortCount / getPortName work on a fresh handle before openPort
+ * (node-midi enumerates then opens — §7's confirmed stance). isInput
+ * selects the input vs output port namespace; the frozen ABI passes it
+ * explicitly so the shared symbol needs no per-handle read. */
+double scr_midi_port_count(void *handle, bool is_input) {
+ (void)handle;
+ int n = scr_midi_plat_count(is_input);
+ return n < 0 ? 0 : (double)n;
+}
+
+ScrStr *scr_midi_port_name(void *handle, double idx) {
+ ScrMidiKind kind = *(ScrMidiKind *)handle;
+ char buf[256];
+ if (!scr_midi_plat_name(kind == SCR_MIDI_IN, (int)idx, buf, sizeof buf)) {
+ /* node-midi returns "" for an out-of-range index rather than throwing. */
+ return scr_str_new("", 0);
+ }
+ return scr_str_new(buf, strlen(buf));
+}
+
+/* ── open / close ────────────────────────────────────────────────────── */
+
+void scr_midi_open_port(void *handle, double idx) {
+ if (!scr_midi_poller_init()) {
+ fputs("scriptc: event poller init failed\n", stderr);
+ abort();
+ }
+ ScrMidiKind kind = *(ScrMidiKind *)handle;
+ if (kind == SCR_MIDI_IN) {
+ ScrMidiInput *s = (ScrMidiInput *)handle;
+ if (s->open) scr_midi_plat_in_close(s); /* node-midi re-opens */
+ const char *err = scr_midi_plat_in_open(s, (int)idx, NULL);
+ if (err) {
+ scr_midi_throw(err);
+ return;
+ }
+ s->open = true;
+ s->is_virtual = false;
+ s->have_last_ts = false;
+ scr_midi_register(s); /* an open input holds the loop */
+ } else {
+ ScrMidiOutput *s = (ScrMidiOutput *)handle;
+ if (s->open) scr_midi_plat_out_close(s);
+ const char *err = scr_midi_plat_out_open(s, (int)idx, NULL);
+ if (err) {
+ scr_midi_throw(err);
+ return;
+ }
+ s->open = true;
+ s->is_virtual = false;
+ }
+}
+
+void scr_midi_open_virtual(void *handle, ScrStr *name) {
+ if (!scr_midi_poller_init()) {
+ fputs("scriptc: event poller init failed\n", stderr);
+ abort();
+ }
+ const char *vname = name && name->len ? name->data : "scriptc";
+ ScrMidiKind kind = *(ScrMidiKind *)handle;
+ if (kind == SCR_MIDI_IN) {
+ ScrMidiInput *s = (ScrMidiInput *)handle;
+ if (s->open) scr_midi_plat_in_close(s);
+ const char *err = scr_midi_plat_in_open(s, -1, vname);
+ if (err) {
+ scr_midi_throw(err);
+ return;
+ }
+ s->open = true;
+ s->is_virtual = true;
+ s->have_last_ts = false;
+ scr_midi_register(s);
+ } else {
+ ScrMidiOutput *s = (ScrMidiOutput *)handle;
+ if (s->open) scr_midi_plat_out_close(s);
+ const char *err = scr_midi_plat_out_open(s, -1, vname);
+ if (err) {
+ scr_midi_throw(err);
+ return;
+ }
+ s->open = true;
+ s->is_virtual = true;
+ }
+}
+
+void scr_midi_close_port(void *handle) {
+ ScrMidiKind kind = *(ScrMidiKind *)handle;
+ if (kind == SCR_MIDI_IN) {
+ ScrMidiInput *s = (ScrMidiInput *)handle;
+ if (!s->open) return; /* node-midi tolerates close on a closed port */
+ scr_midi_plat_in_close(s); /* forgets its fds, then closes them */
+ s->open = false;
+ scr_midi_ring_clear(s);
+ scr_midi_unregister(s); /* the loop can drain */
+ } else {
+ ScrMidiOutput *s = (ScrMidiOutput *)handle;
+ if (!s->open) return;
+ scr_midi_plat_out_close(s);
+ s->open = false;
+ }
+}
+
+bool scr_midi_is_open(void *handle) {
+ ScrMidiKind kind = *(ScrMidiKind *)handle;
+ if (kind == SCR_MIDI_IN) return ((ScrMidiInput *)handle)->open;
+ return ((ScrMidiOutput *)handle)->open;
+}
+
+void scr_midi_ignore_types(ScrMidiInput *s, bool sysex, bool timing, bool sense) {
+ s->ign_sysex = sysex;
+ s->ign_timing = timing;
+ s->ign_sense = sense;
+}
+
+/* ── send ────────────────────────────────────────────────────────────── */
+
+/* The ABI primitive (the frozen table's `midi.send`): raw bytes + length. */
+void scr_midi_send(ScrMidiOutput *s, const uint8_t *bytes, double len) {
+ if (!s->open) {
+ scr_midi_throw("Message sent on unopened port");
+ return;
+ }
+ size_t n = len < 0 ? 0 : (size_t)len;
+ if (n == 0) return;
+ scr_midi_plat_out_send(s, bytes, n);
+}
+
+/* Marshaling entry points for the two accepted argument shapes (the
+ * surfaces.ts stance: a number[] literal/variable, or a Uint8Array). Both
+ * narrow to the raw primitive above. */
+void scr_midi_send_array(ScrMidiOutput *s, ScrArr *message) {
+ size_t n = (size_t)message->len;
+ if (n == 0) {
+ if (!s->open) scr_midi_throw("Message sent on unopened port");
+ return;
+ }
+ unsigned char stackbuf[64];
+ unsigned char *buf = n <= sizeof stackbuf ? stackbuf : malloc(n);
+ if (!buf) scr_midi_oom();
+ for (size_t i = 0; i < n; i++) {
+ double v = scr_arr_get_f64(message, (double)i);
+ buf[i] = (unsigned char)((int)v & 0xFF);
+ }
+ scr_midi_send(s, buf, (double)n);
+ if (buf != stackbuf) free(buf);
+}
+
+void scr_midi_send_bytes(ScrMidiOutput *s, ScrBytes *message) {
+ size_t n = (size_t)scr_bytes_byte_len(message);
+ scr_midi_send(s, (const uint8_t *)message->data, (double)n);
+}
+
+/* ── on('message') / once('message') ─────────────────────────────────── */
+
+void scr_midi_on_message(ScrMidiInput *s, ScrClosure *cb, ScrMidiMsgFn fn, bool once) {
+ if (!s) {
+ scr_closure_release(cb);
+ return;
+ }
+ scr_midi_ls_add(&s->msg_ls, cb, (void *)fn, once);
+}
+
+/* ── the fire path (LOOP THREAD) ─────────────────────────────────────── */
+
+/* Drain one input's ring, firing 'message' for each un-filtered message.
+ * The number[] is built here (never off-thread); deltaTime is seconds
+ * since the previous DELIVERED message, 0 for the first. The handle is
+ * retained across the drain (a listener may closePort/release it). */
+static void scr_midi_in_fire(ScrMidiInput *s) {
+ scr_midi_input_retain(s);
+ for (;;) {
+ ScrMidiMsg *m = scr_midi_ring_pop(s);
+ if (!m) break;
+ if (scr_midi_filtered(s, m->bytes, m->len)) {
+ free(m->bytes);
+ free(m);
+ continue;
+ }
+ double dt = 0.0;
+ if (s->have_last_ts) dt = (m->ts_ms - s->last_ts_ms) / 1000.0;
+ s->last_ts_ms = m->ts_ms;
+ s->have_last_ts = true;
+
+ ScrArr *arr = scr_arr_new(SCR_ELEM_F64, m->len);
+ for (size_t i = 0; i < m->len; i++) scr_arr_push_f64(arr, (double)m->bytes[i]);
+ free(m->bytes);
+ free(m);
+
+ ScrMidiL *snap;
+ size_t nl = scr_midi_ls_snapshot(&s->msg_ls, &snap);
+ for (size_t i = 0; i < nl; i++) {
+ if (!scr_exc_pending()) ((ScrMidiMsgFn)snap[i].fn)(snap[i].cb, dt, arr);
+ scr_closure_release(snap[i].cb);
+ }
+ free(snap);
+ scr_arr_release(arr);
+ if (scr_exc_pending()) break;
+ }
+ scr_midi_input_release(s);
+}
+
+/* ── the loop hooks (scr_async.c) ────────────────────────────────────── */
+
+static bool scr_midi_pending(void) {
+ for (ScrMidiInput *s = scr_midi_inputs; s; s = s->next) {
+ /* An open input holds the loop (a live source); a filled ring is due
+ * work regardless. */
+ if (s->open) return true;
+ if (scr_midi_ring_nonempty(s)) return true;
+ }
+ return false;
+}
+
+static int scr_midi_pollfd(void) {
+ return scr_midi_poller != NULL ? scrp_poller_fd(scr_midi_poller) : -1;
+}
+
+/* Called each loop turn (the dgram dispatch station's exact shape):
+ * alternate a zero-timeout poller drain — which pumps each ready input's
+ * platform source into its ring (ALSA decode on the loop thread; a pipe
+ * drain for the off-thread backends, whose bytes are already in the ring)
+ * — with a firing pass, stopping when a listener enqueued microtasks or
+ * threw. */
+static void scr_midi_dispatch(void) {
+ if (!scr_midi_inputs) return;
+ for (;;) {
+ if (scr_midi_poller != NULL) {
+ ScrPollerEvent evs[64];
+ int n = scrp_drain(scr_midi_poller, evs, 64);
+ for (int i = 0; i < n; i++) {
+ ScrMidiInput *s = (ScrMidiInput *)evs[i].udata;
+ if (!s || !s->open) continue; /* closed earlier in this batch */
+ scr_midi_plat_in_pump(s);
+ }
+ }
+ bool any = false;
+ for (ScrMidiInput *s = scr_midi_inputs; s; s = s->next) {
+ if (!scr_midi_ring_nonempty(s)) continue;
+ any = true;
+ scr_midi_in_fire(s);
+ if (scr_exc_pending()) return;
+ }
+ if (!any) return;
+ if (scr_loop_has_ready()) return; /* microtasks interleave first */
+ }
+}
+
+/* Exit-time cleanup (the dgram precedent): inputs a program leaves open at
+ * exit release their listeners and registry references so the RC audit
+ * sees a clean heap. */
+static void scr_midi_cleanup_atexit(void) {
+ while (scr_midi_inputs) {
+ ScrMidiInput *s = scr_midi_inputs;
+ if (s->open) {
+ scr_midi_plat_in_close(s);
+ s->open = false;
+ }
+ scr_midi_ls_drop(&s->msg_ls);
+ scr_midi_ring_clear(s);
+ scr_midi_unregister(s);
+ }
+}
+
+void scr_midi_install(void) {
+ static bool installed = false;
+ if (installed) return;
+ installed = true;
+ atexit(scr_midi_cleanup_atexit);
+ scr_loop_set_midi(&scr_midi_pending, &scr_midi_dispatch, &scr_midi_pollfd);
+}
+
+/* ══ platform backends ═══════════════════════════════════════════════════
+ * Each provides: enumerate (count/name), open input/output (idx>=0 opens a
+ * real port; idx<0 opens a virtual port named vname), close, pump (drain a
+ * source into the ring), send. All error strings are returned (NULL =
+ * success) so the portable surface owns the throw. */
+
+/* ─────────────────────────── Linux: ALSA sequencer ─────────────────── */
+#if SCR_MIDI_ALSA
+
+/* A shared client handle for pure ENUMERATION (getPortCount/getPortName on
+ * a fresh handle, before any port opens). Opened lazily, kept for the
+ * process; the per-handle open uses its own client. */
+static snd_seq_t *scr_midi_enum_seq(void) {
+ static snd_seq_t *seq = NULL;
+ if (seq == NULL) {
+ if (snd_seq_open(&seq, "default", SND_SEQ_OPEN_DUPLEX, 0) < 0) seq = NULL;
+ }
+ return seq;
+}
+
+/* Walk every client/port, invoking `hit` for each whose capability matches
+ * the direction we want (input source = readable+subscribable-read; output
+ * sink = writable+subscribable-write). Returns the total, and fills
+ * client/port + name for the `want`-th match when name!=NULL. */
+static int scr_midi_alsa_walk(bool is_input, int want, int *out_client, int *out_port,
+ char *name, size_t namesz) {
+ snd_seq_t *seq = scr_midi_enum_seq();
+ if (!seq) return -1;
+ unsigned int need = is_input ? (SND_SEQ_PORT_CAP_READ | SND_SEQ_PORT_CAP_SUBS_READ)
+ : (SND_SEQ_PORT_CAP_WRITE | SND_SEQ_PORT_CAP_SUBS_WRITE);
+ snd_seq_client_info_t *cinfo;
+ snd_seq_port_info_t *pinfo;
+ snd_seq_client_info_alloca(&cinfo);
+ snd_seq_port_info_alloca(&pinfo);
+ snd_seq_client_info_set_client(cinfo, -1);
+ int count = 0;
+ while (snd_seq_query_next_client(seq, cinfo) >= 0) {
+ int client = snd_seq_client_info_get_client(cinfo);
+ if (client == SND_SEQ_CLIENT_SYSTEM) continue; /* skip the system client */
+ snd_seq_port_info_set_client(pinfo, client);
+ snd_seq_port_info_set_port(pinfo, -1);
+ while (snd_seq_query_next_port(seq, pinfo) >= 0) {
+ unsigned int caps = snd_seq_port_info_get_capability(pinfo);
+ if ((caps & need) != need) continue;
+ if (want == count) {
+ if (out_client) *out_client = client;
+ if (out_port) *out_port = snd_seq_port_info_get_port(pinfo);
+ if (name && namesz) {
+ snprintf(name, namesz, "%s:%d", snd_seq_client_info_get_name(cinfo),
+ snd_seq_port_info_get_port(pinfo));
+ }
+ }
+ count++;
+ }
+ }
+ return count;
+}
+
+static int scr_midi_plat_count(bool is_input) {
+ return scr_midi_alsa_walk(is_input, -1, NULL, NULL, NULL, 0);
+}
+
+static bool scr_midi_plat_name(bool is_input, int idx, char *buf, size_t bufsz) {
+ int c = -1, p = -1;
+ char nm[256] = "";
+ int total = scr_midi_alsa_walk(is_input, idx, &c, &p, nm, sizeof nm);
+ if (idx < 0 || idx >= total || nm[0] == '\0') return false;
+ snprintf(buf, bufsz, "%s", nm);
+ return true;
+}
+
+static const char *scr_midi_plat_in_open(ScrMidiInput *s, int idx, const char *vname) {
+ if (snd_seq_open(&s->seq, "default", SND_SEQ_OPEN_DUPLEX, SND_SEQ_NONBLOCK) < 0)
+ return "MIDI: could not open ALSA sequencer";
+ snd_seq_set_client_name(s->seq, vname ? vname : "scriptc-input");
+ /* Our port is WRITABLE (others write to us) so it can receive. */
+ s->seq_port = snd_seq_create_simple_port(
+ s->seq, vname ? vname : "scriptc-input",
+ SND_SEQ_PORT_CAP_WRITE | SND_SEQ_PORT_CAP_SUBS_WRITE,
+ SND_SEQ_PORT_TYPE_MIDI_GENERIC | SND_SEQ_PORT_TYPE_APPLICATION);
+ if (s->seq_port < 0) {
+ snd_seq_close(s->seq);
+ s->seq = NULL;
+ return "MIDI: could not create ALSA port";
+ }
+ if (snd_midi_event_new(1024, &s->decoder) < 0) {
+ snd_seq_delete_simple_port(s->seq, s->seq_port);
+ snd_seq_close(s->seq);
+ s->seq = NULL;
+ return "MIDI: could not create event decoder";
+ }
+ snd_midi_event_no_status(s->decoder, 1); /* emit full status each message */
+ if (idx >= 0) {
+ int c = -1, p = -1;
+ int total = scr_midi_alsa_walk(true, idx, &c, &p, NULL, 0);
+ if (idx >= total) {
+ scr_midi_plat_in_close(s);
+ return "MIDI: port index out of range";
+ }
+ s->seq_dest_client = c;
+ s->seq_dest_port = p;
+ /* Subscribe: connect the remote source to our writable port. */
+ if (snd_seq_connect_from(s->seq, s->seq_port, c, p) < 0) {
+ scr_midi_plat_in_close(s);
+ return "MIDI: could not connect to input port";
+ }
+ }
+ /* Register the sequencer's pollable fds with the loop poller. */
+ int npfd = snd_seq_poll_descriptors_count(s->seq, POLLIN);
+ if (npfd > 0) {
+ struct pollfd *pfd = calloc((size_t)npfd, sizeof *pfd);
+ if (!pfd) scr_midi_oom();
+ npfd = snd_seq_poll_descriptors(s->seq, pfd, (unsigned)npfd, POLLIN);
+ s->pfds = calloc((size_t)npfd, sizeof(int));
+ if (!s->pfds) scr_midi_oom();
+ s->npfds = npfd;
+ for (int i = 0; i < npfd; i++) {
+ s->pfds[i] = pfd[i].fd;
+ scr_midi_watch_read(pfd[i].fd, s, true);
+ }
+ free(pfd);
+ }
+ return NULL;
+}
+
+static void scr_midi_plat_in_close(ScrMidiInput *s) {
+ for (int i = 0; i < s->npfds; i++) scr_midi_forget_fd(s->pfds[i]);
+ free(s->pfds);
+ s->pfds = NULL;
+ s->npfds = 0;
+ if (s->decoder) {
+ snd_midi_event_free(s->decoder);
+ s->decoder = NULL;
+ }
+ if (s->seq) {
+ if (s->seq_port >= 0) snd_seq_delete_simple_port(s->seq, s->seq_port);
+ snd_seq_close(s->seq);
+ s->seq = NULL;
+ s->seq_port = -1;
+ }
+}
+
+static void scr_midi_plat_in_pump(ScrMidiInput *s) {
+ if (!s->seq || !s->decoder) return;
+ snd_seq_event_t *ev = NULL;
+ while (snd_seq_event_input(s->seq, &ev) >= 0 && ev != NULL) {
+ unsigned char buf[1024];
+ long n = snd_midi_event_decode(s->decoder, buf, sizeof buf, ev);
+ if (n > 0) scr_midi_ring_push(s, buf, (size_t)n, scr_midi_now_ms());
+ /* snd_seq_event_input returns >0 while more input is buffered; the
+ * loop exits when it returns -EAGAIN (no more pending). */
+ }
+}
+
+static const char *scr_midi_plat_out_open(ScrMidiOutput *s, int idx, const char *vname) {
+ if (snd_seq_open(&s->seq, "default", SND_SEQ_OPEN_DUPLEX, 0) < 0)
+ return "MIDI: could not open ALSA sequencer";
+ snd_seq_set_client_name(s->seq, vname ? vname : "scriptc-output");
+ /* Our port is READABLE (others read from us) so it can transmit. */
+ s->seq_port = snd_seq_create_simple_port(
+ s->seq, vname ? vname : "scriptc-output",
+ SND_SEQ_PORT_CAP_READ | SND_SEQ_PORT_CAP_SUBS_READ,
+ SND_SEQ_PORT_TYPE_MIDI_GENERIC | SND_SEQ_PORT_TYPE_APPLICATION);
+ if (s->seq_port < 0) {
+ snd_seq_close(s->seq);
+ s->seq = NULL;
+ return "MIDI: could not create ALSA port";
+ }
+ if (snd_midi_event_new(1024, &s->encoder) < 0) {
+ snd_seq_delete_simple_port(s->seq, s->seq_port);
+ snd_seq_close(s->seq);
+ s->seq = NULL;
+ return "MIDI: could not create event encoder";
+ }
+ snd_midi_event_init(s->encoder);
+ if (idx >= 0) {
+ int c = -1, p = -1;
+ int total = scr_midi_alsa_walk(false, idx, &c, &p, NULL, 0);
+ if (idx >= total) {
+ scr_midi_plat_out_close(s);
+ return "MIDI: port index out of range";
+ }
+ s->seq_dest_client = c;
+ s->seq_dest_port = p;
+ if (snd_seq_connect_to(s->seq, s->seq_port, c, p) < 0) {
+ scr_midi_plat_out_close(s);
+ return "MIDI: could not connect to output port";
+ }
+ }
+ return NULL;
+}
+
+static void scr_midi_plat_out_close(ScrMidiOutput *s) {
+ if (s->encoder) {
+ snd_midi_event_free(s->encoder);
+ s->encoder = NULL;
+ }
+ if (s->seq) {
+ if (s->seq_port >= 0) snd_seq_delete_simple_port(s->seq, s->seq_port);
+ snd_seq_close(s->seq);
+ s->seq = NULL;
+ s->seq_port = -1;
+ }
+}
+
+static void scr_midi_plat_out_send(ScrMidiOutput *s, const unsigned char *bytes, size_t len) {
+ if (!s->seq || !s->encoder) return;
+ snd_seq_event_t ev;
+ size_t off = 0;
+ while (off < len) {
+ snd_seq_ev_clear(&ev);
+ long used = snd_midi_event_encode(s->encoder, bytes + off, (long)(len - off), &ev);
+ if (used <= 0) break;
+ off += (size_t)used;
+ if (ev.type == SND_SEQ_EVENT_NONE) continue; /* mid-message, no event yet */
+ snd_seq_ev_set_source(&ev, s->seq_port);
+ snd_seq_ev_set_subs(&ev);
+ snd_seq_ev_set_direct(&ev);
+ snd_seq_event_output(s->seq, &ev);
+ }
+ snd_seq_drain_output(s->seq);
+}
+
+/* ─────────────────────────── macOS: CoreMIDI ───────────────────────── */
+#elif SCR_MIDI_COREMIDI
+
+static int scr_midi_plat_count(bool is_input) {
+ return (int)(is_input ? MIDIGetNumberOfSources() : MIDIGetNumberOfDestinations());
+}
+
+static bool scr_midi_cm_name(MIDIEndpointRef ep, char *buf, size_t bufsz) {
+ if (ep == 0) return false;
+ CFStringRef cf = NULL;
+ if (MIDIObjectGetStringProperty(ep, kMIDIPropertyDisplayName, &cf) != noErr || !cf)
+ return false;
+ Boolean ok = CFStringGetCString(cf, buf, (CFIndex)bufsz, kCFStringEncodingUTF8);
+ CFRelease(cf);
+ return ok ? true : false;
+}
+
+static bool scr_midi_plat_name(bool is_input, int idx, char *buf, size_t bufsz) {
+ ItemCount total = is_input ? MIDIGetNumberOfSources() : MIDIGetNumberOfDestinations();
+ if (idx < 0 || (ItemCount)idx >= total) return false;
+ MIDIEndpointRef ep =
+ is_input ? MIDIGetSource((ItemCount)idx) : MIDIGetDestination((ItemCount)idx);
+ return scr_midi_cm_name(ep, buf, bufsz);
+}
+
+/* The CoreMIDI read callback — RUNS ON A COREMIDI THREAD. It must not
+ * touch the runtime: it only copies bytes into the ring (libc malloc) and
+ * pokes the self-pipe. */
+static void scr_midi_cm_read(const MIDIPacketList *pktlist, void *readProcRefCon,
+ void *srcConnRefCon) {
+ (void)srcConnRefCon;
+ ScrMidiInput *s = (ScrMidiInput *)readProcRefCon;
+ const MIDIPacket *pkt = &pktlist->packet[0];
+ double now = scr_midi_now_ms();
+ for (UInt32 i = 0; i < pktlist->numPackets; i++) {
+ scr_midi_ring_push(s, pkt->data, pkt->length, now);
+ pkt = MIDIPacketNext(pkt);
+ }
+ if (s->pipe_w >= 0) {
+ unsigned char one = 1;
+ ssize_t w = write(s->pipe_w, &one, 1); /* wake the loop */
+ (void)w;
+ }
+}
+
+static const char *scr_midi_cm_selfpipe(ScrMidiInput *s) {
+ int fds[2];
+ if (pipe(fds) != 0) return "MIDI: could not create wake pipe";
+ fcntl(fds[0], F_SETFL, O_NONBLOCK);
+ fcntl(fds[0], F_SETFD, FD_CLOEXEC);
+ fcntl(fds[1], F_SETFD, FD_CLOEXEC);
+ s->pipe_r = fds[0];
+ s->pipe_w = fds[1];
+ scr_midi_watch_read(s->pipe_r, s, true);
+ return NULL;
+}
+
+static const char *scr_midi_plat_in_open(ScrMidiInput *s, int idx, const char *vname) {
+ if (MIDIClientCreate(CFSTR("scriptc"), NULL, NULL, &s->client) != noErr)
+ return "MIDI: could not create CoreMIDI client";
+ const char *pipe_err = scr_midi_cm_selfpipe(s);
+ if (pipe_err) {
+ MIDIClientDispose(s->client);
+ s->client = 0;
+ return pipe_err;
+ }
+ if (idx >= 0) {
+ if (MIDIInputPortCreate(s->client, CFSTR("scriptc-in"), scr_midi_cm_read, s, &s->port) !=
+ noErr) {
+ scr_midi_plat_in_close(s);
+ return "MIDI: could not create input port";
+ }
+ ItemCount total = MIDIGetNumberOfSources();
+ if ((ItemCount)idx >= total) {
+ scr_midi_plat_in_close(s);
+ return "MIDI: port index out of range";
+ }
+ s->endpoint = MIDIGetSource((ItemCount)idx);
+ if (MIDIPortConnectSource(s->port, s->endpoint, s) != noErr) {
+ scr_midi_plat_in_close(s);
+ return "MIDI: could not connect to input port";
+ }
+ } else {
+ /* A virtual input is a DESTINATION we publish for others to send to. */
+ CFStringRef nm = CFStringCreateWithCString(NULL, vname, kCFStringEncodingUTF8);
+ OSStatus rc =
+ MIDIDestinationCreate(s->client, nm, scr_midi_cm_read, s, &s->endpoint);
+ if (nm) CFRelease(nm);
+ if (rc != noErr) {
+ scr_midi_plat_in_close(s);
+ return "MIDI: could not create virtual input port";
+ }
+ }
+ return NULL;
+}
+
+static void scr_midi_plat_in_close(ScrMidiInput *s) {
+ if (s->port && s->endpoint) MIDIPortDisconnectSource(s->port, s->endpoint);
+ if (s->is_virtual && s->endpoint) MIDIEndpointDispose(s->endpoint);
+ s->endpoint = 0;
+ if (s->port) {
+ MIDIPortDispose(s->port);
+ s->port = 0;
+ }
+ if (s->client) {
+ MIDIClientDispose(s->client);
+ s->client = 0;
+ }
+ if (s->pipe_r >= 0) {
+ scr_midi_forget_fd(s->pipe_r);
+ close(s->pipe_r);
+ s->pipe_r = -1;
+ }
+ if (s->pipe_w >= 0) {
+ close(s->pipe_w);
+ s->pipe_w = -1;
+ }
+}
+
+/* Loop-thread pump: the bytes are already in the ring (the read callback
+ * put them there); just drain the wake pipe so it stops signalling. */
+static void scr_midi_plat_in_pump(ScrMidiInput *s) {
+ if (s->pipe_r < 0) return;
+ unsigned char buf[256];
+ while (read(s->pipe_r, buf, sizeof buf) > 0) { /* drain */
+ }
+}
+
+static const char *scr_midi_plat_out_open(ScrMidiOutput *s, int idx, const char *vname) {
+ if (MIDIClientCreate(CFSTR("scriptc"), NULL, NULL, &s->client) != noErr)
+ return "MIDI: could not create CoreMIDI client";
+ if (idx >= 0) {
+ if (MIDIOutputPortCreate(s->client, CFSTR("scriptc-out"), &s->port) != noErr) {
+ scr_midi_plat_out_close(s);
+ return "MIDI: could not create output port";
+ }
+ ItemCount total = MIDIGetNumberOfDestinations();
+ if ((ItemCount)idx >= total) {
+ scr_midi_plat_out_close(s);
+ return "MIDI: port index out of range";
+ }
+ s->endpoint = MIDIGetDestination((ItemCount)idx);
+ s->endpoint_is_virtual = false;
+ } else {
+ /* A virtual output is a SOURCE we publish for others to read from. */
+ CFStringRef nm = CFStringCreateWithCString(NULL, vname, kCFStringEncodingUTF8);
+ OSStatus rc = MIDISourceCreate(s->client, nm, &s->endpoint);
+ if (nm) CFRelease(nm);
+ if (rc != noErr) {
+ scr_midi_plat_out_close(s);
+ return "MIDI: could not create virtual output port";
+ }
+ s->endpoint_is_virtual = true;
+ }
+ return NULL;
+}
+
+static void scr_midi_plat_out_close(ScrMidiOutput *s) {
+ if (s->endpoint_is_virtual && s->endpoint) MIDIEndpointDispose(s->endpoint);
+ s->endpoint = 0;
+ if (s->port) {
+ MIDIPortDispose(s->port);
+ s->port = 0;
+ }
+ if (s->client) {
+ MIDIClientDispose(s->client);
+ s->client = 0;
+ }
+}
+
+static void scr_midi_plat_out_send(ScrMidiOutput *s, const unsigned char *bytes, size_t len) {
+ Byte storage[512 + sizeof(MIDIPacketList)];
+ MIDIPacketList *pl;
+ Byte *heap = NULL;
+ if (len + sizeof(MIDIPacketList) + 16 > sizeof storage) {
+ heap = malloc(len + sizeof(MIDIPacketList) + 16);
+ if (!heap) scr_midi_oom();
+ pl = (MIDIPacketList *)heap;
+ } else {
+ pl = (MIDIPacketList *)storage;
+ }
+ MIDIPacket *pkt = MIDIPacketListInit(pl);
+ pkt = MIDIPacketListAdd(
+ pl, len + sizeof(MIDIPacketList) + 16, pkt, mach_absolute_time(), len, bytes);
+ if (pkt) {
+ if (s->endpoint_is_virtual) MIDIReceived(s->endpoint, pl); /* publish on the source */
+ else MIDISend(s->port, s->endpoint, pl);
+ }
+ free(heap);
+}
+
+/* ─────────────────────────── Windows: WinMM ────────────────────────── */
+#elif SCR_MIDI_WINMM
+
+static int scr_midi_plat_count(bool is_input) {
+ return (int)(is_input ? midiInGetNumDevs() : midiOutGetNumDevs());
+}
+
+static bool scr_midi_plat_name(bool is_input, int idx, char *buf, size_t bufsz) {
+ if (idx < 0) return false;
+ if (is_input) {
+ MIDIINCAPSA caps;
+ if ((UINT)idx >= midiInGetNumDevs()) return false;
+ if (midiInGetDevCapsA((UINT_PTR)idx, &caps, sizeof caps) != MMSYSERR_NOERROR) return false;
+ snprintf(buf, bufsz, "%s", caps.szPname);
+ } else {
+ MIDIOUTCAPSA caps;
+ if ((UINT)idx >= midiOutGetNumDevs()) return false;
+ if (midiOutGetDevCapsA((UINT_PTR)idx, &caps, sizeof caps) != MMSYSERR_NOERROR) return false;
+ snprintf(buf, bufsz, "%s", caps.szPname);
+ }
+ return true;
+}
+
+/* A loopback socketpair — the win32 self-pipe over WSAPoll (scr_loop_
+ * wsapoll.c watches SOCKETs). Producer (the WinMM callback thread) writes
+ * one byte; the loop drains the read end. */
+static int scr_midi_win_selfpipe(int fds[2]) {
+ SOCKET listener = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
+ if (listener == INVALID_SOCKET) return -1;
+ struct sockaddr_in a;
+ memset(&a, 0, sizeof a);
+ a.sin_family = AF_INET;
+ a.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
+ a.sin_port = 0;
+ int len = sizeof a;
+ if (bind(listener, (struct sockaddr *)&a, len) != 0 || listen(listener, 1) != 0 ||
+ getsockname(listener, (struct sockaddr *)&a, &len) != 0) {
+ closesocket(listener);
+ return -1;
+ }
+ SOCKET w = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
+ if (w == INVALID_SOCKET || connect(w, (struct sockaddr *)&a, len) != 0) {
+ closesocket(listener);
+ if (w != INVALID_SOCKET) closesocket(w);
+ return -1;
+ }
+ SOCKET r = accept(listener, NULL, NULL);
+ closesocket(listener);
+ if (r == INVALID_SOCKET) {
+ closesocket(w);
+ return -1;
+ }
+ u_long one = 1;
+ ioctlsocket(r, FIONBIO, &one);
+ fds[0] = (int)r;
+ fds[1] = (int)w;
+ return 0;
+}
+
+/* The WinMM input callback — RUNS OFF-THREAD. Heap-free: copy to the ring,
+ * poke the pipe. */
+static void CALLBACK scr_midi_win_in_cb(HMIDIIN h, UINT msg, DWORD_PTR inst, DWORD_PTR p1,
+ DWORD_PTR p2) {
+ (void)h;
+ (void)p2;
+ ScrMidiInput *s = (ScrMidiInput *)inst;
+ double now = scr_midi_now_ms();
+ if (msg == MIM_DATA) {
+ unsigned char b[3];
+ DWORD dw = (DWORD)p1;
+ b[0] = (unsigned char)(dw & 0xFF);
+ b[1] = (unsigned char)((dw >> 8) & 0xFF);
+ b[2] = (unsigned char)((dw >> 16) & 0xFF);
+ /* Length by status: 1 byte for realtime/0xF*, else 2 or 3. Keep the
+ * full 3 — the ignoreTypes filter and the JS consumer read the run;
+ * trailing zero bytes on a 2-byte message are harmless for the common
+ * decoders, but trim by status class for correctness. */
+ size_t n = 3;
+ unsigned char st = b[0];
+ if (st >= 0xF8) n = 1; /* system realtime */
+ else if ((st & 0xF0) == 0xC0 || (st & 0xF0) == 0xD0) n = 2; /* program/chanpress */
+ else if (st == 0xF1 || st == 0xF3) n = 2; /* MTC / song select */
+ scr_midi_ring_push(s, b, n, now);
+ } else if (msg == MIM_LONGDATA) {
+ MIDIHDR *hdr = (MIDIHDR *)p1;
+ if (hdr && hdr->dwBytesRecorded > 0)
+ scr_midi_ring_push(s, (unsigned char *)hdr->lpData, hdr->dwBytesRecorded, now);
+ /* re-queue the sysex buffer */
+ if (hdr) midiInAddBuffer(s->h, hdr, sizeof *hdr);
+ } else {
+ return;
+ }
+ if (s->pipe_w >= 0) {
+ char one = 1;
+ send((SOCKET)s->pipe_w, &one, 1, 0);
+ }
+}
+
+static const char *scr_midi_plat_in_open(ScrMidiInput *s, int idx, const char *vname) {
+ (void)vname;
+ if (idx < 0) return "MIDI: virtual ports are not supported on Windows (WinMM)";
+ if ((UINT)idx >= midiInGetNumDevs()) return "MIDI: port index out of range";
+ int fds[2];
+ if (scr_midi_win_selfpipe(fds) != 0) return "MIDI: could not create wake pipe";
+ s->pipe_r = fds[0];
+ s->pipe_w = fds[1];
+ scr_midi_watch_read(s->pipe_r, s, true);
+ if (midiInOpen(&s->h, (UINT)idx, (DWORD_PTR)scr_midi_win_in_cb, (DWORD_PTR)s,
+ CALLBACK_FUNCTION) != MMSYSERR_NOERROR) {
+ scr_midi_plat_in_close(s);
+ return "MIDI: could not open input port";
+ }
+ memset(&s->sysex_hdr, 0, sizeof s->sysex_hdr);
+ s->sysex_hdr.lpData = s->sysex_buf;
+ s->sysex_hdr.dwBufferLength = sizeof s->sysex_buf;
+ midiInPrepareHeader(s->h, &s->sysex_hdr, sizeof s->sysex_hdr);
+ midiInAddBuffer(s->h, &s->sysex_hdr, sizeof s->sysex_hdr);
+ midiInStart(s->h);
+ return NULL;
+}
+
+static void scr_midi_plat_in_close(ScrMidiInput *s) {
+ if (s->h) {
+ midiInStop(s->h);
+ midiInReset(s->h);
+ midiInUnprepareHeader(s->h, &s->sysex_hdr, sizeof s->sysex_hdr);
+ midiInClose(s->h);
+ s->h = NULL;
+ }
+ if (s->pipe_r >= 0) {
+ scr_midi_forget_fd(s->pipe_r);
+ closesocket((SOCKET)s->pipe_r);
+ s->pipe_r = -1;
+ }
+ if (s->pipe_w >= 0) {
+ closesocket((SOCKET)s->pipe_w);
+ s->pipe_w = -1;
+ }
+}
+
+static void scr_midi_plat_in_pump(ScrMidiInput *s) {
+ if (s->pipe_r < 0) return;
+ char buf[256];
+ while (recv((SOCKET)s->pipe_r, buf, sizeof buf, 0) > 0) { /* drain */
+ }
+}
+
+static const char *scr_midi_plat_out_open(ScrMidiOutput *s, int idx, const char *vname) {
+ (void)vname;
+ if (idx < 0) return "MIDI: virtual ports are not supported on Windows (WinMM)";
+ if ((UINT)idx >= midiOutGetNumDevs()) return "MIDI: port index out of range";
+ if (midiOutOpen(&s->h, (UINT)idx, 0, 0, CALLBACK_NULL) != MMSYSERR_NOERROR)
+ return "MIDI: could not open output port";
+ return NULL;
+}
+
+static void scr_midi_plat_out_close(ScrMidiOutput *s) {
+ if (s->h) {
+ midiOutReset(s->h);
+ midiOutClose(s->h);
+ s->h = NULL;
+ }
+}
+
+static void scr_midi_plat_out_send(ScrMidiOutput *s, const unsigned char *bytes, size_t len) {
+ if (!s->h) return;
+ if (len <= 3 && bytes[0] != 0xF0) {
+ DWORD dw = 0;
+ for (size_t i = 0; i < len; i++) dw |= (DWORD)bytes[i] << (8 * i);
+ midiOutShortMsg(s->h, dw);
+ } else {
+ MIDIHDR hdr;
+ memset(&hdr, 0, sizeof hdr);
+ hdr.lpData = (LPSTR)bytes;
+ hdr.dwBufferLength = (DWORD)len;
+ hdr.dwBytesRecorded = (DWORD)len;
+ if (midiOutPrepareHeader(s->h, &hdr, sizeof hdr) == MMSYSERR_NOERROR) {
+ midiOutLongMsg(s->h, &hdr, sizeof hdr);
+ midiOutUnprepareHeader(s->h, &hdr, sizeof hdr);
+ }
+ }
+}
+
+/* ─────────────────────────── stub (no backend) ─────────────────────── */
+#else
+
+static int scr_midi_plat_count(bool is_input) {
+ (void)is_input;
+ return 0;
+}
+static bool scr_midi_plat_name(bool is_input, int idx, char *buf, size_t bufsz) {
+ (void)is_input;
+ (void)idx;
+ (void)buf;
+ (void)bufsz;
+ return false;
+}
+static const char *scr_midi_plat_in_open(ScrMidiInput *s, int idx, const char *vname) {
+ (void)s;
+ (void)idx;
+ (void)vname;
+ return "MIDI: no MIDI backend on this platform";
+}
+static void scr_midi_plat_in_close(ScrMidiInput *s) { (void)s; }
+static void scr_midi_plat_in_pump(ScrMidiInput *s) { (void)s; }
+static const char *scr_midi_plat_out_open(ScrMidiOutput *s, int idx, const char *vname) {
+ (void)s;
+ (void)idx;
+ (void)vname;
+ return "MIDI: no MIDI backend on this platform";
+}
+static void scr_midi_plat_out_close(ScrMidiOutput *s) { (void)s; }
+static void scr_midi_plat_out_send(ScrMidiOutput *s, const unsigned char *bytes, size_t len) {
+ (void)s;
+ (void)bytes;
+ (void)len;
+}
+
+#endif /* backend selection */
diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h
index 60b2dfff1..80281e88b 100644
--- a/packages/runtime/src/scr_runtime.h
+++ b/packages/runtime/src/scr_runtime.h
@@ -6064,6 +6064,72 @@ long scr_dgram_live_count(void);
* hook's exact shape, one more nullable slot set. */
void scr_loop_set_dgram(bool (*pending)(void), void (*dispatch)(void), int (*pollfd)(void));
+/* ── node:midi (scr_midi.c — compiled only when the program uses it;
+ * design note atop the file). Two lean refcounted handle kinds modeled on
+ * ScrDgramSocket: ScrMidiInput (a live, pollable source — an OPEN input
+ * holds the loop, like a bound socket) and ScrMidiOutput (fire-and-forget,
+ * like a connected sender). Both start with a ScrMidiKind tag as their
+ * first member so the shared void*-handle ABI symbols route on it. The
+ * ALSA/CoreMIDI/WinMM backends live behind platform guards; off-thread
+ * platform callbacks (CoreMIDI/WinMM) only fill a lock-guarded ring and
+ * poke a self-pipe — all JS-visible work runs in scr_midi_dispatch on the
+ * loop thread. Self-contained: no symbol here needs scr_dgram.c to link. */
+typedef struct ScrMidiInput ScrMidiInput;
+typedef struct ScrMidiOutput ScrMidiOutput;
+/* The 'message' adapter (the dgram thunk family): deltaTime in SECONDS as
+ * the leading f64, the byte run as a number[] (SCR_ELEM_F64) delivered
+ * BORROWED (multiple listeners see one message; the two-param adapter
+ * retains for its listener's owned param). */
+typedef void (*ScrMidiMsgFn)(ScrClosure *cb, double deltaTime, ScrArr *message);
+
+/* Refcount entry points the compiler emits per handle kind (the
+ * scr_dgram_retain/_v pair, one set per struct). */
+ScrMidiInput *scr_midi_input_retain(ScrMidiInput *s);
+void scr_midi_input_release(ScrMidiInput *s);
+void *scr_midi_input_retain_v(void *p);
+void scr_midi_input_release_v(void *p);
+ScrMidiOutput *scr_midi_output_retain(ScrMidiOutput *s);
+void scr_midi_output_release(ScrMidiOutput *s);
+void *scr_midi_output_retain_v(void *p);
+void scr_midi_output_release_v(void *p);
+
+ScrMidiInput *scr_midi_input_new(void); /* +1 */
+ScrMidiOutput *scr_midi_output_new(void); /* +1 */
+/* Enumeration works on a fresh handle before openPort (node-midi's
+ * enumerate-then-open). is_input selects the input vs output namespace
+ * (the frozen ABI passes it explicitly); port_name reads the handle tag
+ * and returns "" for an out-of-range index (node-midi's answer). */
+double scr_midi_port_count(void *handle, bool is_input);
+ScrStr *scr_midi_port_name(void *handle, double idx); /* +1 */
+void scr_midi_open_port(void *handle, double idx); /* throws on bad index */
+void scr_midi_open_virtual(void *handle, ScrStr *name /*borrowed*/); /* throws on WinMM */
+void scr_midi_close_port(void *handle);
+bool scr_midi_is_open(void *handle);
+void scr_midi_ignore_types(ScrMidiInput *s, bool sysex, bool timing, bool sense);
+/* send: the frozen ABI primitive is the raw byte pointer + length; the
+ * _array (number[]) and _bytes (Uint8Array) forms marshal to it — the two
+ * accepted argument shapes. All borrowed. Throws on an unopened port. */
+void scr_midi_send(ScrMidiOutput *s, const uint8_t *bytes /*borrowed*/, double len);
+void scr_midi_send_array(ScrMidiOutput *s, ScrArr *message /*borrowed*/);
+void scr_midi_send_bytes(ScrMidiOutput *s, ScrBytes *message /*borrowed*/);
+/* on('message')/once('message'): cb MOVES in, fn is the arity adapter
+ * (scr_midi_msg_thunk0/1/2). See the ABI note below — this carries an fn
+ * adapter argument the §4 draft table omitted (the dgram on_message
+ * precedent), so a 0/1/2-param listener is never called with a mismatched
+ * C signature. */
+void scr_midi_on_message(ScrMidiInput *s, ScrClosure *cb /*moves*/, ScrMidiMsgFn fn, bool once);
+/* The runtime-provided message adapters (zero/one/two-param listeners). */
+void scr_midi_msg_thunk0(ScrClosure *cb, double deltaTime, ScrArr *message);
+void scr_midi_msg_thunk1(ScrClosure *cb, double deltaTime, ScrArr *message);
+void scr_midi_msg_thunk2(ScrClosure *cb, double deltaTime, ScrArr *message);
+void scr_midi_install(void);
+#ifdef SCR_RC_AUDIT
+long scr_midi_live_count(void);
+#endif
+/* The loop-side registration (scr_async.c, always linked) — the dgram
+ * hook's exact shape, one more nullable slot set. */
+void scr_loop_set_midi(bool (*pending)(void), void (*dispatch)(void), int (*pollfd)(void));
+
/* ── fs.watch (scr_watch.c — compiled only when the program uses it;
* design note atop the file). FSWatcher handles over the unit's own
* event backend (kqueue EVFILT_VNODE on macOS/BSD, inotify on Linux):
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fc0566257..6fa1f3148 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -17,6 +17,9 @@ importers:
eslint:
specifier: ^9.20.0
version: 9.39.5
+ midi:
+ specifier: npm:@julusian/midi@^3.8.1
+ version: '@julusian/midi@3.8.1'
tsx:
specifier: ^4.19.0
version: 4.23.0
@@ -476,6 +479,10 @@ packages:
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+ '@julusian/midi@3.8.1':
+ resolution: {integrity: sha512-T+Ecn2pWTFu0G81PUa64Tk7yqzS6KlW61BKIaWVCBeXKXtXQ8ARgn6NcqmH6kABOM3FA8DV7XaPcw7VDBtgxKQ==}
+ engines: {node: '>=14.15'}
+
'@mapbox/node-pre-gyp@2.0.3':
resolution: {integrity: sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==}
engines: {node: '>=18'}
@@ -1939,6 +1946,9 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
+ node-addon-api@6.1.0:
+ resolution: {integrity: sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==}
+
node-fetch@2.6.7:
resolution: {integrity: sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==}
engines: {node: 4.x || >=6.0.0}
@@ -2077,6 +2087,11 @@ packages:
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
engines: {node: '>=12'}
+ pkg-prebuilds@1.1.0:
+ resolution: {integrity: sha512-jyai+KTQ2OwbN6iRYw88XbYOMgtpoSYJpjYebx7d9ihqz3txNi3ucsBt3va0iVWe6svSlaqpijMHFF/eJCMZzg==}
+ engines: {node: '>= 14.15.0'}
+ hasBin: true
+
postcss@8.5.16:
resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==}
engines: {node: ^10 || ^12 || >=14}
@@ -2805,6 +2820,12 @@ snapshots:
'@jridgewell/sourcemap-codec@1.5.5': {}
+ '@julusian/midi@3.8.1':
+ dependencies:
+ node-addon-api: 6.1.0
+ pkg-prebuilds: 1.1.0
+ tslib: 2.8.1
+
'@mapbox/node-pre-gyp@2.0.3':
dependencies:
consola: 3.4.2
@@ -4260,6 +4281,8 @@ snapshots:
natural-compare@1.4.0: {}
+ node-addon-api@6.1.0: {}
+
node-fetch@2.6.7:
dependencies:
whatwg-url: 5.0.0
@@ -4386,6 +4409,8 @@ snapshots:
picomatch@4.0.5: {}
+ pkg-prebuilds@1.1.0: {}
+
postcss@8.5.16:
dependencies:
nanoid: 3.3.15
@@ -4645,8 +4670,7 @@ snapshots:
ts-toolbelt@6.15.5: {}
- tslib@2.8.1:
- optional: true
+ tslib@2.8.1: {}
tsx@4.21.0:
dependencies:
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 767319b2e..29c80c3e1 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -3,6 +3,7 @@ packages:
minimumReleaseAgeExclude:
- "vercel@58.1.0"
allowBuilds:
+ "@julusian/midi": true
esbuild: true
overrides:
"@napi-rs/wasm-runtime": "1.1.6"
diff --git a/tests/coverage-fixtures/midi-enumerate.ts b/tests/coverage-fixtures/midi-enumerate.ts
new file mode 100644
index 000000000..e0b25b6c3
--- /dev/null
+++ b/tests/coverage-fixtures/midi-enumerate.ts
@@ -0,0 +1,24 @@
+// A fully static node:midi enumerate program: construct the port handles,
+// read the port counts (node-midi allows enumeration on a fresh handle
+// before openPort), print them, and close. No dynamic remainder — every
+// statement lowers, so coverage must pin it at 100% static.
+import { Input, Output } from "midi";
+
+const input = new Input();
+const output = new Output();
+
+const inputPorts = input.getPortCount();
+const outputPorts = output.getPortCount();
+
+console.log("inputs", inputPorts);
+console.log("outputs", outputPorts);
+
+for (let i = 0; i < inputPorts; i++) {
+ console.log("input", i, input.getPortName(i));
+}
+for (let i = 0; i < outputPorts; i++) {
+ console.log("output", i, output.getPortName(i));
+}
+
+input.closePort();
+output.closePort();
diff --git a/tests/diagnostics/midi.ts b/tests/diagnostics/midi.ts
new file mode 100644
index 000000000..7c3861829
--- /dev/null
+++ b/tests/diagnostics/midi.ts
@@ -0,0 +1,29 @@
+// node:midi lowering boundaries: what stays rejected at LOWERING with
+// specific messages. The fallback declarations type the port surface
+// exactly, so most misuse (a "clock" event, a string sendMessage, a wrong
+// listener arity) is a type error before lowering; these are the forms that
+// TYPECHECK and fence per site — the SC2020 lib fence for a message shape no
+// marshaler lowers, and the SC1090 statement-position rule the dgram spoke
+// shares. Each site is its own statement so all four diagnostics collect.
+
+import { Input, Output } from "midi";
+
+const output = new Output();
+
+// The static type calls this a number[], but the runtime shape is a string:
+// only a cast reaches the byte-transparent marshaler fence (a number[] rides
+// sendArray, a Uint8Array rides sendBytes, and nothing else lowers).
+output.sendMessage("nope" as unknown as number[]);
+
+const input = new Input();
+
+// Port calls return void — Node returns void here too — so their result
+// cannot feed a binding; call them as their own statement.
+const opened = input.openPort(0);
+
+// A message listener is called as void; an ANNOTATED value-returning arrow
+// keeps its word and stays fenced (the child_process listener rule exactly).
+input.on("message", (deltaTime): number => deltaTime);
+
+// A void-result port call in argument position is not a statement either.
+console.log(input.closePort());
diff --git a/tests/fixtures/midi/cases/virtual-loopback/main.ts b/tests/fixtures/midi/cases/virtual-loopback/main.ts
new file mode 100644
index 000000000..a9a2c5ce0
--- /dev/null
+++ b/tests/fixtures/midi/cases/virtual-loopback/main.ts
@@ -0,0 +1,57 @@
+// The hardware-free MIDI differential: a virtual-port loopback. An open
+// virtual Output and an Input connected to it live in one process, so no
+// real device is needed — but the pair still requires a POSIX MIDI backend
+// with virtual ports (ALSA sequencer / CoreMIDI), which CI here does not
+// have, so tests/harness/midi.test.ts GATES this case and skips it when no
+// backend is present. On a host that has one it runs under both Node (the
+// @julusian/midi dev-dep aliased to "midi") and the native binary, and the
+// two stdouts must match byte-for-byte.
+//
+// Determinism: deltaTime is wall-clock time between messages and is NEVER
+// printed; only the received message bytes are, one line per message. Ports
+// are located by NAME, not index, since index ordering varies across hosts.
+import { Input, Output } from "midi";
+
+const PORT_NAME = "scriptc-loopback";
+
+const output = new Output();
+output.openVirtualPort(PORT_NAME);
+
+const input = new Input();
+
+// Locate the virtual output by name (index ordering is host-dependent).
+let portIndex = -1;
+const portCount = input.getPortCount();
+for (let i = 0; i < portCount; i++) {
+ if (input.getPortName(i).includes(PORT_NAME)) {
+ portIndex = i;
+ break;
+ }
+}
+
+// Deliver everything (do not drop SysEx/timing/sense) so the byte stream is
+// exactly what was sent.
+input.ignoreTypes(false, false, false);
+
+const messages: number[][] = [
+ [0x90, 60, 100], // note on, channel 1
+ [0xb0, 7, 64], // control change (volume)
+ [0x80, 60, 0], // note off, channel 1
+];
+
+let received = 0;
+input.on("message", (_deltaTime, message) => {
+ // Print only the bytes — never the nondeterministic deltaTime.
+ console.log(message.join(" "));
+ received += 1;
+ if (received === messages.length) {
+ // The open input holds the loop alive; closing both drains it and exits.
+ input.closePort();
+ output.closePort();
+ }
+});
+
+input.openPort(portIndex);
+for (const m of messages) {
+ output.sendMessage(m);
+}
diff --git a/tests/harness/__snapshots__/midi.ts.txt b/tests/harness/__snapshots__/midi.ts.txt
new file mode 100644
index 000000000..53e39eafa
--- /dev/null
+++ b/tests/harness/__snapshots__/midi.ts.txt
@@ -0,0 +1,29 @@
+midi.ts:16:20 - error SC2020: 'sendMessage with a message that is not a number[] or Uint8Array' is part of the standard library types but has no scriptc lowering yet
+
+ 15 | // sendArray, a Uint8Array rides sendBytes, and nothing else lowers).
+ 16 | output.sendMessage("nope" as unknown as number[]);
+ | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ 17 |
+
+ hint: the supported message shapes are a number[] (array literal) and a Uint8Array
+
+midi.ts:22:16 - error SC1090: using the result of port.openPort(...) (the result is void here — call it as its own statement) is not supported yet
+
+ 21 | // cannot feed a binding; call them as their own statement.
+ 22 | const opened = input.openPort(0);
+ | ^~~~~~~~~~~~~~~~~
+ 23 |
+
+midi.ts:26:21 - error SC1090: listeners returning a value (make the callback body a block, or return nothing) is not supported yet
+
+ 25 | // keeps its word and stays fenced (the child_process listener rule exactly).
+ 26 | input.on("message", (deltaTime): number => deltaTime);
+ | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ 27 |
+
+midi.ts:29:13 - error SC1090: using the result of port.closePort(...) (the result is void here — call it as its own statement) is not supported yet
+
+ 28 | // A void-result port call in argument position is not a statement either.
+ 29 | console.log(input.closePort());
+ | ^~~~~~~~~~~~~~~~~
+ 30 |
\ No newline at end of file
diff --git a/tests/harness/coverage.test.ts b/tests/harness/coverage.test.ts
index 720a8ebc9..517fe3440 100644
--- a/tests/harness/coverage.test.ts
+++ b/tests/harness/coverage.test.ts
@@ -58,6 +58,15 @@ test("fully static JavaScript program reports 100%", () => {
expect(out).toContain("fully static");
});
+test("node:midi enumerate program is fully static", () => {
+ // The enumerate surface (construct, getPortCount/getPortName, closePort)
+ // lowers with no dynamic remainder — the static-coverage floor the native
+ // enumerate program builds on. See tests/coverage-fixtures/midi-enumerate.ts.
+ const out = report(fixture("midi-enumerate.ts"));
+ expect(out).toContain("(100%)");
+ expect(out).toContain("fully static");
+});
+
test("JS inference gaps land where 'any' lands: SC2011 static, island dynamic", async () => {
// The js-gap fixture's tsconfig turns noImplicitAny off, so the untyped
// parameter types `any` — the static analysis reports the site as
diff --git a/tests/harness/midi.test.ts b/tests/harness/midi.test.ts
new file mode 100644
index 000000000..8f2dd16ae
--- /dev/null
+++ b/tests/harness/midi.test.ts
@@ -0,0 +1,147 @@
+/* node:midi harness — two lanes, both gated on host capability.
+ *
+ * 1. The WASI refusal. wasm32-wasi is a production LLVM target, but WASI
+ * Preview 1 has no MIDI API, so any midi surface must fence at compile
+ * time with SC3002 (the socket/child-process precedent in index.ts).
+ * Reaching the wasi build platform needs zigcc on PATH, exactly like the
+ * wasm32-wasi differential lane, so this describe skips without zig.
+ *
+ * 2. The virtual-port loopback differential. The corpus is differential
+ * against Node, but a MIDI program that touches ports cannot be made
+ * byte-identical without a real MIDI stack: this CI container has no ALSA
+ * (the runtime compiles a stub that enumerates 0 ports and throws on
+ * open), and Node needs @julusian/midi (a native RtMidi addon) to answer
+ * at all. So this case is CAPABILITY-GATED: it runs only on a host where
+ * the Node baseline can actually open a virtual port pair (POSIX ALSA
+ * sequencer / CoreMIDI), and is skipped otherwise. It documents intent
+ * and validates real hardware-free loopback on a capable host; it must
+ * never break CI. See tests/fixtures/midi/cases/virtual-loopback/main.ts. */
+import { execFileSync, spawn, spawnSync } from "node:child_process";
+import { createHash } from "node:crypto";
+import { mkdirSync, readFileSync } from "node:fs";
+import { createRequire } from "node:module";
+import { join } from "node:path";
+import { afterAll, beforeAll, describe, expect, test } from "vitest";
+import { compile } from "@scriptc/compiler";
+
+const repoRoot = join(import.meta.dirname, "../..");
+const cacheDir = join(repoRoot, "node_modules/.cache/scriptc-tests");
+const require = createRequire(import.meta.url);
+
+function zigOnPath(): boolean {
+ try {
+ execFileSync("zig", ["version"], { stdio: "ignore" });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+describe.skipIf(!zigOnPath())("midi WASI refusal", () => {
+ let oldCc: string | undefined;
+ let oldTarget: string | undefined;
+
+ beforeAll(() => {
+ oldCc = process.env["SCRIPTC_CC"];
+ oldTarget = process.env["SCRIPTC_TARGET"];
+ process.env["SCRIPTC_CC"] = "zigcc";
+ process.env["SCRIPTC_TARGET"] = "wasm32-wasi";
+ });
+
+ afterAll(() => {
+ if (oldCc === undefined) delete process.env["SCRIPTC_CC"];
+ else process.env["SCRIPTC_CC"] = oldCc;
+ if (oldTarget === undefined) delete process.env["SCRIPTC_TARGET"];
+ else process.env["SCRIPTC_TARGET"] = oldTarget;
+ });
+
+ test("a midi surface fences before linking with SC3002", async () => {
+ const entry = join(repoRoot, "tests/coverage-fixtures/midi-enumerate.ts");
+ const outDir = join(cacheDir, "midi-wasi");
+ mkdirSync(outDir, { recursive: true });
+ const result = await compile(entry, { outDir, outPath: join(outDir, "program.wasm") });
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.diagnostics).toHaveLength(1);
+ expect(result.diagnostics[0]?.code).toBe("SC3002");
+ expect(result.diagnostics[0]?.message).toMatch(/MIDI/);
+ }
+ });
+});
+
+/** Whether this host can run the virtual-port loopback: the "midi" dev-dep
+ * (aliased to @julusian/midi) must resolve AND actually open a virtual
+ * output/input pair — which needs a POSIX MIDI backend with virtual ports.
+ * Windows WinMM has no user-space virtual ports, so it is excluded. When the
+ * Node baseline can do this, the native ALSA/CoreMIDI backend on the same
+ * host has virtual ports too. Any failure (missing addon, no ALSA) → skip. */
+function midiLoopbackAvailable(): boolean {
+ if (process.platform === "win32") return false;
+ try {
+ require.resolve("midi");
+ } catch {
+ return false;
+ }
+ const probe = [
+ 'const midi = require("midi");',
+ 'const out = new midi.Output();',
+ 'out.openVirtualPort("scriptc-probe");',
+ 'const inp = new midi.Input();',
+ 'let seen = false;',
+ 'for (let i = 0; i < inp.getPortCount(); i++) {',
+ ' if (inp.getPortName(i).includes("scriptc-probe")) seen = true;',
+ '}',
+ 'inp.closePort();',
+ 'out.closePort();',
+ 'process.exit(seen ? 0 : 1);',
+ ].join("");
+ const res = spawnSync(process.execPath, ["-e", probe], { stdio: "ignore", timeout: 15_000 });
+ return res.status === 0;
+}
+
+async function buildLoopback(entry: string): Promise {
+ const key = createHash("sha256").update(readFileSync(entry)).digest("hex").slice(0, 16);
+ const outDir = join(cacheDir, `midi-loopback-${key}`);
+ mkdirSync(outDir, { recursive: true });
+ const result = await compile(entry, { outPath: join(outDir, "program"), outDir, backend: "c" });
+ if (!result.ok) {
+ throw new Error(
+ "midi loopback fixture failed to compile:\n" +
+ result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"),
+ );
+ }
+ return result.binaryPath;
+}
+
+function runLane(cmd: string, args: string[]): Promise<{ stdout: string; exitCode: number }> {
+ return new Promise((resolve, reject) => {
+ const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
+ const out: Buffer[] = [];
+ let errText = "";
+ const timer = setTimeout(() => {
+ child.kill("SIGKILL");
+ reject(new Error(`midi loopback timed out\nstderr:\n${errText}`));
+ }, 30_000);
+ child.stdout.on("data", (c: Buffer) => out.push(c));
+ child.stderr.on("data", (c: Buffer) => (errText += c.toString("utf8")));
+ child.on("close", (code, signal) => {
+ clearTimeout(timer);
+ if (signal) reject(new Error(`midi loopback died to ${signal}\nstderr:\n${errText}`));
+ else resolve({ stdout: Buffer.concat(out).toString("utf8"), exitCode: code ?? 0 });
+ });
+ });
+}
+
+describe.skipIf(!midiLoopbackAvailable())("midi virtual-port loopback differential", () => {
+ const entry = join(repoRoot, "tests/fixtures/midi/cases/virtual-loopback/main.ts");
+
+ test("native loopback matches Node byte-for-byte", async () => {
+ const binary = await buildLoopback(entry);
+ // Sequential, not parallel: both lanes open a virtual MIDI port named the
+ // same, so keep the host's port table uncontended between the two runs.
+ const nodeRes = await runLane("node", [entry]);
+ const nativeRes = await runLane(binary, []);
+ expect(nativeRes.stdout).toBe(nodeRes.stdout);
+ expect(nativeRes.exitCode).toBe(nodeRes.exitCode);
+ }, 120_000);
+});