Components
Time and sequencing
Blocks
A compressor as a device you can plug in: its state, a panel that draws it, a Web Audio engine that plays it and the tools an agent drives it with, each its own file.
Audio UI
"use client";
import { createSource, type Source } from "@audio-ui/react";A device is not a widget with audio inside. It is four pieces that share one description, so a host can take the ones it needs: a DAW that has its own engine keeps the state and the panel, an agent keeps the state and the tools, and a page that only wants sound takes them all.
Each file installs in components/audio-ui/devices/compressor/; the tools come from @audio-ui/core/device.
A state is a plain object keyed by parameter id: numbers and booleans, JSON-safe. The device holds no instance, so a host stores it, saves it with its project and sends it where it likes.
import { compressorDevice } from "@/components/audio-ui/devices/compressor/device";
const state = compressorDevice.defaults();
// { enabled: true, thresholdDb: -18, ratio: 4, attackMs: 5, releaseMs: 80,
// kneeDb: 6, autoMakeup: true, makeupDb: 0, mix: 100 }
compressorDevice.set(state, "thresholdDb", -200); // a new state, clamped to -60
compressorDevice.sanitize(JSON.parse(saved)); // a loaded project, repairedIts id, audioui.compressor, is what a project stores: not the panel that draws it, which can be restyled without rewriting anyone's session.
Controlled: it keeps no state, it draws the one it is given and returns the whole new state.
import { CompressorPanel } from "@/components/audio-ui/devices/compressor/panel";
<CompressorPanel
level={inputLevel} // optional: a live source in dB, a dot rides the curve
onValueChange={setState}
value={state}
/>;It draws the curve the state plays: the threshold, the level going in on it, and the dashed unity line. Below the threshold the line sits above unity by the make-up gain.
import { createCompressorEngine } from "@/components/audio-ui/devices/compressor/engine";
const engine = createCompressorEngine(ctx, state);
source.connect(engine.input);
engine.output.connect(ctx.destination);
engine.update(nextState); // parameters ramp: no click
engine.reduction; // dB being taken off, read per frame
engine.dispose();What goes on each node comes from compressorSettings(state), a pure function you can read and test without an AudioContext. Two things the browser's compressor node does that the engine accounts for:
autoMakeup off the engine takes it back out and applies makeupDb; with it on, the node's is kept.Working, it delays the signal by 6 ms, the look-ahead of the node; the dry path waits as long, so the mix stays aligned.
The tools are derived from the device's parameters, with a JSON Schema input, so any agent can use them: they are not tied to an SDK.
import { deviceTools } from "@audio-ui/core/device";
const [set, reset] = deviceTools(compressorDevice);
// set.name === "audioui_compressor_set"
const { state: next, summary } = set.run(state, { thresholdDb: -300, ratio: "high" });
// summary: "Threshold: -18.0 dB to -60.0 dB (brought into range); ratio: refused, not a number"The ranges a model is told are the ones the device enforces. A value out of range is brought in and reported, a value of the wrong type is refused by name and never replaced by a default, and a call never throws: the summary is the answer to give back to the model.
defineDevice is the one extension point and the core keeps no list: a device you define gets the same panel, tools and bindings as this one.
import { defineDevice } from "@audio-ui/core/device";
import { DevicePanel } from "@/components/audio-ui/device-panel";
const tremolo = defineDevice({
id: "acme.tremolo",
name: "Tremolo",
description: "Moves the level up and down.",
parameters: [
{ id: "on", kind: "toggle", label: "Active", default: true },
{ id: "rate", label: "Rate", min: 0.5, max: 20, scale: "log", step: 0.5, unit: "Hz", default: 5 },
{ id: "depth", label: "Depth", min: 0, max: 100, step: 1, unit: "%", default: 50 },
],
});
<DevicePanel device={tremolo} onValueChange={setState} value={state} />;DevicePanel makes a knob per number, a toggle per switch and a row of buttons per choice (a waveform, a filter type) from the descriptors, with their ranges, steps, scales and readouts. A face of your own (a curve, a spectrum) is optional.
An instrument is the same with kind: "instrument": its parameters shape the timbre, and its engine adds noteOn and noteOff next to update. The Pocket Synth block is the example: its panel is controlled and sends notes out, so it plays through any engine.
A host that already has its audio engine, a DAW with one in a worklet for instance, keeps the state and the panel and translates the state into its own messages. A DeviceBinding is just that:
import type { DeviceBinding } from "@audio-ui/core/device";
const binding: DeviceBinding<CompressorState> = {
update: (state) => engine.post({ type: "compressor", ...compressorSettings(state) }),
dispose: () => engine.post({ type: "compressor-off" }),
};The curve is the static curve of the browser's compressor node: the level once the gain has settled. A real detector follows a signal with an attack and a release, so what you hear sits a little above it: measured in Chrome with a 440 Hz sine, about 0.5 dB at the defaults and up to 1.3 dB with a slow attack and a short release. Below the threshold it is exact.
Every control is a native input: the knobs are sliders named after their parameter, with their value and unit as the text value ("Threshold, -18.0 dB"), and the switches are toggle buttons. The panel is a group named after the device, the curve is decoration around them, and everything takes the keyboard.