@codex-music/audio-native

The native and offline backends: a compiled-TypeScript AudioContext control API over the native ScriptC audio engine.

The native side of the transport. This package is a compiled-TypeScript control API shaped like Web Audio, sitting over a native audio engine — the thing the plugin shell's processBlock pulls from, and the thing CI renders through when a test needs deterministic audio with no browser in the room.

Compiled TypeScript, not a binding

api.ts is not a Node addon wrapper. It is restricted TypeScript that ScriptC compiles to native code, and its bottom edge is a set of ambient declarations:

declare function nativeAudioContextCreate(sampleRate: number, channels: number): number;
declare function nativeAudioCreateOscillator(context: number): number;
declare function nativeAudioScheduleParam(
  context: number, node: number, param: number,
  eventType: number, value: number, when: number,
): number;

ffi.json maps each of those sixteen declarations to a C symbol with its parameter and return kinds — nativeAudioContextCreatesc_audio_context_create, (f64, f64) → f64 — and names the archive that provides them, build/libscriptc_audio_probe.a, built from native/audio_context.c. Handles are opaque numbers across the boundary; nothing structured crosses it.

The consequence worth internalizing: the control API is written in the same language as your kernel and your UI, and it is not interpreted at runtime. It compiles.

The AudioContext-shaped surface

The exported classes will look familiar, because looking familiar is the point:

import { AudioContext, OfflineAudioContext, encodeAudioBufferToWav } from "@codex-music/audio-native";

const context = new OfflineAudioContext({ numberOfChannels: 2, length: 480, sampleRate: 48000 });
const osc = context.createOscillator();
const gain = context.createGain();

osc.connect(gain).connect(context.destination);
gain.gain.setValueAtTime(0.25, 0);
gain.gain.linearRampToValueAtTime(0.5, 0.01);
osc.start(0);

const buffer = await context.startRendering();
const wav = encodeAudioBufferToWav(buffer);

AudioParam carries value plus setValueAtTime, linearRampToValueAtTime, and exponentialRampToValueAtTime, each returning this for chaining. AudioNode.connect returns its destination and refuses to join nodes from different contexts. GainNode and OscillatorNode expose gain and frequency. AudioBuffer implements the planar subset — length, numberOfChannels, sampleRate, duration, getChannelData, copyFromChannel, copyToChannel — with real validation (positive integer lengths, one or two channels, sample rates between 8000 and 192000). OfflineAudioContext.startRendering() returns a Promise<AudioBuffer> and rejects if called twice, and AudioContext.renderMetrics(frames) returns { peak, rms } for tests that only need to know a graph produced signal. encodeAudioBufferToWav writes a canonical interleaved PCM16 RIFF/WAVE file.

Note that AudioContext.resume() throws with a clear message on non-macOS platforms: real-time device playback is macOS-only in the current probe.

What the probe proves

./verify.sh builds everything and byte-compares fixed expected output. It runs four render lanes — offline-test and offline-compat against both the LLVM and the C backend — plus a WAV generation check and a webaudio-node contract test. Every expectation is a literal string in the script:

single 0.2500 0.1768 1.0000
scheduled 0.5000 0.2500 0.0100

and the compat lane pins linear automation to 0.3125 -0.4375 0.5000 and exponential to 0.3536 -0.7071 1.0000. The claim being verified is narrow and strong: two independent compilation backends produce bit-identical numbers for the same graph, and the automation ramps behave exactly as the Web Audio formulas specify. That is the foundation the parity story stands on — see Parity and determinism.

Backends it powers

Two EngineBackend values route here. "native" is the plugin shell path: the DAW's processBlock pulls the engine with the plugin's ScriptC-compiled kernel archive linked in, and audio never touches the webview or any JavaScript. Host MIDI and sample-accurate automation are translated into the common AudioEvent ABI first, so the event stream reaching the kernel is the same shape the browser produces. "offline-native" is the CI and batch path — deterministic renders, no device, no clock.

Roadmap

The package description calls itself a probe that "grows into the quantum engine per the M1-M4 roadmap," and the milestones are explicit. M1 moves to 128-frame f32 planar rendering with reusable scratch arenas, adds disconnect, fan-out caching, topological validation, oscillator waveforms, constant source and biquad, and requires zero allocations during a render quantum on both backends, plain and sanitized. M2 integrates one production real-time device layer with an SPSC command queue, state transitions, sample-rate negotiation and underrun counters, exiting on an hour-long clean stress run. M3 adds the compiler effect/profile check and the borrowed f32 slice ABI, emitting one direct native kernel per statically imported processor, and exits when user TS transforms a signal in real time with no callback allocation and matches offline output sample-for-sample. M4 is Linux and Windows device backends, hot-plug handling, denormals, and SIMD where measurements justify it.

The plugin shell that consumes this today targets V1 scope: VST3 plus standalone on macOS.