Architecture
July 11, 2026 · View on GitHub
See also: Plugins
Files
| File | Role |
|---|---|
core.js | Engine — decode, paginate, plugin registry, instance factory, page I/O. The only required file. |
stats.js | Block-level stat engine — min/max/energy/clip/dc per BLOCK_SIZE-sample block during decode. Powers waveform, loudness, stat queries without touching PCM. |
cache.js | Page cache — LRU eviction to OPFS, on-demand restore. Large files stay playable without exhausting RAM. |
plan.js | Edit pipeline — non-destructive edit list, plan builder, stream renderer. a.gain(-3).trim() becomes a declarative plan materialized on read. |
audio.js | Full bundle — core + stats + cache + plan + all plugins. The default import. |
fn/*.js | Plugins — each file is one op, stat, or method. Self-contained, independently importable. |
bin/cli.js | CLI — arg parsing, plugin auto-discovery, batch/glob/macro/playback. |
Three import paths
import audio from 'audio' // full bundle — all ops, stats, cache
import audio from 'audio/core.js' // bare engine — no ops, no cache
import gain from 'audio/fn/gain.js' // individual plugin
Worker engine
audio/worker runs the whole library inside a Worker; the main thread holds a thin
facade (imports none of the engine). The edit list is the wire protocol — ops post
[type, opts] tuples, the worker replays them through the real engine, so undo,
serialization and stream≡read carry across the boundary unchanged. Op methods are
proxied against the worker's live registry (plugins registered worker-side appear
automatically); read/encode results transfer zero-copy; facades sharing a worker
reference each other by instance id (a.mix(b)). Custom worker entry = your plugin
imports + import 'audio/worker' (the file self-hosts in worker scope). Playback crosses
the boundary too — a.play() streams to an AudioWorklet (browser, no SharedArrayBuffer)
or the @audio/speaker sink (Node); breakpoint curves {t, v} replace function params,
which can't serialize.
Motivation: DSP on the calling thread competes with the ~23ms/block playback budget, so
heavy chains (compressor, denoise, declick) jank the UI. The worker moves render
off-thread while the facade keeps the media-element API; live playbackRate runs
fn/varispeed.js (shared with the local player) for parity. Main-thread stays the
zero-setup default — the worker is the recommended path for editor UIs. Validated against
wavearea, which runs its editor on audio/worker.
Stream-first
No operation touches full PCM at once. Audio is stored in pages. Decode streams pages progressively. Every output — stream(), play(), save(), stat() — walks pages one chunk at a time through the edit pipeline. read() materializes into memory but still processes through the same chunk pipeline internally.
A 2-hour file never needs 2 hours of float arrays in memory unless you read() the whole thing.
Non-destructive editing
Ops don't mutate source pages. a.gain(-3).crop({at: 1, duration: 5}) pushes two entries to a.edits. Source data stays immutable. The edit list replays on demand — undo, serialize, or reapply.
Plan
compilePlan(a, len, final) compiles a.edits into a plan object:
-
Segment map — copy instructions: which source ranges map to which output positions. Structural ops (crop, remove, insert, repeat, pad, reverse, speed) rewrite segments. Each segment is
[from, count, to, rate?, ref?, interp?]— "readcountsamples fromfrom, write atto." -
Sample pipeline — per-block transforms applied in order (gain, fade, filter, pan). Each op receives separate
inputandoutputbuffers (Float32Array[]per channel,BLOCK_SIZEsamples). Each pipeline stage owns its output buffer set, sized to that stage's channel width (channel-changing ops likeremixare a per-stage property, not a special case) — previous stage's output is the next stage's input, zero allocation in the hot path. Ops read from input, write to output (never alias). Stateful ops carry state across blocks viactx.The engine also resolves cross-cutting concerns once, for every op:
- Range scoping —
{at, duration}on an op without native range handling is applied by the engine: input copied through outside the range, the op invoked only on the in-range sub-block. - Automation — function-valued numeric params are sampled by the engine in 128-sample sub-blocks (
gain/panopt into per-sample viaauto: 'sample'; ops with genuine function args likefilter(fn)/transform(fn)exclude them viafnArgs). Breakpoint curves{t, v}are the serializable equivalent — same sampling, but they survivetoJSON()and the worker boundary. - Click-free patching — when a streamed plan's values refine (progressive normalize, live edits), numeric changes ramp linearly across one block instead of stepping.
- Mid-stream edits —
stream()/play()watcha.version; edits pushed while streaming recompile the plan and crossfade (~20ms) into the new pipeline.
- Range scoping —
-
Limit — the safe output boundary. During incremental streaming (
final=false),adjustLimittracks how far output is deterministic given partial source data.
buildPlan(a) is the cached wrapper for fully-decoded audio — calls compilePlan(a, len, true) once per version.
During streaming, compilePlan is called repeatedly as more data arrives (final=false). Each call recomputes the segment map and limit from scratch — stateless, pure, cheap. The limit tells the stream loop how far it can safely render without waiting for more source data.
a.edits ──→ compilePlan(a, len, final) ──→ { segs, pipeline, totalLen, sr, limit }
↓
source pages ──→ segment map ──→ sample pipeline ──→ output blocks
(Float32) (structural) (per-block ops) (stream or flat)
Incremental streaming
Structural ops (crop, repeat, pad, reverse, etc.) stream incrementally — output begins before decode completes. adjustLimit(limit, type, ctx) is a pure function that transforms the safe output boundary per op type during compilation:
- crop: limit clamps to cropped range
- insert: limit extends by inserted length (when insertion point is reachable)
- remove: limit shrinks by removed portion
- pad: limit extends by pad amount
- reverse: limit collapses to 0 for open-end reverse (needs total), passes through for ranged
- speed: limit scales by rate
- negative
at: limit drops to 0 (position depends on total)
The stream loop waits when outPos >= plan.limit, resumes when new source data arrives and recompilation extends the limit.
Progressive resolve
Ops with resolve (trim, normalize) can work with partial stats during incremental streaming. stats.snapshot() returns whatever block stats are available so far (marked partial: true). The resolved result refines as more data decodes — e.g. trim can determine head silence early, normalize can apply gain from available peaks.
Pages and blocks
Audio is fragmented at two levels — pages for storage, blocks for processing:
| `PAGE_SIZE$ (1024 \times \text{BLOCK_SIZE}) | $BLOCK_SIZE` (1024) | |
|---|---|---|
| What | Samples per storage chunk | Samples per processing unit |
| Stores | Float32Array[] per channel | min, max, energy, clip, dc per block |
| Purpose | Memory management, cache eviction, decode streaming | Op processing, stat computation, waveform resolution |
| Memory | PCM data — evictable to OPFS | Stats — always resident (~7 MB for 2h stereo) |
The edit pipeline materializes one block at a time: read source → apply structural ops (segment map) → run sample transforms (rotating pre-allocated input/output buffers) → yield output block. Peak memory is two blocks per channel regardless of file length or op chain depth.
Cache eviction works at page granularity: cold pages offload to OPFS, hot pages near the playhead stay resident.
Ops and stats both process in BLOCK_SIZE chunks — same unit, same granularity. Stateful ops like filters carry state sample-to-sample within and across blocks.
Both are configurable — set before creating any instances:
audio.BLOCK_SIZE = 256
audio.PAGE_SIZE = 2048 * audio.BLOCK_SIZE
Each instance's stats record stats.blockSize — the block size used at decode time.
Waveform zoom and block resolution
BLOCK_SIZE sets the finest pre-computed stat resolution. Three zoom regimes:
- Zoomed out (many samples per pixel):
stat('max', { bins })aggregates multiple blocks into fewer bins. Pre-computed stats suffice — no PCM access. - 1:1 (one block per pixel): stats render directly.
- Zoomed in (fewer samples than one block per pixel): read PCM from pages for sample-accurate waveform. Page cache makes this fast for the visible region.
Changing BLOCK_SIZE cannot retroactively refine existing stats. For finer resolution in a zoomed region, read the PCM.
contract atoms
audio.use(atom) hosts contract audio.js factories natively:
params map to op params (engine automation/curves/ramps apply), declared tail composes
a trailing pad, declared latency gets plan-level delay compensation, streaming: false
atoms run as whole-render ops (materialize → one call → continue from the result), and
multi-bus atoms read their sidechain from the key option. audio.plugins maps names
to published packages for audio.use('name') and CLI auto-resolution (audio.atoms
remains as a deprecated alias).
Meta, markers, regions
Container tags (a.meta), point markers (a.markers), and time-span regions (a.regions) live alongside PCM but follow a different path.
On decode, the streaming loop buffers the first 256 KB of the encoded source (enough for ID3v2 tags, WAV chunks before data, and FLAC metadata blocks) and hands them to fn/meta.js for parsing. The parser returns normalized keys (title, artist, ...) plus unknown blocks preserved under .raw. Pictures arrive as raw Uint8Array with a lazy .url getter — a CD of 20 tracks with 500 KB covers doesn't eagerly create 10 MB of Blob URLs.
Markers and regions are stored in source-sample coordinates on a._.markers / a._.regions. The instance getters project through the edit plan on read:
source markers ──→ buildPlan(a).segs ──→ remapSample(sample, segs) ──→ output markers
remapSample walks plan segments [from, count, to, rate, ref] and emits one output position per segment that covers the source sample. This makes crop shift, remove drop, reverse flip, and speed/stretch scale — uniformly, without per-op plumbing.
On save, WAV/MP3/FLAC buffer the whole encoded output and splice meta back in; other formats stream. Pass meta: false to strip.