@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 actualAudioWorkletProcessoris 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.