@codex-music/audio-worklet

The browser transport: the ScriptC WASM reactor loader, the WASI shim, and the result-bytes convention.

createWorkletEngine() throws today. The landed piece is the reactor loader — loadReactor, wasiImports, readResultBytes — extracted from two apps that had been carrying byte-identical copies of it. Running a reactor inside an actual AudioWorkletProcessor is still ahead.

This package is the browser side of the transport story: the AudioWorklet host that will drive backend: "shared-wasm", and the loader that gets a ScriptC-compiled WASM reactor instantiated and callable in the first place.

The reactor loader

ScriptC emits wasm32-wasi reactor modules — libraries, not programs. They declare WASI imports for startup even though they never meaningfully use them, so instantiating one requires satisfying those imports before any exported function can be called.

import { loadReactor, readResultBytes } from "@codex-music/audio-worklet";
import type { ReactorExports } from "@codex-music/audio-worklet";

interface Fx1Exports extends ReactorExports {
  scfx_init(): void;
  scfx_render_variation(input: number, length: number, rate: number, variation: number, result: number): void;
}

const { exports, wasiImportNames } = await loadReactor<Fx1Exports>("/fx1-chain.wasm");
exports.scfx_init();

loadReactor fetches with cache: "no-store", compiles, builds the WASI shim from the module's own declared import list, instantiates, binds reactor memory to the shim, and calls the _initialize export when the build emitted one. It returns the typed exports plus wasiImportNames — every module.name import descriptor in declaration order, which exists purely as a load-time diagnostic when a build's imports drift from what you expected.

Calling the profile's own <prefix>_init afterward is your job. Init and render symbols are profile-specific by construction, so the loader cannot generalize them.

The WASI shim

wasiImports(module) builds the import object by walking the module's real imports rather than guessing at a fixed table. The behavior is minimal and deliberate: args_sizes_get and environ_sizes_get write zeros, clock_time_get writes the host Date in nanoseconds, fd_write reports zero bytes written so stdout and stderr are discarded rather than surfaced, and proc_exit throws — a reactor calling it during a library-mode export is a bug, not a shutdown.

Two things are rejected loudly instead of stubbed: any import whose kind is not "function", and any WASI name the shim does not recognize. A silently stubbed import that returns zero is a mystery wrong-answer later; a thrown error at instantiation names the symbol.

The returned WasiShim is { imports, bindMemory, names }. bindMemory must be called with the instance's exported memory before any import fires, since args_sizes_get and friends write through it — loadReactor handles that ordering for you.

ReactorExports and the result convention

export interface ReactorExports extends WebAssembly.Exports {
  memory: WebAssembly.Memory;
  malloc(size: number): number;
  free(pointer: number): void;
}

Every reactor exports at least these three; extend the interface with your profile's own symbols. Memory management is explicit and yours: allocate input buffers, write into memory.buffer, call, read the result, free everything.

Every bytes-returning ScriptC library export currently uses the same convention — the caller passes a pointer to an eight-byte result slot, and the export writes a [pointer, length] pair of little-endian u32s into it.

const resultSlots = exports.malloc(8);
exports.scfx_render_variation(inputPtr, inputLen, 48000, 16, resultSlots);
const pcm = readResultBytes(exports, resultSlots);
exports.free(resultSlots);

readResultBytes reads that pair and slices out an owned copy of the bytes it names, so the result survives any later allocation that grows and detaches the memory buffer. It does not free resultSlots — the caller allocated it and frees it alongside its other arguments.

Where this came from, and where it goes

apps/listening-lab/browser/wasm-engine.ts and vertical-slice's since-deleted browser/wasm-kernel.ts both carried this WASI shim, instantiate step, and result reader. They were byte-identical, and this package became their single home. Vertical-slice's browser lane now runs through createWorkletEngine, which instantiates the reactor inside a real AudioWorkletProcessor; listening-lab keeps a thin main-thread adapter (its graph ABI needs fresh per-render input blobs, which the worklet render protocol doesn't carry yet) built on the shared callReactorRender orchestration. This was a mechanical extraction, not a redesign.

What is still ahead

createWorkletEngine(binding, schema) — taking a WorkletKernelBinding of { wasmUrl, abiPrefix } and a ParamSchema, returning an AudioEngine — throws. Its real implementation is the schema-driven loader that instantiates the reactor inside an AudioWorkletProcessor and speaks the AudioEvent ABI over its MessagePort. That is the piece that turns backend: "shared-wasm" into working audio, and it is the next thing on the browser side.

See audio-contracts for the event ABI it will carry and audio-native for the corresponding native transport.