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