@codex-music/audio-contracts
The shared types: ParamSchema, AudioKernel, KernelIO, AudioEvent, GraphPlan, and the normalize math every surface agrees on.
Four small files, zero runtime dependencies, and every other package in the system depends on them. This is where the web UI, the worklet transport, the native shell, and the generated C all meet. Nothing here does any work at runtime except a sort and some arithmetic — the value is that everyone agrees.
Parameters
A ParamSchema is a kernelId (matching the ScriptC profile's ABI prefix, e.g.
"scfx_") and a readonly ordered list of ParamDescriptors:
import type { ParamDescriptor, ParamSchema, ParamType } from "@codex-music/audio-contracts";
ParamType is "float" | "enum" | "bool". A descriptor carries a dotted path
(which doubles as the stable automation id), a label, a group, the type,
and optional min, max, step, default, units, and options.
bridgeParams(schema) returns BridgeParam[] — each descriptor plus an
index assigned by array position. That index is the order a host must register
parameters in, which is why schemas are append-only in practice: insert a
parameter in the middle and every saved automation lane after it shifts.
Why the normalize math lives here
Hosts speak normalized 0..1. Your kernel speaks engine values — hertz, milliseconds, decibels. Two functions cross that gap:
import { denormalize, normalize } from "@codex-music/audio-contracts";
const value = denormalize(descriptor, 0.5); // number | string | boolean
const norm = normalize(descriptor, 2500); // 0..1
The details are opinionated and load-bearing. denormalize clamps before
scaling, quantizes with Math.round(v / step) * step when a step exists, maps
bool at the >= 0.5 threshold, and rounds enum to the nearest option index.
normalize inverts each case, returning 0 when max === min rather than
dividing by zero.
That specificity is the point. @codex-music/plugin-shell-kit emits a C
translation of exactly this math into codex_plugin_params.h — including
codex_js_round defined as floor(x + 0.5), because JavaScript's Math.round
rounds halves toward positive infinity and C's round() does not, and including
a hard requirement that the header compile with -ffp-contract=off, because JS
never fuses a + b * c into an FMA and neither may the C side. A knob at the
same position produces the same number in a browser and in a DAW. That is a
claim about two implementations of one algorithm, so the algorithm is written
once and mechanically transcribed.
Kernels
export interface KernelIO {
readonly inputs: ReadonlyArray<Float32Array>;
readonly outputs: ReadonlyArray<Float32Array>;
readonly frameCount: number;
}
export interface AudioKernel<State> {
init(sampleRate: number): State;
process(state: State, io: KernelIO, params: Readonly<Record<string, number>>, events: readonly AudioEvent[]): void;
reset(state: State): void;
}
Planar channels, written in place. inputs is empty for pure generators.
params is a flat frozen record keyed by dotted path. This is the
authoring-time TypeScript view — ScriptC's --lib --profile step lowers a
kernel to the real native and WASM ABI, which is bytes-based today and moves to
borrowed planar f32 spans later.
Events
export type AudioEvent =
| { kind: "param"; frameOffset: number; path: string; value: number }
| { kind: "noteOn"; frameOffset: number; note: number; velocity: number }
| { kind: "noteOff"; frameOffset: number; note: number };
Three cases, integer-timed. frameOffset is a sample frame within the current
render block, never float seconds — float time would introduce a rounding
difference between hosts before a single sample was computed.
sortEvents(events) orders by frameOffset, then puts param before noteOn
and noteOff at the same frame. A note that starts on the frame its filter
cutoff changes must hear the new cutoff, not the old one, and both hosts must
agree about that without coordinating. It returns a new array; the input is
readonly and untouched.
Engine
EngineBackend is "tone" | "shared-wasm" | "hybrid" | "native" | "offline-native". GraphPlan is { schema, nodes } where each node is
{ kind, params } — a compact, order-preserving encoding generalizing the
listening lab's byte-plan. AudioEngineOptions is { backend, sampleRate?, graph }. AudioEngine is the host-neutral surface — send, setParam,
noteOn, noteOff, render, start, close — plus a readonly backend
field so code can tell where it ended up. AudioBufferLike is the minimal
render() result: length, numberOfChannels, sampleRate, getChannelData.
Packaging
Zero runtime dependencies, "type": "module", and four subpath exports
alongside the root: ./params, ./events, ./kernel, ./engine. Import the
narrow path when you want a file to visibly depend on one concept only — a
kernel source importing @codex-music/audio-contracts/kernel cannot
accidentally reach for engine types it has no business touching.
Most applications never import this package directly. @codex-music/audio-sdk re-exports all of it.