From c589e9da6f9391ae55c1fdf2c579a509e93e9922 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 02:59:03 +0000 Subject: [PATCH 01/11] docs: add native MIDI messaging port plan and TODO Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EYLKF6JBn2Fozts9CGr9W6 --- docs/plans/midi-native-port.md | 269 +++++++++++++++++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 docs/plans/midi-native-port.md diff --git a/docs/plans/midi-native-port.md b/docs/plans/midi-native-port.md new file mode 100644 index 000000000..1b71739db --- /dev/null +++ b/docs/plans/midi-native-port.md @@ -0,0 +1,269 @@ +# Plan: Native MIDI messaging support for scriptc + +Status: proposed · Owner: compiler+runtime · Target branch: `claude/midi-native-port-plan-1mmmsq` + +## 1. Goal + +MIDI messaging is available to JavaScript today in two shapes: + +- **Web** — the [Web MIDI API](https://www.w3.org/TR/webmidi/): `navigator.requestMIDIAccess()` + yields a `MIDIAccess` with `inputs`/`outputs` maps of `MIDIInput`/`MIDIOutput` + ports; you receive with `input.onmidimessage` (a `MIDIMessageEvent` carrying a + `Uint8Array` `data`) and transmit with `output.send(data, timestamp?)`. +- **Server (Node)** — native addons over the platform MIDI stacks, the de-facto + standard being [`node-midi`](https://github.com/justinlatimer/node-midi) and its + maintained fork [`@julusian/midi`](https://github.com/Julusian/node-midi) + (RtMidi under the hood), plus the ergonomic wrapper + [`easymidi`](https://github.com/dinchak/node-easymidi). Core surface: + `new midi.Input()` / `new midi.Output()`, `getPortCount()`, `getPortName(i)`, + `openPort(i)`, `openVirtualPort(name)`, `input.on('message', (dt, msg) => …)`, + `output.sendMessage([status, d1, d2])`, `closePort()`, `ignoreTypes(...)`. + +scriptc compiles TS/JS to **native executables** (macOS/Linux/Windows) and to +**WASI** wasm. There is no MIDI surface today. This plan ports **MIDI messaging +core features** — enumerate ports, open input/output (incl. virtual ports), +receive time-stamped messages via an event, and send raw messages — to +scriptc's native runtime, exposed through a Node-shaped `node:midi` module +surface that is differential-testable against a real Node baseline. + +### Scope + +**In scope (core messaging):** +- Port enumeration: `getPortCount()`, `getPortName(index)`. +- Input: `new Input()`, `openPort(i)`, `openVirtualPort(name)`, `closePort()`, + `on('message', cb)` / `once('message', cb)`, `ignoreTypes(sysex, timing, sense)`. +- Output: `new Output()`, `openPort(i)`, `openVirtualPort(name)`, `closePort()`, + `sendMessage(number[] | Uint8Array)`. +- Message payloads carry raw bytes (Note On/Off, CC, Program Change, Pitch Bend, + channel pressure, and SysEx as a byte run) — the runtime is byte-transparent; + it does not parse or validate message semantics. A thin optional decode helper + (note/CC accessors) may follow but is **not** core. +- Delta-time (seconds since the previous message on that input), matching + node-midi's `message` callback first argument. + +**Out of scope (this port):** +- Browser Web MIDI in the WASI target (WASI Preview 1 has no MIDI capability — it + fences, see §6). The *API shape* is modeled to stay portable, but the wasm + target refuses MIDI at compile time like it does sockets. +- MIDI file (SMF) parsing, sequencing/clock scheduling, SysEx device protocols, + MIDI 2.0 / UMP, virtual-MIDI on Windows (WinMM has no user-space virtual ports). +- `easymidi`-style semantic event names (`noteon`, `cc`, …). Those can be a + pure-TS layer on top later; the native core stays raw-byte. + +### Why a Node-module shape (not a Web-MIDI global) + +The corpus is **differential against Node**: every program runs under Node and as +a native binary and must match stdout/stderr/exit byte-for-byte (AGENTS.md). Node +has no built-in MIDI, but `@julusian/midi` provides one under the same +`import midi from "midi"` name we target, **and** it supports `openVirtualPort`, +which gives us a hardware-free deterministic loopback for tests (open a virtual +output, open an input on that virtual port, send, receive, compare). Modeling on +the Web MIDI global would have no Node baseline to diff against. So: `node:midi` +module surface, API-compatible with node-midi/@julusian/midi. + +## 2. How scriptc adds a native module surface (the dgram template) + +`node:dgram` is the closest existing analog: an event-driven, message-oriented +device/socket handle whose reads feed the event loop. A MIDI input is +structurally the same (a pollable source delivering discrete messages), and a +MIDI output is like a connected UDP socket (`sendMessage` ≈ `send`). Every +touchpoint below is mirrored from dgram. + +| Concern | dgram implementation | MIDI equivalent to build | +| --- | --- | --- | +| Ambient types | `declare module "dgram"` / `"node:dgram"` in `ambient/scriptc-node-fallback.d.ts` | `declare module "midi"` / `"node:midi"` | +| IR handle type | `dgramSocket` in `ir/nodes.ts` (kind union, `HANDLE_KINDS`, `DGRAMSOCK_T`, refcount predicate, `moduleUsesDgram`) | `midiInput`, `midiOutput` kinds + `moduleUsesMidi` | +| Type mapping | `types.ts` maps ambient `Socket` (declared in `dgram`) → `{kind:"dgramSocket"}` | ambient `Input`/`Output` → `midiInput`/`midiOutput` | +| Lowering spoke | `lowering/lower-dgram.ts` (module fns + method calls + event listeners), dispatched from `lowerer.ts` & `lower-calls.ts` | new `lowering/lower-midi.ts`, dispatched the same way | +| Module registry | `SUPPORTED_BUILTIN_MODULES` in `frontend/shared.ts`; builtin set in `frontend/npm.ts`; keys in `surfaces.ts` | add `"midi"` to all three | +| Runtime C | `runtime/src/scr_dgram.c` over the `scr_platform.h` poller seam | new `runtime/src/scr_midi.c` (+ platform backends) | +| Build inclusion | conditional TU behind `moduleUsesDgram`/`net` in `backend/cc.ts`, flagged from `index.ts` | conditional TU behind `moduleUsesMidi` | +| WASI fence | `index.ts` refuses `dgram.`/`dgramSocket` on WASI with SC3002 | refuse `midi`/`midiInput`/`midiOutput` on WASI | +| Tests | `tests/fixtures/dgram/cases/*`, `tests/corpus/*dgram*`, `tests/harness/dgram.test.ts` | `tests/fixtures/midi/*`, corpus, `tests/harness/midi.test.ts` | +| Docs | platforms / limitations / dependencies pages under `docs/` | same pages + a MIDI note | +| Manifest | projected into `surface-manifest.json` via `pnpm manifest` | regenerate | + +## 3. Proposed API surface (ambient `.d.ts`) + +Mirrors node-midi/@julusian/midi so the Node differential baseline is a real, +installable package. + +```ts +declare module "midi" { + export class Input { + getPortCount(): number; + getPortName(port: number): string; + openPort(port: number): void; + openVirtualPort(name: string): void; // POSIX only; fences on Windows + closePort(): void; + isPortOpen(): boolean; + // sysex, timing (clock), activeSensing — each true = ignore (node-midi default true,true,true) + 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; // POSIX only; fences on Windows + closePort(): void; + isPortOpen(): boolean; + sendMessage(message: number[] | Uint8Array): void; + } +} +declare module "node:midi" { export * from "midi"; } +``` + +Constrained call forms (the surfaces.ts stance): `sendMessage` takes an array +literal or a `Uint8Array`; `on`/`once` accept only the `"message"` event with a +`(deltaTime, message)` void arrow/function of ≤2 params (the +`lowerCallbackArg` pattern from lower-dgram). Anything else fences +member-qualified with a named hint (never a silent drop). + +## 4. Runtime design (`scr_midi.c` + platform backends) + +### Handle model +`ScrMidiInput` and `ScrMidiOutput` are refcounted handles like `ScrDgramSocket`. +An **open input** holds the loop alive (a live source, like a bound socket); +an output does not (send is fire-and-forget). Both are freed on `closePort()` ++ last ref drop; the unit forgets any registered fd before closing it. + +### Event-loop integration (the `scr_platform.h` seam) +The runtime already exposes a readiness poller: `scrp_poller_new`, +`scrp_watch_read(fd,…)`, `scrp_forget(fd)`, `scrp_drain(...)` (kqueue/epoll/wsapoll). +The loop (`scr_async.c`) will call a new `scr_midi_dispatch()` each turn, exactly +as it calls `scr_dgram_dispatch()`. + +- **Linux — ALSA sequencer (`libasound`).** `snd_seq_open`, create a port, + subscribe. ALSA exposes pollable fds via `snd_seq_poll_descriptors()` → + register each with `scrp_watch_read`; on readiness `snd_seq_event_input()` and + translate seq events to raw MIDI bytes (`snd_midi_event_decode`). Virtual ports + are native (an ALSA port other clients connect to). **Container note:** ALSA + dev headers are absent here (`/usr/include/alsa/asoundlib.h` missing) and CI has + no sound stack — the Linux backend is written behind the seam and validated on a + host with ALSA; loopback tests use the virtual-port pair so no hardware is needed. +- **macOS — CoreMIDI (`-framework CoreMIDI`).** `MIDIClientCreate`, + `MIDIInputPortCreate` with a read callback that fires **on a CoreMIDI thread**. + Bridge to the loop with a self-pipe/`eventfd`: the callback enqueues the packet + on a mutex-guarded ring and writes one byte; the pipe read-end is registered + with `scrp_watch_read`, so `scr_midi_dispatch` drains the ring on the loop + thread and fires JS listeners there (never call into the runtime from the + CoreMIDI thread). `MIDISourceCreate`/`MIDIDestinationCreate` back virtual ports. +- **Windows — WinMM (`winmm.lib`).** `midiInOpen` with a callback (also + off-thread → same self-pipe bridge over `scr_loop_wsapoll.c`), `midiInAddBuffer` + for SysEx, `midiOutShortMsg`/`midiOutLongMsg` to send. **No virtual ports** on + WinMM → `openVirtualPort` fences at runtime with a clear error (documented + divergence; WinRT MIDI is a later option). + +### Delta-time +Each input tracks the timestamp of its previous delivered message and reports +`deltaTime` in **seconds** (node-midi's unit). First message after open reports +`0`. Use the platform timestamp where available (CoreMIDI packet time, ALSA +tick/real-time), else the loop clock. + +### ABI contract (lowering ⇄ runtime) — keep parallel prototypes integrable +The lowering emits `IrLibFn` calls; the runtime implements these exact symbols. +Draft (finalize in the front-matter task, then freeze for the runtime task): + +| lib fn id | C symbol | signature (conceptual) | +| --- | --- | --- | +| `midi.newInput` | `scr_midi_input_new` | `() -> ScrMidiInput*` | +| `midi.newOutput` | `scr_midi_output_new` | `() -> ScrMidiOutput*` | +| `midi.portCount` | `scr_midi_port_count` | `(handle, isInput) -> f64` | +| `midi.portName` | `scr_midi_port_name` | `(handle, idx) -> ScrString*` | +| `midi.openPort` | `scr_midi_open_port` | `(handle, idx) -> void` | +| `midi.openVirtual` | `scr_midi_open_virtual` | `(handle, ScrString* name) -> void` | +| `midi.closePort` | `scr_midi_close_port` | `(handle) -> void` | +| `midi.isOpen` | `scr_midi_is_open` | `(handle) -> bool` | +| `midi.ignoreTypes` | `scr_midi_ignore_types` | `(input, b,b,b) -> void` | +| `midi.send` | `scr_midi_send` | `(output, bytes*, len) -> void` | +| `midi.onMessage` | `scr_midi_on_message` | `(input, closure, once) -> void` | +| `midi.dispatch` | `scr_midi_dispatch` | loop hook (internal) | + +Message bytes are delivered to the JS closure as a `number[]` (the node-midi +shape) built by the runtime, with `deltaTime` as the first f64 argument. + +## 5. Testing strategy (hardware-free, differential) + +The blocker for MIDI tests is "no hardware, must match Node byte-for-byte." +Solved by **virtual-port loopback**, supported by both `@julusian/midi` (Node +baseline) and the POSIX runtime backends: + +1. Node baseline fixture uses `import midi from "midi"` (dev-dep `@julusian/midi`). +2. Program opens a virtual **Output** named e.g. `scriptc-test`, opens an + **Input** and connects it to that virtual port, sends a deterministic + sequence, prints each received message (and a fixed/synthetic deltaTime so + output is stable), then closes. +3. Harness runs it under Node and native; stdout must match. + +Determinism guards: print `message` bytes only (not wall-clock deltaTime — round +or replace with a monotonic counter in the test program); enumerate ports by a +name filter, not index, since index ordering varies. Gate the corpus case on +platform capability (POSIX virtual ports) like other capability-gated cases. +Windows and CI-without-ALSA lanes get compile-coverage + fence tests only. + +Also: fence/diagnostics snapshot tests (unsupported event names, bad +`sendMessage` args, `openVirtualPort` on Windows, any MIDI use on WASI → SC3002). + +## 6. WASI / web boundary +WASI Preview 1 has no MIDI capability. Follow the socket precedent in +`index.ts`: refuse `midi`/`midiInput`/`midiOutput` at compile time for the wasm +target with SC3002 and a message pointing at the platform-support page. Document +that Web MIDI (browser) is a separate runtime not covered by the WASI target. + +## 7. Risks & open questions +- **ALSA/CoreMIDI/WinMM link flags** must be added conditionally only when a + program uses MIDI (don't burden every binary). Mirror the fetch/curl + conditional-link precedent in `cc.ts`. +- **Off-thread callbacks** (CoreMIDI/WinMM) must never touch the runtime heap; + the self-pipe bridge is mandatory. Reference-count audit (the sanitized lane) + will catch violations. +- **CI has no ALSA/sound** → Linux native MIDI validated on a real host; CI keeps + fence + compile tests. Flag this to maintainers. +- **deltaTime nondeterminism** → tests must not print raw timing. +- Decide whether `getPortCount`/`getPortName` also work on a fresh handle before + `openPort` (node-midi allows it — enumerate then open). Plan: yes. + +--- + +## TODO checklist + +### Phase 0 — Design freeze +- [ ] Confirm API shape against installed `@julusian/midi@3.8.1` (method names, arg order, defaults). +- [ ] Freeze the lowering⇄runtime ABI table (§4) so parallel work integrates. + +### Phase 1 — Compiler front (ambient + IR + types) +- [ ] Add `declare module "midi"` and `"node:midi"` to `ambient/scriptc-node-fallback.d.ts`. +- [ ] Add IR handle kinds `midiInput`/`midiOutput` in `ir/nodes.ts`: kind union, `HANDLE_KINDS`, `*_T` consts, refcount predicate, `moduleUsesMidi`, type-name mapping. +- [ ] Map ambient `Input`/`Output` (declared in `midi`) → handle kinds in `frontend/types.ts`. +- [ ] Register `"midi"` in `SUPPORTED_BUILTIN_MODULES` (`frontend/shared.ts`) and the builtin set in `frontend/npm.ts`. + +### Phase 2 — Lowering spoke +- [ ] Create `lowering/lower-midi.ts`: constructors (`new Input()`/`new Output()`), methods (`getPortCount`/`getPortName`/`openPort`/`openVirtualPort`/`closePort`/`isPortOpen`/`ignoreTypes`/`sendMessage`), and the `on`/`once` `"message"` listener (reuse the `lowerCallbackArg` shape). +- [ ] Add `midi: {}` key + fence hint in `lowering/surfaces.ts`. +- [ ] Dispatch the spoke from `lowerer.ts` and `lower-calls.ts` (module calls + method calls on the handle receivers), mirroring `lowerDgramDnsModuleCall`. +- [ ] Statement-position + arg-shape fences with named hints (no silent drops). + +### Phase 3 — Runtime C +- [ ] `runtime/src/scr_midi.c`: handle structs, refcount, loop liveness, `scr_midi_dispatch`, the ABI symbols from §4. +- [ ] Linux ALSA-seq backend (`snd_seq_*`, poll descriptors → poller, virtual ports). +- [ ] macOS CoreMIDI backend (client/ports, self-pipe bridge from the CoreMIDI thread, virtual sources/destinations). +- [ ] Windows WinMM backend (`midiIn*`/`midiOut*`, self-pipe bridge, `openVirtualPort` runtime fence). +- [ ] Wire `scr_midi_dispatch()` into the loop in `scr_async.c`. + +### Phase 4 — Build wiring +- [ ] `moduleUsesMidi` flag threaded from `index.ts` into the backend options. +- [ ] Conditional TU compilation of `scr_midi.c` in `backend/cc.ts`, with conditional platform link flags (`-lasound` / `-framework CoreMIDI` / `winmm.lib`). +- [ ] WASI fence (SC3002) for any MIDI surface in `index.ts`. + +### Phase 5 — Tests & docs +- [ ] `tests/fixtures/midi/cases/*`: virtual-port loopback differential program(s); add `@julusian/midi` dev-dep for the Node baseline. +- [ ] `tests/harness/midi.test.ts` + a `tests/corpus/*` case (capability-gated). +- [ ] Diagnostics snapshots: unsupported event, bad `sendMessage`, `openVirtualPort` on Windows, MIDI on WASI. +- [ ] Docs: platform-support, limitations, dependencies pages; CHANGELOG entry. +- [ ] Regenerate `surface-manifest.json` (`pnpm manifest`). + +### Phase 6 — Validation +- [ ] `pnpm -r build` clean; `pnpm lint` clean. +- [ ] `pnpm test:sandbox` (plain + sanitized) green; native MIDI loopback validated on a host with ALSA/CoreMIDI. From f1d5da753eb434fa0c546412dac021a056538334 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 03:07:25 +0000 Subject: [PATCH 02/11] =?UTF-8?q?feat(compiler):=20add=20node:midi=20front?= =?UTF-8?q?-matter=20=E2=80=94=20ambient=20types,=20IR=20handle=20kinds,?= =?UTF-8?q?=20C-emission=20mapping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds declare module midi/node:midi, midiInput/midiOutput IR handle kinds, moduleUsesMidi predicate, type mapping, module registry entries, and the C-representation/retain/release mapping in the emission layer. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EYLKF6JBn2Fozts9CGr9W6 --- .../ambient/scriptc-node-fallback.d.ts | 41 +++++++++++++ .../src/backend/emission/emit-types.ts | 20 +++++++ .../compiler/src/backend/emission/emitter.ts | 2 + packages/compiler/src/frontend/npm.ts | 2 +- packages/compiler/src/frontend/shared.ts | 2 +- packages/compiler/src/frontend/types.ts | 36 ++++++++++- packages/compiler/src/ir/nodes.ts | 60 ++++++++++++++++++- 7 files changed, 159 insertions(+), 4 deletions(-) diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index fee214132..4c23aafda 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 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..65d81dcdc 100644 --- a/packages/compiler/src/backend/emission/emitter.ts +++ b/packages/compiler/src/backend/emission/emitter.ts @@ -1905,6 +1905,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/npm.ts b/packages/compiler/src/frontend/npm.ts index 8b919cd08..64841bd56 100644 --- a/packages/compiler/src/frontend/npm.ts +++ b/packages/compiler/src/frontend/npm.ts @@ -576,7 +576,7 @@ const KNOWN_BUILTINS = new Set([ ...SHIMMED_BUILTINS, "assert", "async_hooks", "buffer", "cluster", "console", "constants", "crypto", "dgram", "diagnostics_channel", "dns", "domain", "http", - "https", "http2", "inspector", "module", "net", "os", "perf_hooks", + "https", "http2", "inspector", "midi", "module", "net", "os", "perf_hooks", "punycode", "querystring", "readline", "repl", "stream", "string_decoder", "sys", "timers", "tls", "trace_events", "tty", "url", "util", "v8", "vm", "wasi", "worker_threads", "zlib", diff --git a/packages/compiler/src/frontend/shared.ts b/packages/compiler/src/frontend/shared.ts index 8bd5deea3..1b35589bd 100644 --- a/packages/compiler/src/frontend/shared.ts +++ b/packages/compiler/src/frontend/shared.ts @@ -57,7 +57,7 @@ export function isNodeTypesPath(file: string): boolean { * this is exactly the set of `declare module` names in that file; when * @types/node stands in (which declares ALL node builtins) the supported * surface must not widen, so preflight allowlists this same fixed set. */ -export const SUPPORTED_BUILTIN_MODULES = ["fs", "path", "path/posix", "path/win32", "os", "url", "fs/promises", "crypto", "zlib", "child_process", "net", "http", "tls", "https", "dgram", "dns", "util", "util/types", "string_decoder", "querystring", "readline", "http2", "assert", "assert/strict", "worker_threads", "buffer", "cluster", "tty", "async_hooks", "events", "stream", "stream/promises", "stream/consumers", "test", "timers", "timers/promises", "diagnostics_channel", "perf_hooks", "module"] as const; +export const SUPPORTED_BUILTIN_MODULES = ["fs", "path", "path/posix", "path/win32", "os", "url", "fs/promises", "crypto", "zlib", "child_process", "net", "http", "tls", "https", "dgram", "dns", "midi", "util", "util/types", "string_decoder", "querystring", "readline", "http2", "assert", "assert/strict", "worker_threads", "buffer", "cluster", "tty", "async_hooks", "events", "stream", "stream/promises", "stream/consumers", "test", "timers", "timers/promises", "diagnostics_channel", "perf_hooks", "module"] as const; /** Builtins Node itself serves ONLY under the node: prefix — * require("test") is MODULE_NOT_FOUND in Node, so the bare name stays a diff --git a/packages/compiler/src/frontend/types.ts b/packages/compiler/src/frontend/types.ts index f855932e2..1c2cb7fe9 100644 --- a/packages/compiler/src/frontend/types.ts +++ b/packages/compiler/src/frontend/types.ts @@ -393,6 +393,10 @@ export function formatIrType(t: IrType, shapes: ShapeRegistry, unions: UnionRegi return "Http2Stream"; case "dgramSocket": return "dgram.Socket"; + case "midiInput": + return "midi.Input"; + case "midiOutput": + return "midi.Output"; case "testCtx": return "TestContext"; case "httpReq": @@ -987,6 +991,8 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { elem.kind === "spawnRes" || elem.kind === "netSocket" || elem.kind === "dgramSocket" || + elem.kind === "midiInput" || + elem.kind === "midiOutput" || elem.kind === "testCtx" || elem.kind === "httpReq" || elem.kind === "httpRes" || @@ -1086,7 +1092,7 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { // no class identity of its own. if (widened.isIntersectionType()) { const HANDLE_KINDS = new Set([ - "netServer", "netSocket", "httpReq", "httpRes", "httpClientReq", "dgramSocket", + "netServer", "netSocket", "httpReq", "httpRes", "httpClientReq", "dgramSocket", "midiInput", "midiOutput", // process.stdout's own type IS the refined intersection // `WriteStream & { fd: 1 }` — the scalar stream kind rides the same // refinement rule. @@ -1800,6 +1806,34 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { ) { return { kind: "dgramSocket" }; } + // midi.Input / midi.Output: the node-midi port classes, disambiguated by + // their enclosing ambient module — @julusian/midi's `class Input` / + // `class Output` and the fallback declarations' classes both live inside + // `declare module "midi"` (isDeclaredInAmbientModule answers for the + // "midi" and "node:midi" spellings alike). The names are generic enough + // to collide with user classes, so the ambient-module guard is load-bearing. + if ( + psym?.name === "Input" && + checker.declarationsOf(psym).some( + (d) => + (ts.isInterfaceDeclaration(d) || ts.isClassDeclaration(d)) && + ctx.isStdlibFile(d.getSourceFile()) && + isDeclaredInAmbientModule(d, "midi"), + ) + ) { + 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"), + ) + ) { + 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/ir/nodes.ts b/packages/compiler/src/ir/nodes.ts index 9335ac1c5..bbf2f86fe 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" || @@ -5337,6 +5359,8 @@ function isJsonSafeAt( case "http2Session": case "http2Stream": case "dgramSocket": + case "midiInput": + case "midiOutput": case "testCtx": case "httpReq": case "httpRes": @@ -6472,6 +6496,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 +6725,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 +6788,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"], From c663d1d113d7d4a3454a04def86b54c4570e52f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 03:13:01 +0000 Subject: [PATCH 03/11] feat(runtime): add scr_midi.c native MIDI unit (ALSA/CoreMIDI/WinMM) Refcounted midi Input/Output handles over the event-loop poller seam, off-thread callback bridging via self-pipe, number[] message delivery with deltaTime, virtual-port loopback on POSIX, header decls and scr_async.c loop hook. Falls back to a stub backend where no MIDI stack is present. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EYLKF6JBn2Fozts9CGr9W6 --- packages/runtime/src/scr_async.c | 43 +- packages/runtime/src/scr_midi.c | 1422 ++++++++++++++++++++++++++++ packages/runtime/src/scr_runtime.h | 66 ++ 3 files changed, 1526 insertions(+), 5 deletions(-) create mode 100644 packages/runtime/src/scr_midi.c 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..170f84d2a --- /dev/null +++ b/packages/runtime/src/scr_midi.c @@ -0,0 +1,1422 @@ +/* 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, 0, 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): From 013019fd50b09971ad5dc2f34e446369415bd21a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 03:13:29 +0000 Subject: [PATCH 04/11] docs: reconcile MIDI ABI table with runtime prototype (thunks, send marshalers, install hook) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EYLKF6JBn2Fozts9CGr9W6 --- docs/plans/midi-native-port.md | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/docs/plans/midi-native-port.md b/docs/plans/midi-native-port.md index 1b71739db..a2c1fdeda 100644 --- a/docs/plans/midi-native-port.md +++ b/docs/plans/midi-native-port.md @@ -177,13 +177,29 @@ Draft (finalize in the front-matter task, then freeze for the runtime task): | `midi.closePort` | `scr_midi_close_port` | `(handle) -> void` | | `midi.isOpen` | `scr_midi_is_open` | `(handle) -> bool` | | `midi.ignoreTypes` | `scr_midi_ignore_types` | `(input, b,b,b) -> void` | -| `midi.send` | `scr_midi_send` | `(output, bytes*, len) -> void` | -| `midi.onMessage` | `scr_midi_on_message` | `(input, closure, once) -> void` | -| `midi.dispatch` | `scr_midi_dispatch` | loop hook (internal) | +| `midi.send` (array) | `scr_midi_send_array` | `(output, ScrArr* number[]) -> void` | +| `midi.send` (bytes) | `scr_midi_send_bytes` | `(output, ScrBytes* Uint8Array) -> void` | +| `midi.onMessage` | `scr_midi_on_message` | `(input, closure, ScrMidiMsgFn fn, once) -> void` | +| `midi.dispatch` | `scr_midi_dispatch` | loop hook (internal, static) | Message bytes are delivered to the JS closure as a `number[]` (the node-midi shape) built by the runtime, with `deltaTime` as the first f64 argument. +**Reconciled during prototyping (both mirror the dgram spoke exactly):** +- `sendMessage` lowers to two marshalers picked by argument type — + `scr_midi_send_array` for a `number[]` and `scr_midi_send_bytes` for a + `Uint8Array` — over a raw `scr_midi_send(out, bytes*, len)` primitive + (parallel to dgram's `send_str`/`send_bytes`). +- `on/once('message')` passes an adapter-thunk pointer selected by the + listener's declared param count (`scr_midi_msg_thunk0/1/2`), because a user + closure's compiled C arity (0/1/2 params) can't be invoked through one fixed + signature — exactly dgram's `msg_thunk0/1` mechanism. +- The runtime registers its loop hook via `scr_loop_set_midi(...)` from + `scr_midi_install()`; generated `main` must call `scr_midi_install()` under + `moduleUsesMidi`, like `scr_dgram_install()`. +- Refcount symbols the C-emission layer calls: `scr_midi_input_retain/release`, + `scr_midi_output_retain/release`, and their `_v` void* variants. + ## 5. Testing strategy (hardware-free, differential) The blocker for MIDI tests is "no hardware, must match Node byte-for-byte." From 01f0452621218540e19883127c6c7159263fe3a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 03:18:44 +0000 Subject: [PATCH 05/11] feat(compiler): add node:midi lowering spoke lower-midi.ts lowers new Input()/Output() constructors and the port method surface (getPortCount/getPortName/openPort/openVirtualPort/closePort/ isPortOpen/ignoreTypes/sendMessage/on-once message) to the midi.* lib calls, wired into lowerNew and the method-call dispatch; surfaces.ts fence hint. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EYLKF6JBn2Fozts9CGr9W6 --- .../src/frontend/lowering/lower-calls.ts | 9 + .../src/frontend/lowering/lower-midi.ts | 334 ++++++++++++++++++ .../compiler/src/frontend/lowering/lowerer.ts | 22 +- .../src/frontend/lowering/surfaces.ts | 7 + 4 files changed, 371 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/src/frontend/lowering/lower-midi.ts 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..ab990bd38 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -100,6 +100,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"; @@ -7799,7 +7800,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 +8019,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 +8250,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 Date: Fri, 14 Aug 2026 03:22:37 +0000 Subject: [PATCH 06/11] feat(compiler): wire midi.* lib fns through emitter and validator Adds the midi.* ids to IrLibFn, the emitter dispatch mapping each to its scr_midi_* symbol (with per-arity onMessage thunk selection and input-only loop liveness), the may-throw set, and the lib-fn signature table. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EYLKF6JBn2Fozts9CGr9W6 --- .../src/backend/emission/emit-exprs.ts | 48 +++++++++++++++++++ packages/compiler/src/ir/nodes.ts | 30 ++++++++++++ packages/compiler/src/ir/validate.ts | 17 ++++++- 3 files changed, 94 insertions(+), 1 deletion(-) 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/ir/nodes.ts b/packages/compiler/src/ir/nodes.ts index bbf2f86fe..3dc0cb61f 100644 --- a/packages/compiler/src/ir/nodes.ts +++ b/packages/compiler/src/ir/nodes.ts @@ -2429,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 @@ -7221,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 From 77e14f91b58b14ee9659a6eca8ccb5b673345c78 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 03:26:29 +0000 Subject: [PATCH 07/11] feat(compiler): wire node:midi build inclusion, install hook, and WASI fence Threads moduleUsesMidi into the backend opts, compiles scr_midi.c and links the platform MIDI stack (ALSA where present / CoreMIDI / WinMM) conditionally, emits scr_midi_install() into generated main, and refuses the MIDI surface on the WASI target (SC3002). End-to-end: an enumerate program builds and runs natively via the C backend (LLVM defers node surfaces, as dgram does). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EYLKF6JBn2Fozts9CGr9W6 --- packages/compiler/src/backend/cc.ts | 28 ++++++++++++++++++- .../compiler/src/backend/emission/emitter.ts | 5 +++- packages/compiler/src/index.ts | 7 ++++- 3 files changed, 37 insertions(+), 3 deletions(-) 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/emitter.ts b/packages/compiler/src/backend/emission/emitter.ts index 65d81dcdc..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();`] : []), diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 5f614973c..77913026d 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)"], @@ -1053,6 +1056,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. From 651f34e6ad69d526d4077654c9d76303c11add5a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 03:48:29 +0000 Subject: [PATCH 08/11] test/docs: add node:midi tests, docs, manifest entry, and Node baseline dev-dep - diagnostics snapshot (SC2020 bad sendMessage shape; SC1090 void-result rules) - coverage fixture pinning the enumerate program at 100% static - capability-gated harness: WASI SC3002 refusal + virtual-port loopback differential (skipped where no ALSA/CoreMIDI backend / @julusian/midi) - platforms / limitations / introduction / how-it-works docs + CHANGELOG - regenerated surface-manifest (node-builtin.midi) and @julusian/midi dev-dep Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EYLKF6JBn2Fozts9CGr9W6 --- CHANGELOG.md | 4 + docs/src/app/how-it-works/page.mdx | 2 +- docs/src/app/introduction/page.mdx | 2 +- docs/src/app/limitations/page.mdx | 10 ++ docs/src/app/platforms/page.mdx | 38 +++++ package.json | 1 + packages/compiler/surface-manifest.json | 7 + pnpm-lock.yaml | 28 +++- tests/coverage-fixtures/midi-enumerate.ts | 24 +++ tests/diagnostics/midi.ts | 29 ++++ .../midi/cases/virtual-loopback/main.ts | 57 +++++++ tests/harness/__snapshots__/midi.ts.txt | 29 ++++ tests/harness/coverage.test.ts | 9 ++ tests/harness/midi.test.ts | 147 ++++++++++++++++++ 14 files changed, 383 insertions(+), 4 deletions(-) create mode 100644 tests/coverage-fixtures/midi-enumerate.ts create mode 100644 tests/diagnostics/midi.ts create mode 100644 tests/fixtures/midi/cases/virtual-loopback/main.ts create mode 100644 tests/harness/__snapshots__/midi.ts.txt create mode 100644 tests/harness/midi.test.ts 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: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PlatformBackendVirtual ports
LinuxALSA sequencer (libasound, linked as -lasound)Yes — a native ALSA port other clients connect to
macOSCoreMIDI (-framework CoreMIDI)Yes — MIDISourceCreate/MIDIDestinationCreate
WindowsWinMM (winmm.lib)No — WinMM has no user-space virtual ports; openVirtualPort fails at runtime with a clear error
WASINoneNo — 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/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/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/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); +}); From 9e83e48d6680ee78f4fb9b544a08fa72e4a0d8ef Mon Sep 17 00:00:00 2001 From: iplanwebsites <787729+iplanwebsites@users.noreply.github.com> Date: Fri, 14 Aug 2026 07:34:17 -0400 Subject: [PATCH 09/11] fix(midi): validate native package loopback --- .../compiler/ambient/scriptc-node-fallback.d.ts | 4 +++- packages/compiler/src/frontend/lowering/lowerer.ts | 3 ++- packages/compiler/src/frontend/program.ts | 14 +++++++++----- packages/compiler/src/frontend/shared.ts | 8 ++++++++ packages/compiler/src/frontend/types.ts | 14 ++++++-------- packages/compiler/src/index.ts | 9 ++++----- packages/runtime/src/scr_midi.c | 3 ++- pnpm-workspace.yaml | 1 + 8 files changed, 35 insertions(+), 21 deletions(-) diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index 4c23aafda..c50a96327 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -3174,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/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index ab990bd38..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, @@ -7446,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); diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 89e0e58d3..3271101b0 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -1465,13 +1465,13 @@ function resolveImport7(program: ts.Program, from: ts.SourceFile, specifier: str /** An import that resolves into node_modules: the package's shipped .d.ts * is the type surface, and the package's shipped JS runs in the dynamic * island under --dynamic. Resolution rides the own resolver (resolve.ts). - * Null for relative and node: specifiers, and for anything that doesn't - * resolve into node_modules. */ + * Null for relative and supported builtin specifiers, and for anything + * that doesn't resolve into node_modules. */ function resolveNpmImport7( fromFileName: string, specifier: string, ): { packageName: string; version?: string; typesFile: string } | null { - if (isRelativeSpecifier(specifier) || specifier.startsWith("node:")) { + if (isRelativeSpecifier(specifier) || canonicalBuiltinModule(specifier) !== null) { return null; } // --provenance-sources: a registered specifier is NOT an npm import — @@ -2011,7 +2011,10 @@ function preflight7(load: LoadResult): { continue; } const isRelative = isRelativeSpecifier(spec); - const isBare = !isRelative && !ambientModules.has(spec); + const isBare = + !isRelative && + canonicalBuiltinModule(spec) === null && + !ambientModules.has(spec); // --npm-static: an opted-in package importing node:module admits // for PROGRAM code (per-member fences, divergence 370) but marks // the PACKAGE an offender — createRequire's static story covers @@ -2668,6 +2671,7 @@ export { builtinDefaultImportModule, canonicalBuiltinModule, fallbackDtsPath, + isMidiTypesPath, isNodeTypesPath, npmPackageNameOf, overridesDtsPath, @@ -2772,7 +2776,7 @@ export function orderedImportsOf( * lowering paths share. */ export function npmStaticDepSf7(program: ts.Program, sf: ts.SourceFile, spec: string): ts.SourceFile | null { if (!npmStaticActive() || isRelativeSpecifier(spec)) return null; - if (spec.startsWith("node:") || spec.startsWith("#")) return null; + if (canonicalBuiltinModule(spec) !== null || spec.startsWith("#")) return null; const npm = resolveNpmImport7(sf.fileName, spec); if (npm === null || !isNpmStaticPackage(npm.packageName)) return null; if (!isJsSourceFileName(npm.typesFile)) return null; diff --git a/packages/compiler/src/frontend/shared.ts b/packages/compiler/src/frontend/shared.ts index 1b35589bd..f9d287d75 100644 --- a/packages/compiler/src/frontend/shared.ts +++ b/packages/compiler/src/frontend/shared.ts @@ -51,6 +51,14 @@ export function isNodeTypesPath(file: string): boolean { return pkg === "@types/node" || pkg === "undici-types"; } +/** True for the declaration surface shipped by the Node-compatible MIDI + * package. ScriptC lowers this package's Input/Output handles natively, so + * its declarations are trusted surface types rather than dynamic-island + * package values. */ +export function isMidiTypesPath(file: string): boolean { + return npmPackageNameOf(file) === "@julusian/midi"; +} + /** The node builtin modules with scriptc lowerings, by CANONICAL (bare) * name — every module answers to both specifier forms ("fs" and "node:fs" * are the same module, like in Node). When the fallback declarations ship, diff --git a/packages/compiler/src/frontend/types.ts b/packages/compiler/src/frontend/types.ts index 1c2cb7fe9..edf75c2c9 100644 --- a/packages/compiler/src/frontend/types.ts +++ b/packages/compiler/src/frontend/types.ts @@ -2,7 +2,7 @@ import * as ts from "./ts7/adapter.js"; import type { IrRecordShape, IrType, IrUnionDef } from "../ir/nodes.js"; import { arrayOf, BOOL, bytesOf, canConvertToDyn, CHILD_T, DATE_T, DYN, F64, funcOf, isSupportedIndexValue, isSupportedMapKey, isSupportedMapValue, isSupportedSetElem, isUnitType, JSVAL, mapOf, NULL_T, PROCSTREAM_T, RUNTIME_EMITTER_CLASS, RUNTIME_ERROR_CLASSES, RUNTIME_STREAM_CLASSES, setOf, STRING, SYMBOL_T, typeEquals, typeKey, UNDEFINED_T, VOID } from "../ir/nodes.js"; -import { isJsSourceFile, isNodeTypesPath } from "./program.js"; +import { isJsSourceFile, isMidiTypesPath, isNodeTypesPath } from "./program.js"; import { accessorSlotProp } from "../ir/nodes.js"; // typeKey moved to ir/nodes.ts (the backend needs it too, for per-type // helper interning); re-exported here so frontend call sites keep their @@ -1807,18 +1807,16 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { return { kind: "dgramSocket" }; } // midi.Input / midi.Output: the node-midi port classes, disambiguated by - // their enclosing ambient module — @julusian/midi's `class Input` / - // `class Output` and the fallback declarations' classes both live inside - // `declare module "midi"` (isDeclaredInAmbientModule answers for the - // "midi" and "node:midi" spellings alike). The names are generic enough - // to collide with user classes, so the ambient-module guard is load-bearing. + // their fallback ambient module or by @julusian/midi's declaration path. + // The names are generic enough to collide with user classes, so this + // provenance guard is load-bearing. if ( psym?.name === "Input" && checker.declarationsOf(psym).some( (d) => (ts.isInterfaceDeclaration(d) || ts.isClassDeclaration(d)) && ctx.isStdlibFile(d.getSourceFile()) && - isDeclaredInAmbientModule(d, "midi"), + (isDeclaredInAmbientModule(d, "midi") || isMidiTypesPath(d.getSourceFile().fileName)), ) ) { return { kind: "midiInput" }; @@ -1829,7 +1827,7 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { (d) => (ts.isInterfaceDeclaration(d) || ts.isClassDeclaration(d)) && ctx.isStdlibFile(d.getSourceFile()) && - isDeclaredInAmbientModule(d, "midi"), + (isDeclaredInAmbientModule(d, "midi") || isMidiTypesPath(d.getSourceFile().fileName)), ) ) { return { kind: "midiOutput" }; diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 77913026d..1e71d6d3a 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -435,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) { diff --git a/packages/runtime/src/scr_midi.c b/packages/runtime/src/scr_midi.c index 170f84d2a..43e1b0e06 100644 --- a/packages/runtime/src/scr_midi.c +++ b/packages/runtime/src/scr_midi.c @@ -1193,7 +1193,8 @@ static void scr_midi_plat_out_send(ScrMidiOutput *s, const unsigned char *bytes, pl = (MIDIPacketList *)storage; } MIDIPacket *pkt = MIDIPacketListInit(pl); - pkt = MIDIPacketListAdd(pl, len + sizeof(MIDIPacketList) + 16, pkt, 0, len, bytes); + 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); 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" From b6a74de2d28b4a1c51fe13942121fdc360557dc6 Mon Sep 17 00:00:00 2001 From: iplanwebsites <787729+iplanwebsites@users.noreply.github.com> Date: Fri, 14 Aug 2026 07:52:10 -0400 Subject: [PATCH 10/11] test(midi): record TS7 order parity --- packages/compiler/test/ts7/baselines/order-parity.json | 6 ++++++ 1 file changed, 6 insertions(+) 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" From 895247d8acb3c4421ccdb9998ebc85604be6d0b0 Mon Sep 17 00:00:00 2001 From: iplanwebsites <787729+iplanwebsites@users.noreply.github.com> Date: Fri, 14 Aug 2026 08:06:56 -0400 Subject: [PATCH 11/11] docs: remove completed MIDI implementation plan --- docs/plans/midi-native-port.md | 285 --------------------------------- 1 file changed, 285 deletions(-) delete mode 100644 docs/plans/midi-native-port.md diff --git a/docs/plans/midi-native-port.md b/docs/plans/midi-native-port.md deleted file mode 100644 index a2c1fdeda..000000000 --- a/docs/plans/midi-native-port.md +++ /dev/null @@ -1,285 +0,0 @@ -# Plan: Native MIDI messaging support for scriptc - -Status: proposed · Owner: compiler+runtime · Target branch: `claude/midi-native-port-plan-1mmmsq` - -## 1. Goal - -MIDI messaging is available to JavaScript today in two shapes: - -- **Web** — the [Web MIDI API](https://www.w3.org/TR/webmidi/): `navigator.requestMIDIAccess()` - yields a `MIDIAccess` with `inputs`/`outputs` maps of `MIDIInput`/`MIDIOutput` - ports; you receive with `input.onmidimessage` (a `MIDIMessageEvent` carrying a - `Uint8Array` `data`) and transmit with `output.send(data, timestamp?)`. -- **Server (Node)** — native addons over the platform MIDI stacks, the de-facto - standard being [`node-midi`](https://github.com/justinlatimer/node-midi) and its - maintained fork [`@julusian/midi`](https://github.com/Julusian/node-midi) - (RtMidi under the hood), plus the ergonomic wrapper - [`easymidi`](https://github.com/dinchak/node-easymidi). Core surface: - `new midi.Input()` / `new midi.Output()`, `getPortCount()`, `getPortName(i)`, - `openPort(i)`, `openVirtualPort(name)`, `input.on('message', (dt, msg) => …)`, - `output.sendMessage([status, d1, d2])`, `closePort()`, `ignoreTypes(...)`. - -scriptc compiles TS/JS to **native executables** (macOS/Linux/Windows) and to -**WASI** wasm. There is no MIDI surface today. This plan ports **MIDI messaging -core features** — enumerate ports, open input/output (incl. virtual ports), -receive time-stamped messages via an event, and send raw messages — to -scriptc's native runtime, exposed through a Node-shaped `node:midi` module -surface that is differential-testable against a real Node baseline. - -### Scope - -**In scope (core messaging):** -- Port enumeration: `getPortCount()`, `getPortName(index)`. -- Input: `new Input()`, `openPort(i)`, `openVirtualPort(name)`, `closePort()`, - `on('message', cb)` / `once('message', cb)`, `ignoreTypes(sysex, timing, sense)`. -- Output: `new Output()`, `openPort(i)`, `openVirtualPort(name)`, `closePort()`, - `sendMessage(number[] | Uint8Array)`. -- Message payloads carry raw bytes (Note On/Off, CC, Program Change, Pitch Bend, - channel pressure, and SysEx as a byte run) — the runtime is byte-transparent; - it does not parse or validate message semantics. A thin optional decode helper - (note/CC accessors) may follow but is **not** core. -- Delta-time (seconds since the previous message on that input), matching - node-midi's `message` callback first argument. - -**Out of scope (this port):** -- Browser Web MIDI in the WASI target (WASI Preview 1 has no MIDI capability — it - fences, see §6). The *API shape* is modeled to stay portable, but the wasm - target refuses MIDI at compile time like it does sockets. -- MIDI file (SMF) parsing, sequencing/clock scheduling, SysEx device protocols, - MIDI 2.0 / UMP, virtual-MIDI on Windows (WinMM has no user-space virtual ports). -- `easymidi`-style semantic event names (`noteon`, `cc`, …). Those can be a - pure-TS layer on top later; the native core stays raw-byte. - -### Why a Node-module shape (not a Web-MIDI global) - -The corpus is **differential against Node**: every program runs under Node and as -a native binary and must match stdout/stderr/exit byte-for-byte (AGENTS.md). Node -has no built-in MIDI, but `@julusian/midi` provides one under the same -`import midi from "midi"` name we target, **and** it supports `openVirtualPort`, -which gives us a hardware-free deterministic loopback for tests (open a virtual -output, open an input on that virtual port, send, receive, compare). Modeling on -the Web MIDI global would have no Node baseline to diff against. So: `node:midi` -module surface, API-compatible with node-midi/@julusian/midi. - -## 2. How scriptc adds a native module surface (the dgram template) - -`node:dgram` is the closest existing analog: an event-driven, message-oriented -device/socket handle whose reads feed the event loop. A MIDI input is -structurally the same (a pollable source delivering discrete messages), and a -MIDI output is like a connected UDP socket (`sendMessage` ≈ `send`). Every -touchpoint below is mirrored from dgram. - -| Concern | dgram implementation | MIDI equivalent to build | -| --- | --- | --- | -| Ambient types | `declare module "dgram"` / `"node:dgram"` in `ambient/scriptc-node-fallback.d.ts` | `declare module "midi"` / `"node:midi"` | -| IR handle type | `dgramSocket` in `ir/nodes.ts` (kind union, `HANDLE_KINDS`, `DGRAMSOCK_T`, refcount predicate, `moduleUsesDgram`) | `midiInput`, `midiOutput` kinds + `moduleUsesMidi` | -| Type mapping | `types.ts` maps ambient `Socket` (declared in `dgram`) → `{kind:"dgramSocket"}` | ambient `Input`/`Output` → `midiInput`/`midiOutput` | -| Lowering spoke | `lowering/lower-dgram.ts` (module fns + method calls + event listeners), dispatched from `lowerer.ts` & `lower-calls.ts` | new `lowering/lower-midi.ts`, dispatched the same way | -| Module registry | `SUPPORTED_BUILTIN_MODULES` in `frontend/shared.ts`; builtin set in `frontend/npm.ts`; keys in `surfaces.ts` | add `"midi"` to all three | -| Runtime C | `runtime/src/scr_dgram.c` over the `scr_platform.h` poller seam | new `runtime/src/scr_midi.c` (+ platform backends) | -| Build inclusion | conditional TU behind `moduleUsesDgram`/`net` in `backend/cc.ts`, flagged from `index.ts` | conditional TU behind `moduleUsesMidi` | -| WASI fence | `index.ts` refuses `dgram.`/`dgramSocket` on WASI with SC3002 | refuse `midi`/`midiInput`/`midiOutput` on WASI | -| Tests | `tests/fixtures/dgram/cases/*`, `tests/corpus/*dgram*`, `tests/harness/dgram.test.ts` | `tests/fixtures/midi/*`, corpus, `tests/harness/midi.test.ts` | -| Docs | platforms / limitations / dependencies pages under `docs/` | same pages + a MIDI note | -| Manifest | projected into `surface-manifest.json` via `pnpm manifest` | regenerate | - -## 3. Proposed API surface (ambient `.d.ts`) - -Mirrors node-midi/@julusian/midi so the Node differential baseline is a real, -installable package. - -```ts -declare module "midi" { - export class Input { - getPortCount(): number; - getPortName(port: number): string; - openPort(port: number): void; - openVirtualPort(name: string): void; // POSIX only; fences on Windows - closePort(): void; - isPortOpen(): boolean; - // sysex, timing (clock), activeSensing — each true = ignore (node-midi default true,true,true) - 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; // POSIX only; fences on Windows - closePort(): void; - isPortOpen(): boolean; - sendMessage(message: number[] | Uint8Array): void; - } -} -declare module "node:midi" { export * from "midi"; } -``` - -Constrained call forms (the surfaces.ts stance): `sendMessage` takes an array -literal or a `Uint8Array`; `on`/`once` accept only the `"message"` event with a -`(deltaTime, message)` void arrow/function of ≤2 params (the -`lowerCallbackArg` pattern from lower-dgram). Anything else fences -member-qualified with a named hint (never a silent drop). - -## 4. Runtime design (`scr_midi.c` + platform backends) - -### Handle model -`ScrMidiInput` and `ScrMidiOutput` are refcounted handles like `ScrDgramSocket`. -An **open input** holds the loop alive (a live source, like a bound socket); -an output does not (send is fire-and-forget). Both are freed on `closePort()` -+ last ref drop; the unit forgets any registered fd before closing it. - -### Event-loop integration (the `scr_platform.h` seam) -The runtime already exposes a readiness poller: `scrp_poller_new`, -`scrp_watch_read(fd,…)`, `scrp_forget(fd)`, `scrp_drain(...)` (kqueue/epoll/wsapoll). -The loop (`scr_async.c`) will call a new `scr_midi_dispatch()` each turn, exactly -as it calls `scr_dgram_dispatch()`. - -- **Linux — ALSA sequencer (`libasound`).** `snd_seq_open`, create a port, - subscribe. ALSA exposes pollable fds via `snd_seq_poll_descriptors()` → - register each with `scrp_watch_read`; on readiness `snd_seq_event_input()` and - translate seq events to raw MIDI bytes (`snd_midi_event_decode`). Virtual ports - are native (an ALSA port other clients connect to). **Container note:** ALSA - dev headers are absent here (`/usr/include/alsa/asoundlib.h` missing) and CI has - no sound stack — the Linux backend is written behind the seam and validated on a - host with ALSA; loopback tests use the virtual-port pair so no hardware is needed. -- **macOS — CoreMIDI (`-framework CoreMIDI`).** `MIDIClientCreate`, - `MIDIInputPortCreate` with a read callback that fires **on a CoreMIDI thread**. - Bridge to the loop with a self-pipe/`eventfd`: the callback enqueues the packet - on a mutex-guarded ring and writes one byte; the pipe read-end is registered - with `scrp_watch_read`, so `scr_midi_dispatch` drains the ring on the loop - thread and fires JS listeners there (never call into the runtime from the - CoreMIDI thread). `MIDISourceCreate`/`MIDIDestinationCreate` back virtual ports. -- **Windows — WinMM (`winmm.lib`).** `midiInOpen` with a callback (also - off-thread → same self-pipe bridge over `scr_loop_wsapoll.c`), `midiInAddBuffer` - for SysEx, `midiOutShortMsg`/`midiOutLongMsg` to send. **No virtual ports** on - WinMM → `openVirtualPort` fences at runtime with a clear error (documented - divergence; WinRT MIDI is a later option). - -### Delta-time -Each input tracks the timestamp of its previous delivered message and reports -`deltaTime` in **seconds** (node-midi's unit). First message after open reports -`0`. Use the platform timestamp where available (CoreMIDI packet time, ALSA -tick/real-time), else the loop clock. - -### ABI contract (lowering ⇄ runtime) — keep parallel prototypes integrable -The lowering emits `IrLibFn` calls; the runtime implements these exact symbols. -Draft (finalize in the front-matter task, then freeze for the runtime task): - -| lib fn id | C symbol | signature (conceptual) | -| --- | --- | --- | -| `midi.newInput` | `scr_midi_input_new` | `() -> ScrMidiInput*` | -| `midi.newOutput` | `scr_midi_output_new` | `() -> ScrMidiOutput*` | -| `midi.portCount` | `scr_midi_port_count` | `(handle, isInput) -> f64` | -| `midi.portName` | `scr_midi_port_name` | `(handle, idx) -> ScrString*` | -| `midi.openPort` | `scr_midi_open_port` | `(handle, idx) -> void` | -| `midi.openVirtual` | `scr_midi_open_virtual` | `(handle, ScrString* name) -> void` | -| `midi.closePort` | `scr_midi_close_port` | `(handle) -> void` | -| `midi.isOpen` | `scr_midi_is_open` | `(handle) -> bool` | -| `midi.ignoreTypes` | `scr_midi_ignore_types` | `(input, b,b,b) -> void` | -| `midi.send` (array) | `scr_midi_send_array` | `(output, ScrArr* number[]) -> void` | -| `midi.send` (bytes) | `scr_midi_send_bytes` | `(output, ScrBytes* Uint8Array) -> void` | -| `midi.onMessage` | `scr_midi_on_message` | `(input, closure, ScrMidiMsgFn fn, once) -> void` | -| `midi.dispatch` | `scr_midi_dispatch` | loop hook (internal, static) | - -Message bytes are delivered to the JS closure as a `number[]` (the node-midi -shape) built by the runtime, with `deltaTime` as the first f64 argument. - -**Reconciled during prototyping (both mirror the dgram spoke exactly):** -- `sendMessage` lowers to two marshalers picked by argument type — - `scr_midi_send_array` for a `number[]` and `scr_midi_send_bytes` for a - `Uint8Array` — over a raw `scr_midi_send(out, bytes*, len)` primitive - (parallel to dgram's `send_str`/`send_bytes`). -- `on/once('message')` passes an adapter-thunk pointer selected by the - listener's declared param count (`scr_midi_msg_thunk0/1/2`), because a user - closure's compiled C arity (0/1/2 params) can't be invoked through one fixed - signature — exactly dgram's `msg_thunk0/1` mechanism. -- The runtime registers its loop hook via `scr_loop_set_midi(...)` from - `scr_midi_install()`; generated `main` must call `scr_midi_install()` under - `moduleUsesMidi`, like `scr_dgram_install()`. -- Refcount symbols the C-emission layer calls: `scr_midi_input_retain/release`, - `scr_midi_output_retain/release`, and their `_v` void* variants. - -## 5. Testing strategy (hardware-free, differential) - -The blocker for MIDI tests is "no hardware, must match Node byte-for-byte." -Solved by **virtual-port loopback**, supported by both `@julusian/midi` (Node -baseline) and the POSIX runtime backends: - -1. Node baseline fixture uses `import midi from "midi"` (dev-dep `@julusian/midi`). -2. Program opens a virtual **Output** named e.g. `scriptc-test`, opens an - **Input** and connects it to that virtual port, sends a deterministic - sequence, prints each received message (and a fixed/synthetic deltaTime so - output is stable), then closes. -3. Harness runs it under Node and native; stdout must match. - -Determinism guards: print `message` bytes only (not wall-clock deltaTime — round -or replace with a monotonic counter in the test program); enumerate ports by a -name filter, not index, since index ordering varies. Gate the corpus case on -platform capability (POSIX virtual ports) like other capability-gated cases. -Windows and CI-without-ALSA lanes get compile-coverage + fence tests only. - -Also: fence/diagnostics snapshot tests (unsupported event names, bad -`sendMessage` args, `openVirtualPort` on Windows, any MIDI use on WASI → SC3002). - -## 6. WASI / web boundary -WASI Preview 1 has no MIDI capability. Follow the socket precedent in -`index.ts`: refuse `midi`/`midiInput`/`midiOutput` at compile time for the wasm -target with SC3002 and a message pointing at the platform-support page. Document -that Web MIDI (browser) is a separate runtime not covered by the WASI target. - -## 7. Risks & open questions -- **ALSA/CoreMIDI/WinMM link flags** must be added conditionally only when a - program uses MIDI (don't burden every binary). Mirror the fetch/curl - conditional-link precedent in `cc.ts`. -- **Off-thread callbacks** (CoreMIDI/WinMM) must never touch the runtime heap; - the self-pipe bridge is mandatory. Reference-count audit (the sanitized lane) - will catch violations. -- **CI has no ALSA/sound** → Linux native MIDI validated on a real host; CI keeps - fence + compile tests. Flag this to maintainers. -- **deltaTime nondeterminism** → tests must not print raw timing. -- Decide whether `getPortCount`/`getPortName` also work on a fresh handle before - `openPort` (node-midi allows it — enumerate then open). Plan: yes. - ---- - -## TODO checklist - -### Phase 0 — Design freeze -- [ ] Confirm API shape against installed `@julusian/midi@3.8.1` (method names, arg order, defaults). -- [ ] Freeze the lowering⇄runtime ABI table (§4) so parallel work integrates. - -### Phase 1 — Compiler front (ambient + IR + types) -- [ ] Add `declare module "midi"` and `"node:midi"` to `ambient/scriptc-node-fallback.d.ts`. -- [ ] Add IR handle kinds `midiInput`/`midiOutput` in `ir/nodes.ts`: kind union, `HANDLE_KINDS`, `*_T` consts, refcount predicate, `moduleUsesMidi`, type-name mapping. -- [ ] Map ambient `Input`/`Output` (declared in `midi`) → handle kinds in `frontend/types.ts`. -- [ ] Register `"midi"` in `SUPPORTED_BUILTIN_MODULES` (`frontend/shared.ts`) and the builtin set in `frontend/npm.ts`. - -### Phase 2 — Lowering spoke -- [ ] Create `lowering/lower-midi.ts`: constructors (`new Input()`/`new Output()`), methods (`getPortCount`/`getPortName`/`openPort`/`openVirtualPort`/`closePort`/`isPortOpen`/`ignoreTypes`/`sendMessage`), and the `on`/`once` `"message"` listener (reuse the `lowerCallbackArg` shape). -- [ ] Add `midi: {}` key + fence hint in `lowering/surfaces.ts`. -- [ ] Dispatch the spoke from `lowerer.ts` and `lower-calls.ts` (module calls + method calls on the handle receivers), mirroring `lowerDgramDnsModuleCall`. -- [ ] Statement-position + arg-shape fences with named hints (no silent drops). - -### Phase 3 — Runtime C -- [ ] `runtime/src/scr_midi.c`: handle structs, refcount, loop liveness, `scr_midi_dispatch`, the ABI symbols from §4. -- [ ] Linux ALSA-seq backend (`snd_seq_*`, poll descriptors → poller, virtual ports). -- [ ] macOS CoreMIDI backend (client/ports, self-pipe bridge from the CoreMIDI thread, virtual sources/destinations). -- [ ] Windows WinMM backend (`midiIn*`/`midiOut*`, self-pipe bridge, `openVirtualPort` runtime fence). -- [ ] Wire `scr_midi_dispatch()` into the loop in `scr_async.c`. - -### Phase 4 — Build wiring -- [ ] `moduleUsesMidi` flag threaded from `index.ts` into the backend options. -- [ ] Conditional TU compilation of `scr_midi.c` in `backend/cc.ts`, with conditional platform link flags (`-lasound` / `-framework CoreMIDI` / `winmm.lib`). -- [ ] WASI fence (SC3002) for any MIDI surface in `index.ts`. - -### Phase 5 — Tests & docs -- [ ] `tests/fixtures/midi/cases/*`: virtual-port loopback differential program(s); add `@julusian/midi` dev-dep for the Node baseline. -- [ ] `tests/harness/midi.test.ts` + a `tests/corpus/*` case (capability-gated). -- [ ] Diagnostics snapshots: unsupported event, bad `sendMessage`, `openVirtualPort` on Windows, MIDI on WASI. -- [ ] Docs: platform-support, limitations, dependencies pages; CHANGELOG entry. -- [ ] Regenerate `surface-manifest.json` (`pnpm manifest`). - -### Phase 6 — Validation -- [ ] `pnpm -r build` clean; `pnpm lint` clean. -- [ ] `pnpm test:sandbox` (plain + sanitized) green; native MIDI loopback validated on a host with ALSA/CoreMIDI.