first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
# `audio`
Audio processing, playback, analysis, and effect plugins.
<!-- begin:auto-docs -->
## Packages in `audio`
24 packages.
| Package | Description |
|---|---|
| [`@suaveplan/audio-analysis-beat`](./audio-analysis-beat/README.md) | Universal beat, tempo, and onset detection plugin for the `@suaveplan/audio-*` family. Detects BPM, beat locations, and onset times from `AudioDocument` stream… |
| [`@suaveplan/audio-analysis-loudness`](./audio-analysis-loudness/README.md) | Universal ITU-R BS.1770-4 loudness analyser. Drop-in `AnalysisOp` plugin for the `@suaveplan/audio-*` family. |
| [`@suaveplan/audio-analysis-pitch`](./audio-analysis-pitch/README.md) | Universal pitch, key, and chord detection Analysis Op plugins for the `@suaveplan/audio-*` family. Three detectors: |
| [`@suaveplan/audio-analysis-spectrum`](./audio-analysis-spectrum/README.md) | Universal spectral analysis for the Suaveplan audio stack: real FFT, Hann/Hamming/Blackman windows, mel filterbank, DCT-II, and three high-level analyzers — `S… |
| [`@suaveplan/audio-cli`](./audio-cli/README.md) | Server-tier `audio` command-line tool. Thin shell over `@suaveplan/audio-core`, `@suaveplan/audio-codecs`, and `@suaveplan/audio-engine-server`: every audio tr… |
| [`@suaveplan/audio-codec-ffmpeg-native`](./audio-codec-ffmpeg-native/README.md) | Server-tier `AudioCodec` plugin for `@suaveplan/audio-codecs` that shells out to the system `ffmpeg` binary via `fluent-ffmpeg` and streams audio through the s… |
| [`@suaveplan/audio-codec-ffmpeg-wasm`](./audio-codec-ffmpeg-wasm/README.md) | Browser-tier `AudioCodec` plugin for `@suaveplan/audio-codecs` backed by `@ffmpeg/ffmpeg` running inside a dedicated Web Worker. Decodes and encodes `wav`, `mp… |
| [`@suaveplan/audio-codecs`](./audio-codecs/README.md) | Universal audio codec registry: decode/encode plugin loader with magic-byte format detection, progressive streaming decode, native WAV/AIFF/CAF codecs, and laz… |
| [`@suaveplan/audio-controllers`](./audio-controllers/README.md) | Universal MIDI hardware controller plugins. Built on `@suaveplan/audio-core`, `@suaveplan/audio-transport`, `@suaveplan/events`, `@suaveplan/fsm`, and `@suavep… |
| [`@suaveplan/audio-core`](./audio-core/README.md) | Universal audio data model and processing primitives. Provides a paged `AudioDocument`, a non-destructive `PlanEngine`, a block-level `ProcessPipeline`, and a… |
| [`@suaveplan/audio-decode-wav`](./audio-decode-wav/README.md) | Universal WAV decoder wrapper over the upstream `@audio/decode-wav` library. Exposes a stable, OTel-instrumented, AbortSignal-honouring contract over a frozen,… |
| [`@suaveplan/audio-engine-browser`](./audio-engine-browser/README.md) | Browser-tier `TimelineAdapter` implementation for the `@suaveplan/audio-*` family. Hosts Web Audio rendering, `AudioWorklet` loading, and the `AudioFxPlugin.cr… |
| [`@suaveplan/audio-engine-server`](./audio-engine-server/README.md) | Server-tier audio I/O layer for the `@suaveplan/audio-*` family. Provides the thinnest possible bridge between universal `AudioDocument` primitives and a Node.… |
| [`@suaveplan/audio-fingerprint`](./audio-fingerprint/README.md) | High-accuracy audio fingerprinting service. The package is dual-runtime: a Python FastAPI backend (`src/fingerprint.py` + `src/api/app.py`) implements chromagr… |
| [`@suaveplan/audio-metadata`](./audio-metadata/README.md) | Universal audio metadata extractor that parses ID3v1/v2, MP4/M4A atoms, FLAC Vorbis comments, Ogg Vorbis, WAV RIFF, and AIFF/AIFC containers, returning a norma… |
| [`@suaveplan/audio-sprite-packer`](./audio-sprite-packer/README.md) | Universal audio sprite packer with injectable adapter, Zod-validated boundaries, and OpenTelemetry instrumentation. Zero browser or Node.js dependencies. |
| [`@suaveplan/audio-sprite-player`](./audio-sprite-player/README.md) | Universal audio sprite playback engine. Consumes manifests produced by `@suaveplan/audio-sprite-packer`, validates them, schedules voices, runs voice-stealing… |
| [`@suaveplan/audio-timeline`](./audio-timeline/README.md) | Headless DAW timeline engine with multi-track arrangements, clips, playback, loop regions, selection, zoom, per-track mixer state, undo/redo, and a pluggable `… |
| [`@suaveplan/audio-transport`](./audio-transport/README.md) | Universal musical clock and transport: sample-accurate scheduling with PPQ, BPM automation, swing, time signatures, and loop regions. Zero browser or Node depe… |
| [`@suaveplan/control-surface-schema`](./control-surface-schema/README.md) | Universal canonical schema for DAW-style control surfaces. Owns the `ControlSurface` root document, the `ControlNode = Group \| Control` discriminated tree, the… |
| [`@suaveplan/midi`](./midi/README.md) | Universal MIDI primitives for the Suaveplan platform. Owns the canonical types, byte parsers, message constructors, control-surface dispatchers (NRPN, RPN, 14-… |
| [`@suaveplan/third-party-elevenlabs`](./third-party-elevenlabs/README.md) | <!-- begin:auto-docs --> |
| [`@suaveplan/voice`](./voice/README.md) | Provider-agnostic voice core for Suaveplan. Defines the abstract interfaces, shared types, Zod schemas, and structured error hierarchy that every voice provide… |
| [`@suaveplan/voice-elevenlabs`](./voice-elevenlabs/README.md) | ElevenLabs domain adapter for the `@suaveplan/voice` interface family. |
<!-- end:auto-docs -->

View File

@@ -0,0 +1 @@
Audio processing, playback, analysis, and effect plugins.

View File

@@ -0,0 +1,68 @@
# @suaveplan/audio-analysis-beat - Universal beat, tempo and onset detection plugin for the @suaveplan/audio-* family. Three selectable algorithms (energy ODF, spectral flux, comb-filter tempo) plus a phase-aligning beat-grid helper, exposed as an AnalysisOp wrapper with batch / streaming / per-block entry points.
Universal beat, tempo, and onset detection plugin for the
`@suaveplan/audio-*` family. Detects BPM, beat locations, and onset times
from `AudioDocument` streams using energy-based and spectral-flux algorithms,
with optional comb-filter tempo estimation and beat-grid phase alignment.
Runs identically in Bun, Node.js, Deno, and every browser — zero browser-only
APIs, zero Node-only APIs, hand-rolled radix-2 FFT over typed arrays.
<!-- begin:auto-docs -->
## Documentation tree
- [beat-grid](./src/algorithms/beat-grid.md) — Beat-grid construction: given a known beat period and a list of detected onsets, find the phase that aligns a regular `…
- [comb-tempo](./src/algorithms/comb-tempo.md) — Comb-filter / autocorrelation tempo estimation. Given an onset-detection function (ODF) and the cadence at which it was…
- [energy-odf](./src/algorithms/energy-odf.md) — Energy onset-detection function (ODF) — the cheapest of the three algorithms shipped by `@suaveplan/audio-analysis-beat…
- [spectral-flux](./src/algorithms/spectral-flux.md) — Spectral-flux onset detection via a hand-rolled radix-2 real FFT. This module provides the highest-precision ODF in the…
- [detector](./src/detector/beat-detector.md) — `BeatDetector` is the universal tempo / onset / beat-grid analysis op for the `@suaveplan/audio-*` family. It impl…
- [beat-detector](./src/detector/beat-detector.md) — `BeatDetector` is the universal tempo / onset / beat-grid analysis op for the `@suaveplan/audio-*` family. It impl…
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry and concrete `SuaveplanError` subclass for `@suaveplan/audio-analysis-beat`. Every fault thr…
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry and concrete `SuaveplanError` subclass for `@suaveplan/audio-analysis-beat`. Every fault thr…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/audio-analysis-beat
```
## Quick start
```ts
import { AudioDocument } from "@suaveplan/audio-core";
import { BeatDetector } from "@suaveplan/audio-analysis-beat";
const doc = new AudioDocument({
sampleRate: 48_000,
channels: 1,
sourceLength: pcm.length,
pages: [{ channelData: [pcm], startSample: 0 }],
});
const result = await new BeatDetector().analyze(doc);
console.log(result.bpm, result.beats.length, result.confidence);
```
## Algorithms
- `energy` (default) — half-wave-rectified first differences of per-block
energies. Cheap, allocation-light, ideal for percussive content.
- `spectral-flux` — frame-to-frame magnitude-spectrum differences via a
hand-rolled radix-2 real FFT. Catches timbral onsets the energy ODF misses.
- `comb` — comb-filter / autocorrelation tempo estimation over the energy
ODF; more robust on syncopated material at the cost of `O(range × odf)`
scan time.
## Entry points
- `analyze(doc, opts?)` — batch scan; returns a single `BeatResult`.
- `analyzeStream(doc, opts?)` — async generator yielding refining
`BeatResult` values with monotone confidence.
- `analyzeBlock(block, ctx)` — synchronous per-block onset state for
visualisers and real-time displays.
- `asAnalysisOp` getter — the underlying audio-core `AnalysisOp` wrapper.
See per-module `.md` files for the deep documentation of each algorithm and
entry point.

View File

@@ -0,0 +1,87 @@
# `algorithms/beat-grid`
## Purpose
Beat-grid construction: given a known beat period and a list of detected
onsets, find the phase that aligns a regular `period`-spaced grid to the
onsets and emit the grid itself. This is package's three-stage
detection pipeline — what runs after tempo estimation has produced a BPM and
the caller is ready to convert raw onset times into a downbeat-stable beat
sequence.
The same phase sweep also yields a confidence score in `[0, 1]` that
quantifies how tightly the detected onsets cluster around the chosen grid —
a sharp cluster (every onset within a tenth of a period of a grid line)
gives a confidence near 1, scattered onsets give one near 0.
## Features
- `buildBeatGrid(opts)` — sweep `phaseTrials` equally-spaced candidate phases
across one period, pick the phase that minimises total
onset-to-nearest-beat distance, and return the resulting beat grid.
- Configurable sweep resolution (`phaseTrials`, default 24) — finer sweeps
cost linearly more time but tighten the grid alignment in proportion.
- Defensive against zero-period, zero-duration, and empty-onsets inputs —
returns an empty grid with `confidence = 0` rather than throwing.
## Usage: Basic Example
```ts
import { buildBeatGrid } from "@suaveplan/audio-analysis-beat/algorithms/beat-grid";
const result = buildBeatGrid({
period: 60 / 120, // 120 BPM
duration: 10,
onsets: [0.5, 1.0, 1.5, 2.0],
});
console.log(result.beats.length, result.phase, result.confidence);
```
## Usage: Advanced Example
```ts
import { buildBeatGrid } from "@suaveplan/audio-analysis-beat/algorithms/beat-grid";
// Compare two BPM candidates and prefer the one whose grid the onsets fit
// more tightly — this is how the BeatDetector breaks ties between candidate
// tempi when an autocorrelation sweep returns two near-equal peaks.
const onsets = new Float64Array([
0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0,
]);
const a = buildBeatGrid({ period: 60 / 120, duration: 4.2, onsets, phaseTrials: 48 });
const b = buildBeatGrid({ period: 60 / 60, duration: 4.2, onsets, phaseTrials: 48 });
const winner = a.confidence >= b.confidence ? a : b;
console.log("chosen phase", winner.phase, "beats", Array.from(winner.beats));
```
## API Reference
- `interface BeatGridOptions { period: number; duration: number; onsets:
readonly number[] | Float64Array; phaseTrials?: number }`.
- `interface BeatGridResult { beats: Float64Array; phase: number; confidence:
number }`.
- `buildBeatGrid(opts: BeatGridOptions): BeatGridResult` — `beats` is
empty when `period <= 0` or `duration <= 0`; `confidence` is `0` for empty
onsets, otherwise clamped to `[0, 1]`.
## Implementation Notes
The cost function is L1 (sum of absolute errors) rather than L2. L1 is more
robust against the occasional outlier onset that does not actually mark a
beat — a snare on the upbeat, a hi-hat trip, a transient false positive —
whereas L2 would let that outlier dominate the phase sweep.
`phaseTrials` defaults to 24, which is one trial every 2.5° of the beat
cycle at 120 BPM (≈ 21 ms). Halving the trial count roughly halves the cost
function evaluation time at the price of a coarser phase grid; doubling it
sharpens alignment until the cost surface becomes flat at the noise floor of
the onset times themselves.
The confidence formula `1 - cost / (n * period/4)` treats a uniformly
distributed onset list (mean absolute error = quarter-period) as confidence 0
and a perfectly aligned list (zero error) as confidence 1. Inputs that are
*better* than uniform — onsets concentrated on the beat — saturate at 1
rather than overshooting, so downstream code can treat the field as a
probability without further clamping.

View File

@@ -0,0 +1,91 @@
# `algorithms/comb-tempo`
## Purpose
Comb-filter / autocorrelation tempo estimation. Given an onset-detection
function (ODF) and the cadence at which it was sampled, this module finds the
dominant periodicity and reports the implied tempo in beats per minute.
It is the universal back-end for package's three-stage
detection pipeline — every algorithm in the package (`energy`,
`spectral-flux`, and the explicit `comb` selection) ends up here once it has
produced a per-block ODF, because tempo estimation is independent of which
ODF you started from.
## Features
- `combScore(odf, period)` — single-period autocorrelation score. Cheap
building block exposed for callers that want to draw a tempo-confidence
curve or implement a custom octave-error policy.
- `combTempo(odf, opts)` — full sweep across every integer BPM in
`[minBpm, maxBpm]`. Returns the winning BPM and its raw score, with
built-in octave-error resolution that prefers tempi near the perceptual
centre (120 BPM) when two candidates score within 90% of each other.
## Usage: Basic Example
```ts
import { combTempo } from "@suaveplan/audio-analysis-beat/algorithms/comb-tempo";
// ODF sampled every 1024 samples at 48 kHz → hopSeconds = 1024/48000.
const result = combTempo(odf, {
minBpm: 60,
maxBpm: 200,
hopSeconds: 1024 / 48_000,
});
console.log(result.bpm); // e.g. 120
```
## Usage: Advanced Example
```ts
import {
combScore,
combTempo,
} from "@suaveplan/audio-analysis-beat/algorithms/comb-tempo";
// Draw a tempo-confidence curve over the search range manually:
const hopSeconds = 0.02; // 50 Hz ODF
const min = 60;
const max = 200;
const curve = new Float64Array(max - min + 1);
for (let bpm = min; bpm <= max; bpm++) {
const period = Math.max(1, Math.round((60 / bpm) / hopSeconds));
curve[bpm - min] = combScore(odf, period);
}
// Fall back to combTempo for the canonical winner including octave handling.
const winner = combTempo(odf, { minBpm: min, maxBpm: max, hopSeconds });
console.log(winner.bpm, winner.score);
```
## API Reference
- `interface CombTempoOptions { minBpm: number; maxBpm: number; hopSeconds:
number }` — input bounds and ODF cadence.
- `interface CombTempoResult { bpm: number; score: number }`.
- `combScore(odf: Float64Array, periodOdfSamples: number): number` — returns
zero for `period <= 0` and otherwise the sum of `odf[i] * odf[i - period]`
across the valid index range.
- `combTempo(odf: Float64Array, opts: CombTempoOptions): CombTempoResult` —
returns `{ bpm: minBpm, score: 0 }` for empty ODF, non-positive
`hopSeconds`, or inverted bounds. Otherwise sweeps every integer BPM in
the inclusive range.
## Implementation Notes
The score for a candidate BPM is the unnormalised autocorrelation of the ODF
at the corresponding lag. The lag is computed as `round((60 / bpm) /
hopSeconds)` and clamped at one ODF sample so candidate BPMs near `maxBpm`
never collapse to a zero lag.
Octave-error resolution is intentionally cheap: among all candidates whose
score is within 90% of the winner's score, the one closest to 120 BPM
wins. This is the median tapping tempo across human listeners (Moelants 2002)
and matches perception far more often than picking the raw maximum, which is
biased toward half-tempo for slow tracks and double-tempo for fast tracks.
Integer BPM resolution is sufficient for the universal use case — the
caller can run a quadratic interpolation across the three neighbours of the
winning bin if sub-BPM precision is needed, but that machinery is not part
of this module to keep the public surface small.

View File

@@ -0,0 +1,91 @@
# `algorithms/energy-odf`
## Purpose
Energy onset-detection function (ODF) — the cheapest of the three algorithms
shipped by `@suaveplan/audio-analysis-beat`. It consumes a per-block energy
sequence and emits a 1-D curve whose peaks mark sample positions where the
signal's energy rises sharply, i.e. note onsets.
Energy ODF works extremely well on percussive material (kick, snare, claps)
and is the only path that adds essentially no cost over a plain document scan
— per-block energies can be reused from the host's existing block statistics
pipeline rather than recomputed.
## Features
- `blockEnergy(block)` — mean per-sample energy of a single multi-channel
block. Defensive against empty blocks, ragged channel arrays, and
zero-length data.
- `energyOdf(energies)` — half-wave-rectified first difference of an energy
sequence. Same length as the input; entry 0 is always zero.
- `energyOdfFromSamples(channels, blockSize)` — convenience that splits raw
channel data into `blockSize`-sample blocks, computes block energies, and
returns the ODF in one call.
## Usage: Basic Example
```ts
import { energyOdf } from "@suaveplan/audio-analysis-beat/algorithms/energy-odf";
const energies = new Float64Array([0, 0, 0.5, 0.6, 0.1, 0.05, 0.4]);
const odf = energyOdf(energies);
// odf === Float64Array([0, 0, 0.5, 0.1, 0, 0, 0.35])
```
## Usage: Advanced Example
```ts
import {
blockEnergy,
energyOdfFromSamples,
} from "@suaveplan/audio-analysis-beat/algorithms/energy-odf";
const sampleRate = 48_000;
const blockSize = 1024;
const left = new Float32Array(sampleRate * 4); // 4 seconds mono-ish
const right = new Float32Array(sampleRate * 4);
// ... fill ...
const odf = energyOdfFromSamples([left, right], blockSize);
const hopSeconds = blockSize / sampleRate;
// Pick the loudest block in the first second:
let bestIdx = 0;
let best = 0;
for (let i = 0; i < Math.floor(1 / hopSeconds); i++) {
if (odf[i] > best) {
best = odf[i];
bestIdx = i;
}
}
console.log("onset at", bestIdx * hopSeconds, "seconds");
console.log("avg first-block energy", blockEnergy([left.subarray(0, blockSize)]));
```
## API Reference
- `blockEnergy(block: readonly Float32Array[]): number` — mean per-sample
energy summed across channels then divided by `length * channels`.
- `energyOdf(energies: Float64Array): Float64Array` — same-length output;
`out[i] = max(0, energies[i] - energies[i-1])`, `out[0] === 0`.
- `energyOdfFromSamples(channels: readonly Float32Array[], blockSize: number):
Float64Array` — combined pipeline; returns an empty `Float64Array` when
`blockSize <= 0` or the first channel is empty.
## Implementation Notes
The half-wave rectification (`d > 0 ? d : 0`) is the entire reason this works
as an onset detector rather than a generic energy-change detector — falling
edges (notes ending) carry no perceptual onset and zeroing them out keeps the
ODF interpretable as a peak-picking problem.
Mean-per-sample energy rather than raw sum is used because the final block of
a document is often shorter than `blockSize`; without normalisation the last
block would appear artificially quiet and the ODF would spike negative
(rectified to zero), masking a legitimate onset at the end of the track.
Defensive `?? 0` and `?? undefined`-shape guards thread through every loop so
malformed input — an empty channels array, a ragged channels array, a block
whose first channel is undefined — yields a defined zero rather than throwing.
This module is the leaf any of the three algorithm paths can fall back to and
must never propagate an exception of its own.

View File

@@ -0,0 +1,98 @@
# `algorithms/spectral-flux`
## Purpose
Spectral-flux onset detection via a hand-rolled radix-2 real FFT. This module
provides the highest-precision ODF in the package — it catches timbral onsets
(hi-hat over a sustained pad, a fingertip pluck on a held note) that the
energy ODF cannot see, because it measures *which bins are changing* rather
than *how much total energy is moving*.
It is written entirely in terms of `Float32Array` and `Float64Array` and
never touches the browser `AnalyserNode` or any Node-only API. The same code
runs identically under Bun, Node.js, Deno, and every browser.
## Features
- `fftRadix2(re, im)` — in-place Cooley-Tukey complex FFT for power-of-two
sizes. The lowest-level primitive; both arrays are read and written.
- `magnitudeSpectrum(frame, fftSize)` — real-input single-sided magnitude
spectrum with a Hann window applied before transform; returns length
`fftSize/2 + 1`.
- `spectralFlux(frame, prevMag, fftSize)` — single-frame ODF value; sum of
positive bin-to-bin differences. `prevMag` is mutated to become the current
magnitude spectrum so the caller can chain across frames.
- `spectralFluxOdf(mono, fftSize, hop)` — convenience that splits a mono
sample stream into `hop`-sample frames of `fftSize` samples (zero-padded as
needed) and returns the full ODF.
## Usage: Basic Example
```ts
import { spectralFluxOdf } from "@suaveplan/audio-analysis-beat/algorithms/spectral-flux";
const mono = new Float32Array(48_000 * 4); // 4 seconds of audio
// ... fill mono with samples ...
const odf = spectralFluxOdf(mono, 1024, 512);
console.log(odf.length, "frames"); // ceil(192000 / 512) = 375
```
## Usage: Advanced Example
```ts
import {
magnitudeSpectrum,
spectralFlux,
} from "@suaveplan/audio-analysis-beat/algorithms/spectral-flux";
const fftSize = 2048;
const hop = 512;
const buf = new Float32Array(fftSize);
const prev = new Float64Array((fftSize >> 1) + 1);
const mono = new Float32Array(48_000 * 10); // 10 seconds
// Manual frame loop — lets the caller stop early on an AbortSignal,
// thread per-frame work through other code, or thin the ODF on the fly.
const odfValues: number[] = [];
for (let start = 0; start + fftSize <= mono.length; start += hop) {
buf.set(mono.subarray(start, start + fftSize));
odfValues.push(spectralFlux(buf, prev, fftSize));
}
// Verify the magnitude spectrum has the expected shape on its own.
const probe = magnitudeSpectrum(mono.subarray(0, fftSize), fftSize);
console.log(probe.length === fftSize / 2 + 1);
```
## API Reference
- `fftRadix2(re: Float64Array, im: Float64Array): void` — `re.length` must
equal `im.length` and must be a power of two; transform is in place.
- `magnitudeSpectrum(frame: Float32Array, fftSize: number): Float64Array` —
output length `fftSize/2 + 1`; Hann-windowed.
- `spectralFlux(frame: Float32Array, prevMag: Float64Array, fftSize: number):
number` — returns the flux value; `prevMag` is mutated in place to become
the current magnitude spectrum.
- `spectralFluxOdf(mono: Float32Array, fftSize: number, hop: number):
Float64Array` — returns empty array when any argument is empty or
non-positive; `odf[0]` is forced to zero so peak-picking does not flag the
artificial first-frame onset.
## Implementation Notes
The FFT is a textbook iterative Cooley-Tukey radix-2 with explicit
bit-reversal permutation followed by `log2(n)` rounds of butterflies. Twiddle
factors are computed by complex multiplication of a running unit-magnitude
phase rather than a precomputed table — for the FFT sizes used in this
package (`<= 4096`) the table cost is not worth it, and trig recurrence
avoids the alignment subtleties of typed-array tables.
Only positive bin-to-bin differences contribute to the flux. Negative
differences (bins that are quieter than in the previous frame) carry no
perceptual onset information and including them would partly cancel the
positive contributions, blurring peaks.
`prevMag` is initialised to zeros, which means the first frame's flux is the
sum of its full magnitude spectrum — an artefact, not a real onset. The
`spectralFluxOdf` wrapper therefore overwrites `odf[0]` to zero before
returning so peak-picking does not flag that boundary.

View File

@@ -0,0 +1,123 @@
# `detector/beat-detector`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`BeatDetector` is the universal tempo / onset / beat-grid analysis op
for the `@suaveplan/audio-*` family. It implements the package-public
`analyze` / `analyzeStream` / `analyzeBlock` triple documented in the spec
delta and also exposes a `createAnalysisOp`-built `AnalysisOp` view for hosts
that drive analysis through the audio-core registry.
Internally it composes the three algorithm modules under `./algorithms/*`:
`energy-odf` or `spectral-flux` for stage 1 (onset detection function),
`comb-tempo` for stage 2 (tempo estimation), and `beat-grid` for stage 3
(phase alignment). No two consumers need agree on which path to use — the
algorithm is selected per call via `BeatDetectorOptions.algorithm`.
## Features
- `BeatDetector` class with `name`, `description`, `analyze`, `analyzeStream`,
`analyzeBlock`, and an `asAnalysisOp` getter for the audio-core view.
- Three selectable algorithms: `"energy"` (default), `"spectral-flux"`,
`"comb"`.
- Zod-validated options at every public entry point with stable
`AUDIO_ANALYSIS_BEAT.INVALID_OPTIONS` errors.
- `AbortSignal` honoured at the entry point and at every block boundary in
streaming and full-scan paths.
- Stable error codes `EMPTY_DOCUMENT` and `NO_BEATS_DETECTED` for the two
edge cases that would otherwise produce a meaningless zero-confidence
result.
- OTel instrumentation: span `audio.analysis.beat`, counter
`audio.analysis.beat.runs_total`, histogram
`audio.analysis.beat.duration_ms`. All inert without an exporter.
## Usage: Basic Example
```ts
import { AudioDocument } from "@suaveplan/audio-core";
import { BeatDetector } from "@suaveplan/audio-analysis-beat/detector/beat-detector";
const doc = new AudioDocument({
sampleRate: 48_000,
channels: 1,
sourceLength: 48_000 * 4,
pages: [{ startSample: 0, channelData: [Array.from(/* 4 s mono */)] }],
});
const detector = new BeatDetector();
const result = await detector.analyze(doc);
console.log(result.bpm, result.beats.length, result.confidence);
```
## Usage: Advanced Example
```ts
import { AudioDocument } from "@suaveplan/audio-core";
import { BeatDetector } from "@suaveplan/audio-analysis-beat/detector/beat-detector";
const detector = new BeatDetector({
algorithm: "spectral-flux",
minBpm: 80,
maxBpm: 160,
fftSize: 2048,
hopSize: 1024,
});
const controller = new AbortController();
setTimeout(() => controller.abort(), 5_000);
// Streaming refinement
let final;
for await (const partial of detector.analyzeStream(doc, {
signal: controller.signal,
})) {
final = partial;
console.log("BPM so far:", partial.bpm, partial.confidence);
}
// Or drive it through the audio-core AnalysisOp surface
const op = detector.asAnalysisOp;
const viaOp = await op.run(doc, {});
console.log(viaOp.bpm, final?.bpm);
```
## API Reference
- `class BeatDetector` — `readonly name: "beat-detector"`,
`readonly description: "Detect tempo and beat locations"`,
`constructor(options?: BeatDetectorOptions)`.
- `analyze(doc, opts?): Promise<BeatResult>` — full-document scan; throws
`EMPTY_DOCUMENT` for zero-length input, `NO_BEATS_DETECTED` on silence,
`INVALID_OPTIONS` on bad config, `ABORTED` on a pre-aborted signal.
- `analyzeStream(doc, opts?): AsyncGenerator<BeatResult>` — yields refining
results; confidence is monotonically non-decreasing.
- `analyzeBlock(block: Float32Array[], ctx: BlockContext): BeatResult` —
synchronous per-block onset state for visualisation.
- `get asAnalysisOp(): AnalysisOp<BeatDetectorOptions, BeatResult>` — the
`createAnalysisOp`-wrapped view.
## Implementation Notes
`analyze` always reads the full document via `readPages` and computes the
ODF over the whole sample stream in one pass. `analyzeStream` reads the
document once up front and then re-runs the stage-2/3 pipeline over
progressively larger prefixes — this is correct because the ODF is itself
constructed from independent block-level statistics; the stream contract
costs one extra full pass rather than `O(blocks²)` work.
The streaming generator enforces a monotone confidence by carrying the
running maximum forward and capping any partial whose confidence dipped
below it. This matches the spec's "later yields ≥ earlier yields" invariant
without lying about the underlying signal — the BPM and beat grid still
reflect what the detector actually computed at that yield.
`analyzeBlock` is intentionally not the same code path as `analyze`. A
single block is too short to drive `combTempo` reliably, so the block
entry point reports the current spectral-flux value as a confidence proxy
and stamps it with a fixed BPM in the configured range. The contract — a
synchronous `BeatResult` with onset-state and confidence in `[0, 1]` — is
preserved while keeping the call allocation-light enough for a render loop.

View File

@@ -0,0 +1,95 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Stable error-code registry and concrete `SuaveplanError` subclass for
`@suaveplan/audio-analysis-beat`. Every fault thrown by the package surfaces
through `AudioAnalysisBeatError` with one of four namespaced codes —
`AUDIO_ANALYSIS_BEAT.INVALID_OPTIONS`, `EMPTY_DOCUMENT`, `NO_BEATS_DETECTED`,
and `ABORTED` — making callers able to discriminate without parsing strings.
The registry is built with `defineCodes` from `@suaveplan/error`, so every code
is a single source of truth and is bound at module load. The frozen metadata
table exposes a one-line description plus a `severity` flag (`"warning"` for
abort, `"error"` everywhere else) so observability adapters can attach the
right log level without re-deriving it.
## Features
- `AUDIO_ANALYSIS_BEAT_CODES` — `defineCodes` output mapping symbolic keys to
the namespaced string codes.
- `AudioAnalysisBeatErrorCode` — union of all four string codes.
- `AUDIO_ANALYSIS_BEAT_ERROR_METADATA` — frozen `Record` keyed by code,
carrying `{ description, severity }`.
- `AudioAnalysisBeatError extends SuaveplanError` — concrete throw type with a
public constructor and `category: "audio-analysis-beat"` discriminator.
- `registerAudioAnalysisBeatErrorCodes()` — idempotent hook callers can invoke
at startup to assert the registry has been touched.
## Usage: Basic Example
```ts
import {
AUDIO_ANALYSIS_BEAT_CODES,
AudioAnalysisBeatError,
} from "@suaveplan/audio-analysis-beat/error-codes";
throw new AudioAnalysisBeatError(
AUDIO_ANALYSIS_BEAT_CODES.EMPTY_DOCUMENT,
"audio-analysis-beat.empty-document",
);
```
## Usage: Advanced Example
```ts
import {
AUDIO_ANALYSIS_BEAT_CODES,
AUDIO_ANALYSIS_BEAT_ERROR_METADATA,
AudioAnalysisBeatError,
} from "@suaveplan/audio-analysis-beat/error-codes";
function logError(err: unknown): void {
if (!(err instanceof AudioAnalysisBeatError)) throw err;
const meta = AUDIO_ANALYSIS_BEAT_ERROR_METADATA[err.code];
console[meta.severity === "warning" ? "warn" : "error"](
`[${err.category}] ${err.code}: ${meta.description}`,
{ context: err.context, cause: err.cause },
);
if (err.code === AUDIO_ANALYSIS_BEAT_CODES.ABORTED) return;
if (err.code === AUDIO_ANALYSIS_BEAT_CODES.NO_BEATS_DETECTED) return;
throw err;
}
```
## API Reference
- `AUDIO_ANALYSIS_BEAT_CODES: Readonly<Record<"INVALID_OPTIONS" |
"EMPTY_DOCUMENT" | "NO_BEATS_DETECTED" | "ABORTED", string>>`.
- `AudioAnalysisBeatErrorCode: string` — union of the four namespaced codes.
- `AUDIO_ANALYSIS_BEAT_ERROR_METADATA: Readonly<Record<AudioAnalysisBeatErrorCode,
{ description: string; severity: "warning" | "error" }>>`.
- `class AudioAnalysisBeatError extends SuaveplanError<AudioAnalysisBeatErrorCode>` —
`category: "audio-analysis-beat"`; constructor
`(code, messageKey, options?: SuaveplanErrorOptions)`.
- `registerAudioAnalysisBeatErrorCodes(): void` — idempotent registry touch.
## Implementation Notes
The constructor of `SuaveplanError` is `protected`. To widen it without a
biome suppression comment, `AudioAnalysisBeatError` provides a non-trivial
constructor body (it sets `this.name`) and a `readonly category` field — both
genuine state mutations beyond a bare `super(...args)`. The class therefore
does not match biome's "useless constructor" heuristic and compiles cleanly
under the rule that forbids `// biome-ignore` comments in new code.
Metadata is `Object.freeze`'d at module load so the export is referentially
stable and cannot be mutated by callers. The bottom-of-file
`registerAudioAnalysisBeatErrorCodes()` call is intentional: it makes the
registry initialisation observable even in tree-shaken builds that drop
unused side-effect modules, guaranteeing the codes are reachable from a real
call site as required by the spec.

View File

@@ -0,0 +1,56 @@
# @suaveplan/audio-analysis-loudness - Universal ITU-R BS.1770-4 loudness analyser: K-weighting, gated integrated LUFS, momentary and short-term loudness, EBU R128 loudness range, 4x oversampled true-peak detection, RMS, sample peak, and per-channel DC offset. Streaming and block-level metering for broadcast compliance, mastering, and auto-normalisation pipelines.
Universal ITU-R BS.1770-4 loudness analyser. Drop-in `AnalysisOp` plugin for
the `@suaveplan/audio-*` family.
<!-- begin:auto-docs -->
## Documentation tree
- [loudness-range](./src/algorithms/loudness-range.md) — `loudness-range` computes the EBU Tech 3342 loudness range (LRA, in LU) — a statistical measure of dynamic spread. It t…
- [lufs](./src/algorithms/lufs.md) — `lufs` contains the kernel of BS.1770-4 loudness measurement: the per-block mean-square energy calculation, the BS.1770…
- [true-peak](./src/algorithms/true-peak.md) — `true-peak` implements true-peak (dBTP) detection via 4× polyphase oversampling, per ITU-R BS.1770-4 Annex 2. Sample pe…
- [error-codes](./src/error-codes/error-codes.md) — This module owns the stable, machine-readable error-code registry for `@suaveplan/audio-analysis-loudness`. Every fault…
- [error-codes](./src/error-codes/error-codes.md) — This module owns the stable, machine-readable error-code registry for `@suaveplan/audio-analysis-loudness`. Every fault…
- [filters](./src/filters/k-weighting.md) — `k-weighting` implements the ITU-R BS.1770 K-weighting filter — a two-stage IIR biquad cascade applied per channel befo…
- [k-weighting](./src/filters/k-weighting.md) — `k-weighting` implements the ITU-R BS.1770 K-weighting filter — a two-stage IIR biquad cascade applied per channel befo…
- [meter](./src/meter/loudness-meter.md) — `LoudnessMeter` is the orchestration layer of `@suaveplan/audio-analysis-loudness`. It wires the K-weighting filter, th…
- [loudness-meter](./src/meter/loudness-meter.md) — `LoudnessMeter` is the orchestration layer of `@suaveplan/audio-analysis-loudness`. It wires the K-weighting filter, th…
<!-- end:auto-docs -->
## Features
- Integrated LUFS via two-gate algorithm (absolute −70, relative −10 LU).
- Momentary (400 ms) and short-term (3 s) loudness.
- EBU Tech 3342 loudness range (LRA in LU).
- 4× oversampled true-peak detection (dBTP, per BS.1770-4 Annex 2).
- Sample peak (dB), RMS (dB), per-channel DC offset.
- Streaming `analyzeStream` whose final yield equals batch `analyze`.
- Real-time `analyzeBlock` for live meters.
- Finite silence floors — no `-Infinity` ever leaks to consumers.
- OTel span `audio.analysis.loudness`, counter, histograms.
- Pure universal: no `window`, no `document`, no `node:fs`.
## Install
```bash
bun add @suaveplan/audio-analysis-loudness
```
## Quick start
```ts
import { LoudnessMeter } from "@suaveplan/audio-analysis-loudness";
const meter = new LoudnessMeter();
const result = await meter.analyze(doc);
console.log({
integrated: result.lufs,
range: result.loudnessRange,
truePeak: result.truePeak,
});
```
## License
MIT

View File

@@ -0,0 +1,66 @@
# loudness-range
## Purpose
`loudness-range` computes the EBU Tech 3342 loudness range (LRA, in LU) — a
statistical measure of dynamic spread. It takes short-term loudness samples
(K-weighted, channel-summed loudness over 3 s windows stepped by 1 s) and
returns the 95th-minus-10th percentile spread of the gated distribution.
LRA complements integrated loudness: two programmes can share an integrated
LUFS but have wildly different listener experience if one is uniformly
compressed and the other has dynamic swings. Broadcasters use LRA thresholds
(e.g. EBU R128 recommends ≤ 7 LU for spoken-word programming) to flag
material that needs dynamics processing before transmission.
## Features
- Two-gate algorithm: absolute gate at −70 LUFS, relative gate 20 LU below
the gated mean (wider than the BS.1770-4 integrated-loudness 10 LU gate).
- Linear-interpolated percentiles per Tech 3342.
- Returns `lra`, `low`, `high`, and `gatedCount` for downstream UIs.
- Sub-window documents safely report `lra: 0` instead of throwing.
## Usage: Basic
```ts
import { loudnessRange } from "@suaveplan/audio-analysis-loudness/algorithms/loudness-range";
const { lra } = loudnessRange(shortTermLufsValues);
```
## Usage: Advanced
```ts
import {
loudnessRange,
percentile,
} from "@suaveplan/audio-analysis-loudness/algorithms/loudness-range";
const result = loudnessRange(shortTermLufsValues);
console.log({
lra: result.lra,
p10: result.low,
p95: result.high,
surviving: result.gatedCount,
});
// Inspect arbitrary percentiles for diagnostic overlays.
const sorted = [...shortTermLufsValues].sort((a, b) => a - b);
const p50 = percentile(sorted, 0.5);
```
## API Reference
- `LRA_RELATIVE_GATE_OFFSET_LU` — `-20`, the Tech 3342 relative-gate offset.
- `loudnessRange(shortTermLufs: readonly number[]) => LoudnessRangeResult`.
- `percentile(sortedAsc, q)` — linear-interpolated q ∈ [0,1].
## Implementation Notes
The mean used for the relative gate is computed in linear power and then
converted back to LUFS via `energyToLufs`, not as an arithmetic mean of LUFS
values — averaging in dB would over-weight quiet blocks. Edge cases (no
input, no survivors after the absolute gate, no survivors after the relative
gate) all map cleanly to `lra: 0` with the best-available `low`/`high` so
downstream consumers never see NaN.

View File

@@ -0,0 +1,57 @@
# lufs
## Purpose
`lufs` contains the kernel of BS.1770-4 loudness measurement: the per-block
mean-square energy calculation, the BS.1770 channel-weighted summation, the
energy-to-LUFS conversion, and the streaming `IntegratedLoudness` accumulator
that implements the two-gate algorithm (absolute gate at −70 LUFS, then a
relative gate 10 LU below the ungated mean of survivors). The kernels are
document-agnostic — they consume `Float32Array[]` blocks and numbers — so a
true-peak limiter can pull `momentaryLufs` without instantiating a meter.
## Features
- Pure functions: `blockMeanSquare`, `combineChannelEnergies`,
`energyToLufs`, `momentaryLufs`, `shortTermLufs`.
- Streaming `IntegratedLoudness` class with two-gate integration.
- BS.1770 channel weights — L/R/C at 1.0, surround at 1.41, LFE excluded.
- Finite silence floor (`LUFS_SILENCE_FLOOR = -100`) instead of `-Infinity`.
## Usage: Basic
```ts
import { momentaryLufs } from "@suaveplan/audio-analysis-loudness/algorithms/lufs";
const lufs = momentaryLufs(kWeightedBlock);
```
## Usage: Advanced
```ts
import { IntegratedLoudness } from "@suaveplan/audio-analysis-loudness/algorithms/lufs";
const integrator = new IntegratedLoudness(2);
for (const block of kWeightedGatingBlocks) integrator.pushBlock(block);
const integrated = integrator.integrated();
console.log({ integrated, surviving: integrator.gatedBlockCount });
```
## API Reference
- `LUFS_SILENCE_FLOOR`, `ABSOLUTE_GATE_LUFS`, `RELATIVE_GATE_OFFSET_LU`,
`CHANNEL_WEIGHTS_BS1770` — frozen constants.
- `channelWeight(index)`, `blockMeanSquare(block)`,
`combineChannelEnergies(perChannel)`, `energyToLufs(weightedMS)`,
`momentaryLufs(block)`, `shortTermLufs(block)`.
- `class IntegratedLoudness` — `pushBlock`, `integrated`, `reset`,
`gatedBlockCount`.
## Implementation Notes
The integrator stores the linear weighted mean-square values that survived
the absolute gate rather than their LUFS counterparts, because the relative
gate is itself derived from the linear mean. This avoids a second
`Math.pow(10, …)` pass per block. The silence floor is the BS.1770-4 minimum
reported value (−70 LUFS for "no programme") rounded down to −100 to give
downstream UI meters a stable bottom.

View File

@@ -0,0 +1,56 @@
# true-peak
## Purpose
`true-peak` implements true-peak (dBTP) detection via 4× polyphase
oversampling, per ITU-R BS.1770-4 Annex 2. Sample peak — `max |x[n]|` — under-
reports the real analog peak because the true waveform crests *between*
samples; near-Nyquist signals routinely show inter-sample peaks 1–3 dB above
the sample peak. Broadcast normalisation pipelines rely on dBTP to keep
material clear of the digital ceiling after DAC reconstruction.
## Features
- 4-phase polyphase FIR with 12-tap kernel per phase.
- `truePeakDbtp(block)` — true-peak across all channels in dBTP.
- `samplePeakLinear(block)` — sample peak in linear amplitude.
- Finite floor at −200 dBTP for digital silence (no `-Infinity`).
- Edge-aware: taps falling outside the buffer are zero-padded.
## Usage: Basic
```ts
import { truePeakDbtp } from "@suaveplan/audio-analysis-loudness/algorithms/true-peak";
const dbtp = truePeakDbtp([leftChannel, rightChannel]);
```
## Usage: Advanced
```ts
import {
truePeakDbtp,
samplePeakLinear,
} from "@suaveplan/audio-analysis-loudness/algorithms/true-peak";
const tp = truePeakDbtp(channels);
const sp = 20 * Math.log10(samplePeakLinear(channels));
console.log({ samplePeakDb: sp, truePeakDbtp: tp, intersampleMargin: tp - sp });
```
## API Reference
- `truePeakDbtp(block: readonly Float32Array[]) => number` — dBTP across all
channels.
- `samplePeakLinear(block: readonly Float32Array[]) => number` — linear-
amplitude sample peak.
## Implementation Notes
The polyphase kernel is a fixed 12-tap windowed-sinc low-pass (Kaiser β=8,
cutoff 0.45·Nyquist), normalised per phase to unity DC gain. Phase 0 is the
identity phase so the original samples are always included in the search and
`truePeakDbtp ≥ samplePeakDb` always holds. BS.1770-4 permits higher
oversampling ratios; the meter sticks to the specified minimum of 4× because
it is sufficient up to 48 kHz material — a configurable ratio would add
surface area for negligible accuracy gain in the target range.

View File

@@ -0,0 +1,77 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the stable, machine-readable error-code registry for
`@suaveplan/audio-analysis-loudness`. Every fault raised by the loudness
analyser is reported through a single `AudioAnalysisLoudnessError` subclass of
`SuaveplanError`, carrying a code drawn from `AUDIO_ANALYSIS_LOUDNESS_CODES`
and rich context such as Zod issue lists or abort reasons.
The registry exists so downstream observability, alerting, and i18n layers
can bind to stable string identifiers instead of fragile message strings.
Codes never change once shipped — new fault classes get new codes, never
re-purposed old ones.
## Features
- Three stable codes: `INVALID_OPTIONS`, `EMPTY_DOCUMENT`, `ABORTED`.
- Frozen metadata table (`description`, `severity`) keyed by code.
- `AudioAnalysisLoudnessError` subclass with public constructor and a
`category` discriminant that satisfies the no-suppression-comment rule
without any `// biome-ignore`.
- Idempotent `registerAudioAnalysisLoudnessErrorCodes()` hook.
## Usage: Basic
```ts
import {
AUDIO_ANALYSIS_LOUDNESS_CODES,
AudioAnalysisLoudnessError,
} from "@suaveplan/audio-analysis-loudness/error-codes";
throw new AudioAnalysisLoudnessError(
AUDIO_ANALYSIS_LOUDNESS_CODES.EMPTY_DOCUMENT,
"audio-analysis-loudness.empty-document",
{ context: { sourceLength: 0 } },
);
```
## Usage: Advanced
```ts
import {
AUDIO_ANALYSIS_LOUDNESS_CODES,
AUDIO_ANALYSIS_LOUDNESS_ERROR_METADATA,
registerAudioAnalysisLoudnessErrorCodes,
} from "@suaveplan/audio-analysis-loudness/error-codes";
registerAudioAnalysisLoudnessErrorCodes();
for (const code of Object.values(AUDIO_ANALYSIS_LOUDNESS_CODES)) {
const meta = AUDIO_ANALYSIS_LOUDNESS_ERROR_METADATA[code];
console.log(code, meta.severity, meta.description);
}
```
## API Reference
- `AUDIO_ANALYSIS_LOUDNESS_CODES` — frozen object mapping reason names to
namespaced code strings.
- `AudioAnalysisLoudnessErrorCode` — union of every code string.
- `AUDIO_ANALYSIS_LOUDNESS_ERROR_METADATA` — frozen lookup keyed by code.
- `AudioAnalysisLoudnessError` — `SuaveplanError` subclass.
- `registerAudioAnalysisLoudnessErrorCodes()` — idempotent startup hook.
## Implementation Notes
`defineCodes` produces both the runtime object and the literal type union; the
returned object is frozen, so attempts to mutate codes at runtime fail in
strict mode. The metadata table is independently `Object.freeze`-d so consumers
cannot mutate descriptions or severities. The `category` field on the error
class is what keeps the subclass "non-trivial" enough for the static
analyser — without it, biome flags the override as useless.

View File

@@ -0,0 +1,71 @@
# k-weighting
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`k-weighting` implements the ITU-R BS.1770 K-weighting filter — a two-stage
IIR biquad cascade applied per channel before any loudness energy is
accumulated. The first stage is a high-shelf "pre-filter" approximating the
acoustic effect of the listener's head (≈ +4 dB lift above ~1500 Hz); the
second stage is an RLB high-pass that rolls off content below ~100 Hz so
sub-bass rumble does not dominate the measurement.
Without K-weighting, "loudness" would be raw RMS — which BS.1770 explicitly
rejects because raw RMS over-weights low-frequency energy that the human ear
perceives as quiet. The K-curve is what makes BS.1770 loudness correlate with
perceived programme loudness across genres.
## Features
- Canonical BS.1770 coefficients at the 48 kHz reference rate.
- Rate-agnostic coefficient derivation via inverse bilinear transform —
44.1 kHz, 88.2 kHz, 96 kHz, and arbitrary rates all reproduce the
BS.1770 magnitude response within floating-point tolerance.
- Per-channel direct-form-II transposed state so block-by-block filtering is
sample-identical to a whole-buffer pass.
- Pure `Float32Array[]` in/out — no `AudioBuffer`, no platform globals.
## Usage: Basic
```ts
import { createKWeighting } from "@suaveplan/audio-analysis-loudness/filters/k-weighting";
const filter = createKWeighting(48000, 2);
const filtered = filter.processBlock([leftChannel, rightChannel]);
```
## Usage: Advanced
```ts
import { buildKWeighting, createKWeighting } from "@suaveplan/audio-analysis-loudness/filters/k-weighting";
// Inspect the coefficients for an arbitrary rate.
const { pre, rlb } = buildKWeighting(44100);
// Stream blocks through a stateful filter — output equals a single-pass run.
const filter = createKWeighting(44100, 1);
for (const block of stream) filter.processBlock(block);
filter.reset(); // clear state between unrelated signals
```
## API Reference
- `buildKWeighting(sampleRate)` → `{ pre: Biquad; rlb: Biquad }`. Returns
fresh objects; mutating them does not affect future calls.
- `createKWeighting(sampleRate, channels)` → `KWeightingFilter`.
- `KWeightingFilter.processBlock(block)` → `Float32Array[]` of identical
shape; never mutates the input.
- `KWeightingFilter.reset()` — zeroes all per-channel biquad state.
## Implementation Notes
Coefficients at non-reference rates are derived by recovering the analog
prototype from the 48 kHz biquad (inverse bilinear transform) and then
re-discretising at the target rate (forward bilinear). This avoids the
warping that would occur if the canonical coefficients were used verbatim at
44.1 kHz, where the magnitude response would shift by ~10% in the shelf
region. State arrays are typed `Float64Array` so accumulated rounding stays
below `Float32` quantisation across hour-long buffers.

View File

@@ -0,0 +1,85 @@
# loudness-meter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`LoudnessMeter` is the orchestration layer of `@suaveplan/audio-analysis-loudness`.
It wires the K-weighting filter, the gated-integration engine, the short-term
LUFS accumulator (for loudness range), the 4× oversampled true-peak detector,
and the RMS / sample-peak / DC-offset accumulators together behind three
entry points — full-document `analyze`, streaming `analyzeStream`, and the
real-time `analyzeBlock`. It is a sidecar processor (plugin slot 3) and
never participates in the render pipeline.
The class also threads OTel spans, the run counter, and per-block histograms
through every entry point, validates all options at the Zod boundary, and
propagates `AbortSignal` cancellation to mid-stream block boundaries.
## Features
- ITU-R BS.1770-4 integrated LUFS via two-gate (absolute + relative).
- EBU Tech 3342 loudness range (LRA in LU).
- 4× oversampled true-peak in dBTP (per BS.1770-4 Annex 2).
- Sample peak in dB, RMS in dB, per-channel DC offset.
- Streaming `analyzeStream` whose final yield equals batch `analyze`.
- Real-time `analyzeBlock` for live meters — synchronous, allocation-light.
- Finite silence floors (no `-Infinity` ever leaks to consumers).
- `AbortSignal` propagation at gating-block boundaries.
- OTel span, counter, histograms — inert without an exporter.
## Usage: Basic
```ts
import { LoudnessMeter } from "@suaveplan/audio-analysis-loudness";
const meter = new LoudnessMeter();
const result = await meter.analyze(doc);
console.log(result.lufs, result.truePeak, result.loudnessRange);
```
## Usage: Advanced
```ts
import { LoudnessMeter } from "@suaveplan/audio-analysis-loudness";
const meter = new LoudnessMeter({ blockSize: 400, overlap: 0.75, targetLufs: -23 });
// Streaming integrated loudness — final yield equals batch analyze.
const ctrl = new AbortController();
for await (const partial of meter.analyzeStream(doc, { signal: ctrl.signal })) {
ui.updateLoudness(partial.lufs);
}
// Real-time block meter — call on every audio callback.
const partial = meter.analyzeBlock([left, right], {
sampleRate: 48000,
channelCount: 2,
});
console.log(partial.lufs, partial.peak, partial.rms);
```
## API Reference
- `class LoudnessMeter`
- `readonly name: "loudness-meter"`, `readonly description: string`,
`readonly version: string`.
- `constructor(options?: LoudnessMeterOptions)`.
- `analyze(doc, opts?) => Promise<LoudnessResult>`.
- `analyzeStream(doc, opts?) => AsyncGenerator<LoudnessResult>`.
- `analyzeBlock(block, ctx) => Partial<LoudnessResult>`.
- `LoudnessMeterOptions` — `blockSize`, `overlap`, `targetLufs`, `signal`.
- `LoudnessResult` extends `AnalysisResult` with `lufs`, `loudnessRange`,
`truePeak`, `rms`, `peak`, `dcOffset[]`.
## Implementation Notes
`analyze` and `analyzeStream` share one `GatingEngine`. The streaming path
yields the engine's running state after every gating block; the batch path
runs the same engine to completion. The invariant is that the last
`analyzeStream` yield equals the `analyze` result for the same document.
`analyzeBlock` keeps a per-instance scratch K-weighting filter so live-meter
callers do not allocate on every audio callback. The scratch filter is
re-built lazily when `(sampleRate, channelCount)` changes.

View File

@@ -0,0 +1,63 @@
# @suaveplan/audio-analysis-pitch - Universal pitch, key, and chord detection AnalysisOp plugins for the @suaveplan/audio-* family. YIN/McLeod/cepstrum f0 estimation, Krumhansl-Schmuckler / Temperley key finding, chromagram template-matched chord progressions with temporal smoothing.
Universal pitch, key, and chord detection Analysis Op plugins for the `@suaveplan/audio-*` family. Three detectors:
- `PitchDetector` — fundamental frequency via YIN (default), McLeod, or cepstrum.
- `KeyDetector` — Krumhansl-Schmuckler / Temperley key finding over an aggregated chromagram.
- `ChordDetector` — per-frame chromagram template matching with temporal smoothing.
Runs identically in browser and server. No DOM or Node-only APIs. The package vendors its own real FFT so it has no hard dependency on `@suaveplan/audio-analysis-spectrum`.
<!-- begin:auto-docs -->
## Documentation tree
- [chord-matching](./src/algorithms/chord-matching.md) — `chord-matching.ts` identifies the closest chord template (major / minor / diminished / augmented over twelve roots — 4…
- [chromagram](./src/algorithms/chromagram.md) — `chromagram.ts` folds a spectral frame into the twelve pitch classes used by Western tonal analysis. Each magnitude bin…
- [downmix](./src/algorithms/downmix.md) — `downmix.ts` provides the per-block mono-folding utilities shared by all three detectors in `@suaveplan/audio-analysis-…
- [key-finding](./src/algorithms/key-finding.md) — `key-finding.ts` correlates an aggregated chromagram against 24 major/minor key profiles to estimate the musical key of…
- [yin](./src/algorithms/yin.md) — `yin.ts` ships three fundamental-frequency estimators behind a unified `PitchEstimate` shape so the `PitchDetector` can…
- [chord-detector](./src/detector/chord-detector.md) — `chord-detector.ts` ships the `ChordDetector` Analysis Op which produces a time-ordered chord progression for an `Audio…
- [key-detector](./src/detector/key-detector.md) — `key-detector.ts` ships the `KeyDetector` Analysis Op (plugin slot 3) which estimates the musical key of an `AudioDocum…
- [pitch-detector](./src/detector/pitch-detector.md) — `pitch-detector.ts` exposes the `PitchDetector` class — the Analysis Op plugin (plugin slot 3 in the `@suaveplan/audio-…
- [error-codes](./src/error-codes/error-codes.md) — `error-codes.ts` defines the stable `AUDIO_ANALYSIS_PITCH.*` error code registry, the metadata table describing each co…
- [error-codes](./src/error-codes/error-codes.md) — `error-codes.ts` defines the stable `AUDIO_ANALYSIS_PITCH.*` error code registry, the metadata table describing each co…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/audio-analysis-pitch
```
## Quick start
```ts
import {
ChordDetector,
KeyDetector,
PitchDetector,
} from "@suaveplan/audio-analysis-pitch";
const pitch = await new PitchDetector().analyze(doc);
const key = await new KeyDetector().analyze(doc);
const chords = await new ChordDetector().analyze(doc);
```
## Subpath exports
Every feature module is independently importable for fine-grained tree-shaking:
```ts
import { yinPitch } from "@suaveplan/audio-analysis-pitch/algorithms/yin";
import { detectKey } from "@suaveplan/audio-analysis-pitch/algorithms/key-finding";
import { PitchDetector } from "@suaveplan/audio-analysis-pitch/detector/pitch-detector";
```
## Error codes
All errors are `AudioAnalysisError`s carrying one of the `AUDIO_ANALYSIS_PITCH.*` codes: `INVALID_OPTIONS`, `ABORTED`, `UNSUPPORTED_FORMAT`, `INSUFFICIENT_DATA`.
## License
MIT

View File

@@ -0,0 +1,54 @@
# chord-matching
## Purpose
`chord-matching.ts` identifies the closest chord template (major / minor / diminished / augmented over twelve roots — 48 candidates) for a given per-frame chromagram, then smooths and merges the per-frame labels into a time-ordered chord progression. Smoothing is mandatory rather than optional because raw per-frame labels are noisy and a single off-frame would otherwise spawn a spurious chord.
The module exposes the raw matcher (`matchChord`), the smoothing pass (`smoothLabels`), and the run-merger (`mergeRuns`) so consumers can swap in their own smoothing strategy without losing the chromagram matching.
## Features
- 48 triad templates rotated to all twelve roots.
- Cosine-similarity scoring with normalisation against template and chromagram norms.
- Median-style single-pass smoother coercing solitary disagreements between identical neighbours.
- `mergeRuns` collapses identical adjacent labels into `Chord` records with `timestamp`, `duration`, and `confidence`.
- `minDuration` knob drops chord runs below a minimum length so transient frames never emit a standalone chord.
## Usage: Basic
```ts
import { matchChord, mergeRuns } from "@suaveplan/audio-analysis-pitch/algorithms/chord-matching";
const frameChord = matchChord(chroma); // { root: "C", quality: "major", confidence }
const chords = mergeRuns([frameChord, frameChord], { hopSeconds: 0.1, minDuration: 0 });
```
## Usage: Advanced
```ts
import {
type FrameChord,
matchChord,
mergeRuns,
smoothLabels,
} from "@suaveplan/audio-analysis-pitch/algorithms/chord-matching";
function detect(frames: Float32Array[], hopSeconds: number) {
const labels: FrameChord[] = frames.map(matchChord);
const smoothed = smoothLabels(labels);
return mergeRuns(smoothed, { hopSeconds, minDuration: 0.25 });
}
```
## API Reference
- `type ChordQuality = "major" | "minor" | "dim" | "aug"`.
- `interface FrameChord { root: string; quality: ChordQuality; confidence: number }`.
- `interface Chord { timestamp: number; duration: number; root: string; quality: ChordQuality; confidence: number }`.
- `matchChord(chroma: Float32Array): FrameChord`.
- `smoothLabels(labels: readonly FrameChord[]): FrameChord[]`.
- `mergeRuns(labels: readonly FrameChord[], { hopSeconds, minDuration }): Chord[]`.
## Implementation Notes
The matcher uses cosine similarity, so a chromagram with all-zero energy returns zero confidence rather than picking a random template by floating-point tiebreak. The smoother is single-pass and idempotent on already-clean runs. `mergeRuns` runs in O(N) over the smoothed label sequence and returns a fresh array — callers may mutate the result safely.

View File

@@ -0,0 +1,51 @@
# chromagram
## Purpose
`chromagram.ts` folds a spectral frame into the twelve pitch classes used by Western tonal analysis. Each magnitude bin is mapped to a pitch class via `pitchClass = round(12 * log2(freq / A4)) mod 12` and accumulated into a 12-element `Float32Array`. The chromagram is the shared substrate for both key-finding and chord template matching in this package.
The module relies on the vendored real FFT in `./yin.ts` so the chromagram path stays self-contained and does not require `@suaveplan/audio-analysis-spectrum` at runtime.
## Features
- `chromagramFromFrame(frame, sampleRate, minFreq?)` — per-frame 12-bin pitch-class profile.
- `normaliseChroma(chroma)` — in-place max-normalisation; idempotent on all-zero vectors.
- `accumulateChroma(acc, add)` — in-place sum, clamped to the shorter length so callers can pass differently sized buffers safely.
## Usage: Basic
```ts
import { chromagramFromFrame } from "@suaveplan/audio-analysis-pitch/algorithms/chromagram";
const chroma = chromagramFromFrame(frame, 48_000);
console.log("strongest pitch class:", chroma.indexOf(Math.max(...chroma)));
```
## Usage: Advanced
```ts
import {
accumulateChroma,
chromagramFromFrame,
normaliseChroma,
} from "@suaveplan/audio-analysis-pitch/algorithms/chromagram";
async function aggregateChroma(stream: AsyncIterable<Float32Array>, sr: number) {
const acc = new Float32Array(12);
for await (const frame of stream) {
accumulateChroma(acc, chromagramFromFrame(frame, sr));
}
normaliseChroma(acc);
return acc;
}
```
## API Reference
- `chromagramFromFrame(frame: Float32Array, sampleRate: number, minFreq = 30): Float32Array` (length 12).
- `normaliseChroma(chroma: Float32Array): Float32Array` — in-place, returns the same buffer.
- `accumulateChroma(acc: Float32Array, add: Float32Array): Float32Array` — clamped to `min(acc.length, add.length)`.
## Implementation Notes
Frames shorter than eight samples short-circuit to an all-zero chromagram. Energy below `minFreq` (default 30 Hz) and at or above the Nyquist is dropped to suppress hum and aliasing. All operations are O(N) over the FFT magnitude vector. Buffers are caller-owned — the module never allocates on the hot path beyond the result.

View File

@@ -0,0 +1,48 @@
# downmix
## Purpose
`downmix.ts` provides the per-block mono-folding utilities shared by all three detectors in `@suaveplan/audio-analysis-pitch`. Detectors only operate on a single mono channel, so each block emitted by `AudioDocument.stream()` is folded to a single `Float32Array` before analysis. Centralising this in one module avoids three duplicated implementations across the detector files and gives one well-tested code path for every block in the package.
## Features
- `downmixMono` averages an arbitrary number of channels into one `Float32Array`.
- Handles zero-channel, sparse-channel, and single-channel inputs without branching at every call site.
- `blockLength` is a tiny helper that returns the first channel's sample count or zero — the canonical way of reading the block size from a `doc.stream()` yield.
## Usage: Basic
```ts
import { blockLength, downmixMono } from "@suaveplan/audio-analysis-pitch/algorithms/downmix";
for await (const block of doc.stream()) {
const length = blockLength(block);
const mono = downmixMono(block, length);
// ... analyse mono
}
```
## Usage: Advanced
```ts
import { blockLength, downmixMono } from "@suaveplan/audio-analysis-pitch/algorithms/downmix";
function streamAnalyse(block: Float32Array[], analyse: (m: Float32Array) => number) {
const length = blockLength(block);
if (length === 0) return 0;
return analyse(downmixMono(block, length));
}
```
## API Reference
- `downmixMono(channels: readonly Float32Array[], length: number): Float32Array` — averages across channels.
- `blockLength(block: readonly Float32Array[]): number` — first-channel length or zero.
## Implementation Notes
The function tolerates sparse channel arrays (TypeScript's `readonly Float32Array[]` could in theory hold `undefined`); it checks each channel and skips undefined slots. The output length is caller-determined so callers can pre-allocate for known block sizes. The function never allocates per-sample and runs in O(channels × length).
The averaging is unweighted across channels — for a stereo block of equal amplitude tones in both ears the output is the same single-channel waveform. For richer downmixing (ITU-R BS.775 surround coefficients, equal-power panning, etc.) callers should fold the input themselves before passing a one-channel array to the detector entry points. Pitch and key detection are mostly invariant to the choice of downmix, so the simple mean is the right default and any pluggable mixer would be premature complexity in this module.
`blockLength` returns a number rather than the channel reference because every consumer in this package wants the length, never the array. Reading the length once and passing it through `downmixMono` lets the compiler hoist the bounds check and avoids re-reading `block[0]?.length` inside the hot loop.

View File

@@ -0,0 +1,47 @@
# key-finding
## Purpose
`key-finding.ts` correlates an aggregated chromagram against 24 major/minor key profiles to estimate the musical key of a passage. Two profile sets ship: the classic Krumhansl-Schmuckler perceptual weights and Temperley's revised weights which reduce relative-major/minor confusion.
The estimator returns the best-scoring key together with a `confidence` derived from the margin between the top and runner-up correlation, giving callers a usable proxy for how decisive the result was.
## Features
- 12 tonics × 2 modes = 24 candidate keys scored per call.
- Two interchangeable profile sets via the `profileSet` argument.
- Pearson correlation as the scoring function — robust to multiplicative gain.
- Stable pitch-class naming via `KEY_PROFILE_NAMES`.
- Numerically safe: zero-denom Pearson collapses to zero rather than NaN.
## Usage: Basic
```ts
import { detectKey } from "@suaveplan/audio-analysis-pitch/algorithms/key-finding";
const chroma = new Float32Array([6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88]);
const { key, confidence } = detectKey(chroma);
console.log(key); // "C major"
```
## Usage: Advanced
```ts
import { detectKey, type KeyProfileSet } from "@suaveplan/audio-analysis-pitch/algorithms/key-finding";
function compareProfiles(chroma: Float32Array) {
const sets: KeyProfileSet[] = ["krumhansl-schmuckler", "temperley"];
return sets.map((set) => ({ set, ...detectKey(chroma, set) }));
}
```
## API Reference
- `type KeyProfileSet = "krumhansl-schmuckler" | "temperley"`.
- `interface KeyEstimate { key: string; mode: "major" | "minor"; tonic: number; confidence: number }`.
- `detectKey(chroma: Float32Array, profileSet?: KeyProfileSet): KeyEstimate`.
- `KEY_PROFILE_NAMES: readonly string[]` — `["C", "C#", "D", …]`.
## Implementation Notes
Profiles are rotated to each tonic with O(1) lookup; for 24 candidates this is 24 Pearson computations per call, each O(12). The confidence is the normalised margin `(best - second) / max(|best|, ε)` clamped to `[0, 1]`. If the chromagram has zero variance the Pearson denominator collapses to zero and the function returns C major with zero confidence rather than NaN.

View File

@@ -0,0 +1,57 @@
# yin
## Purpose
`yin.ts` ships three fundamental-frequency estimators behind a unified `PitchEstimate` shape so the `PitchDetector` can swap algorithms via the `algorithm` option without callers branching on the result type. The default is YIN (de Cheveigné & Kawahara, 2002), with McLeod Pitch Method (MPM) and a real-cepstrum estimator available for material where they outperform YIN.
This file also vendors a small radix-2 real FFT plus `nextPow2` / `realFftMagnitude` helpers consumed by the chromagram and cepstrum paths so the package has no hard dependency on `@suaveplan/audio-analysis-spectrum`.
## Features
- `yinPitch` — difference function, cumulative mean normalised difference, absolute threshold pick at 0.1, parabolic interpolation.
- `mcleodPitch` — NSDF + key-maxima picking with threshold-relative-to-global-max.
- `cepstrumPitch` — log-magnitude inverse FFT with quefrency peak in the lag window.
- Robust no-pitch result: returns `pitch: 0`, `confidence: 0` when the input is silent or has no detectable periodicity.
- Vendored radix-2 FFT for self-contained operation.
## Usage: Basic
```ts
import { yinPitch } from "@suaveplan/audio-analysis-pitch/algorithms/yin";
const frame = new Float32Array(4096);
for (let i = 0; i < frame.length; i++) {
frame[i] = Math.sin((2 * Math.PI * 440 * i) / 48_000);
}
const { pitch, confidence } = yinPitch(frame, 48_000, 20, 20_000);
```
## Usage: Advanced
```ts
import {
cepstrumPitch,
mcleodPitch,
yinPitch,
} from "@suaveplan/audio-analysis-pitch/algorithms/yin";
function multiAlgorithmEnsemble(frame: Float32Array, sr: number) {
const yin = yinPitch(frame, sr, 50, 2_000);
const mpm = mcleodPitch(frame, sr, 50, 2_000);
const cep = cepstrumPitch(frame, sr, 50, 2_000);
return [yin, mpm, cep].sort((a, b) => b.confidence - a.confidence)[0];
}
```
## API Reference
- `interface PitchEstimate { pitch: number; confidence: number }`.
- `yinPitch(frame, sampleRate, minFreq, maxFreq): PitchEstimate`.
- `mcleodPitch(frame, sampleRate, minFreq, maxFreq, threshold = 0.85): PitchEstimate`.
- `cepstrumPitch(frame, sampleRate, minFreq, maxFreq): PitchEstimate`.
- `nextPow2(n: number): number`.
- `realFftMagnitude(frame: Float32Array): Float32Array`.
## Implementation Notes
YIN's CMNDF gives lag-zero a value of 1, suppressing the trivial autocorrelation peak. MPM walks the NSDF from the first negative crossing onward to avoid the same trivial peak. Parabolic interpolation around the chosen lag yields sub-sample precision. The cepstrum path is sample-rate-bound in resolution (one bin equals `sr/period²`), so its accuracy is coarser than YIN/MPM — typically within ±20 Hz at 48 kHz for a 4096-sample frame.

View File

@@ -0,0 +1,49 @@
# chord-detector
## Purpose
`chord-detector.ts` ships the `ChordDetector` Analysis Op which produces a time-ordered chord progression for an `AudioDocument`. It computes a per-frame chromagram, template-matches every frame against major/minor/dim/aug triad templates across all twelve roots, then smooths and merges adjacent identical labels so the result is a sequence of `Chord` records rather than 48 templates × thousands of frames.
The smoothing pass is part of the algorithm, not a tuning knob — raw per-frame labels are too noisy to be usable by application code without it.
## Features
- 48 triad templates over twelve roots × four qualities (major, minor, diminished, augmented).
- Built-in median-style smoothing pass eliminating single-frame spurious labels.
- `minDuration` knob drops chord runs shorter than the smoothing window.
- `analyze` aggregates the whole document; `analyzeStream` yields refining progressions.
- `AbortSignal` honoured at every block boundary; Zod validation on options; OTel instrumentation per run.
## Usage: Basic
```ts
import { ChordDetector } from "@suaveplan/audio-analysis-pitch";
const detector = new ChordDetector();
const { chords } = await detector.analyze(doc);
for (const c of chords) console.log(c.timestamp, c.root, c.quality);
```
## Usage: Advanced
```ts
import { ChordDetector } from "@suaveplan/audio-analysis-pitch";
const detector = new ChordDetector();
const opts = { frameSize: 8_192, minDuration: 0.5 };
let latest = "(none)";
for await (const r of detector.analyzeStream(doc, opts)) {
const last = r.chords[r.chords.length - 1];
if (last !== undefined) latest = `${last.root} ${last.quality}`;
}
```
## API Reference
- `class ChordDetector { name: "chord-detector"; version: string; description: string; analyze(doc, opts?); analyzeStream(doc, opts?) }`.
- `interface ChordDetectorOptions { frameSize?, minDuration?, signal? }`.
- `interface ChordResult extends AnalysisResult { op: "chord-detector"; chords: readonly Chord[]; timestamp; duration }`.
## Implementation Notes
Per-frame matching is O(48 × 12) = O(576) cosine similarities per frame and easily fits in the FFT budget. The streaming variant recomputes the merged progression on every yield, which is O(N) over the per-frame labels collected so far; for hour-long documents callers may prefer `analyze` once to avoid the cumulative-merge cost. `ChordResult.chords` is a fresh array on every yield so consumers may keep references safely.

View File

@@ -0,0 +1,51 @@
# key-detector
## Purpose
`key-detector.ts` ships the `KeyDetector` Analysis Op (plugin slot 3) which estimates the musical key of an `AudioDocument` by accumulating a 12-bin chromagram across the analysed range and correlating it against Krumhansl-Schmuckler or Temperley key profiles. It surfaces both whole-document analysis and a streaming generator whose confidence rises as more audio is consumed.
The detector consumes only the universal chromagram and key-finding utilities in this package; it ships no DOM or Node-only code paths and runs identically in browser, Bun, and Node.
## Features
- Two interchangeable profile sets (`krumhansl-schmuckler`, `temperley`).
- `analyze` aggregates a chromagram across the full document.
- `analyzeStream` yields refining `KeyResult`s as audio is consumed.
- `AbortSignal` support honoured at every block boundary.
- Zod validation on every options object.
- OTel spans + counters + histograms per run.
## Usage: Basic
```ts
import { KeyDetector } from "@suaveplan/audio-analysis-pitch";
const detector = new KeyDetector();
const result = await detector.analyze(doc);
console.log(result.key, "(confidence", result.confidence, ")");
```
## Usage: Advanced
```ts
import { KeyDetector } from "@suaveplan/audio-analysis-pitch";
const detector = new KeyDetector();
const opts = { profile: "temperley" as const, frameSize: 8_192 };
for await (const r of detector.analyzeStream(doc, opts)) {
if (r.confidence > 0.8) {
console.log("settled key:", r.key);
break;
}
}
```
## API Reference
- `class KeyDetector { name: "key-detector"; version: string; description: string; analyze(doc, opts?); analyzeStream(doc, opts?) }`.
- `interface KeyDetectorOptions { profile?, frameSize?, signal? }`.
- `interface KeyResult extends AnalysisResult { op: "key-detector"; key; confidence; chromagram; timestamp; duration }`.
## Implementation Notes
The detector accumulates chromagrams in a single 12-element `Float32Array` reused across the entire stream — no per-block allocation beyond the chroma vector itself. `KeyResult.chromagram` is a defensive copy via `chroma.slice()` so consumers cannot mutate the internal accumulator. The streaming variant computes `detectKey` on every yield, which is O(12 × 24) and well below the cost of the FFT it runs alongside.

View File

@@ -0,0 +1,51 @@
# pitch-detector
## Purpose
`pitch-detector.ts` exposes the `PitchDetector` class — the Analysis Op plugin (plugin slot 3 in the `@suaveplan/audio-*` family) that estimates the fundamental frequency of an `AudioDocument`. It supports whole-document analysis (`analyze`), streaming refinement (`analyzeStream`), and synchronous single-block analysis (`analyzeBlock`) for real-time visualisation.
Three estimators are selectable per call — YIN (default), McLeod, and cepstrum — letting callers pick the right trade-off for the material at hand without coupling to a different package.
## Features
- Three pitch algorithms behind the `algorithm` option.
- Configurable frequency range via `minFreq` / `maxFreq` and frame size.
- `AbortSignal` support across all three entry points.
- Zod boundary validation on every options object.
- OpenTelemetry spans per detector run, counter for total runs, histograms for run and block duration.
- Stable error codes (`INVALID_OPTIONS`, `ABORTED`, `UNSUPPORTED_FORMAT`, `INSUFFICIENT_DATA`) all routable through `AudioAnalysisError`.
## Usage: Basic
```ts
import { PitchDetector } from "@suaveplan/audio-analysis-pitch";
const detector = new PitchDetector();
const result = await detector.analyze(doc);
console.log(result.pitch, "Hz", "midi:", result.midiNote);
```
## Usage: Advanced
```ts
import { PitchDetector } from "@suaveplan/audio-analysis-pitch";
const detector = new PitchDetector();
const controller = new AbortController();
const opts = { algorithm: "mcleod" as const, minFreq: 50, maxFreq: 1_000, signal: controller.signal };
const out: number[] = [];
for await (const r of detector.analyzeStream(doc, opts)) {
out.push(r.pitch);
if (out.length >= 100) controller.abort();
}
```
## API Reference
- `class PitchDetector { name: "pitch-detector"; version: string; description: string; analyze(doc, opts?); analyzeStream(doc, opts?); analyzeBlock(block, ctx) }`.
- `interface PitchDetectorOptions { algorithm?, minFreq?, maxFreq?, frameSize?, hopSize?, signal? }`.
- `interface PitchResult extends AnalysisResult { op: "pitch-detector"; pitch; midiNote; confidence; timestamp; duration }`.
## Implementation Notes
`analyze` aggregates results by keeping the highest-confidence pitch across all frames. `analyzeStream` yields per-block estimates with a running max confidence so the stream is non-decreasing in expectation. Both honour an `AbortSignal` checked at every block boundary. `analyzeBlock` is allocation-light: it skips the Zod schema (the block path is hot) and uses the package defaults.

View File

@@ -0,0 +1,64 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`error-codes.ts` defines the stable `AUDIO_ANALYSIS_PITCH.*` error code registry, the metadata table describing each code, and the `AudioAnalysisError` class used by every entry point in the package. The codes give callers a stable contract for distinguishing categories of failures (option-validation, abort, unsupported format, insufficient data) without parsing error messages.
The class extends `SuaveplanError` so it integrates with the project-wide error pipeline (OTel span events, cause chaining, redacted serialisation) while carrying a discriminant `kind` property and a derived `category` field so consumers can switch on the failure shape without inspecting the string code.
## Features
- Frozen metadata table covering all four codes with description and severity.
- `AudioAnalysisError` extends `SuaveplanError<AudioAnalysisPitchErrorCode>` with `kind` and `category` discriminants.
- `registerAudioAnalysisPitchErrorCodes()` idempotent registration shim.
- Self-registers at import time so the codes are reachable to global error registries.
## Usage: Basic
```ts
import {
AUDIO_ANALYSIS_PITCH_CODES,
AudioAnalysisError,
} from "@suaveplan/audio-analysis-pitch";
throw new AudioAnalysisError(
AUDIO_ANALYSIS_PITCH_CODES.INVALID_OPTIONS,
"audio.analysis.pitch.invalid-options",
);
```
## Usage: Advanced
```ts
import {
AUDIO_ANALYSIS_PITCH_CODES,
AUDIO_ANALYSIS_PITCH_ERROR_METADATA,
AudioAnalysisError,
} from "@suaveplan/audio-analysis-pitch";
try {
await detector.analyze(doc, opts);
} catch (err) {
if (err instanceof AudioAnalysisError) {
const meta = AUDIO_ANALYSIS_PITCH_ERROR_METADATA[err.code];
logger.warn({ code: err.code, severity: meta.severity }, err.message);
if (err.category === "abort") return;
}
throw err;
}
```
## API Reference
- `AUDIO_ANALYSIS_PITCH_CODES` — frozen `{ INVALID_OPTIONS, ABORTED, UNSUPPORTED_FORMAT, INSUFFICIENT_DATA }`.
- `AUDIO_ANALYSIS_PITCH_ERROR_METADATA: Record<AudioAnalysisPitchErrorCode, { description, severity }>`.
- `class AudioAnalysisError extends SuaveplanError<AudioAnalysisPitchErrorCode>` — `kind: "AudioAnalysisError"`, `category: "options" | "abort" | "data" | "format"`.
- `registerAudioAnalysisPitchErrorCodes(): void` — idempotent.
## Implementation Notes
The class adds a `category` field whose computation runs in the constructor body, ensuring the explicit constructor cannot be auto-collapsed by formatters. The metadata table is frozen with `Object.freeze` for shallow immutability. The `_registered` guard ensures `registerAudioAnalysisPitchErrorCodes` may be called multiple times safely from package consumers without side effects.

View File

@@ -0,0 +1,62 @@
# @suaveplan/audio-analysis-spectrum - Universal spectral analysis: real FFT, Hann/Hamming/Blackman windows, mel filterbank, DCT-II, SpectrumAnalyzer/Spectrogram/MFCC analysis ops with OTel instrumentation. Zero browser or Node dependencies.
Universal spectral analysis for the Suaveplan audio stack: real FFT, Hann/Hamming/Blackman windows, mel filterbank, DCT-II, and three high-level analyzers — `SpectrumAnalyzer`, `Spectrogram`, `MFCC`. Zero browser or Node-only dependencies.
<!-- begin:auto-docs -->
## Documentation tree
- [dct](./src/algorithms/dct.md) — The DCT-II module is the cepstral projection step of the MFCC pipeline: it takes the log mel-spectrum of one frame and…
- [fft](./src/algorithms/fft.md) — The FFT module is the numerical core every analyzer in this package depends on. It runs an in-place Cooley–Tukey radix-…
- [mel-scale](./src/algorithms/mel-scale.md) — The mel-scale module provides the perceptual frequency warp used by MFCC and any caller that wants a psychoacoustically…
- [window](./src/algorithms/window.md) — Window functions taper the endpoints of each analysis frame so the FFT sees a periodic-looking signal rather than the a…
- [dsp](./src/analyzer/dsp.md) — The dsp module is the per-frame DSP toolbox every analyzer in this package leans on. It owns the document→mono read pat…
- [mfcc](./src/analyzer/mfcc.md) — `MFCC` is the speech/music feature extractor of this package: per frame it builds the magnitude spectrum, applies a tri…
- [runtime](./src/analyzer/runtime.md) — The runtime module is the shared spine for `SpectrumAnalyzer`, `Spectrogram`, and `MFCC`. It owns option parsing throug…
- [spectrogram](./src/analyzer/spectrogram.md) — `Spectrogram` produces the ordered list of per-frame magnitude spectra for a document, slicing by the same `frameSize`/…
- [spectrum-analyzer](./src/analyzer/spectrum-analyzer.md) — `SpectrumAnalyzer` is the workhorse analysis op of this package: it slices an `AudioDocument` into overlapping frames,…
- [error-codes](./src/error-codes/error-codes.md) — The error-codes module owns the stable `AUDIO_ANALYSIS_SPECTRUM.*` code registry used by every analyzer in this package…
- [error-codes](./src/error-codes/error-codes.md) — The error-codes module owns the stable `AUDIO_ANALYSIS_SPECTRUM.*` code registry used by every analyzer in this package…
<!-- end:auto-docs -->
## What's in the box
- **Algorithms** (`./algorithms/*`)
- `fft` — Cooley–Tukey radix-2 real-input FFT for every power of two in `[256, 32768]`.
- `window` — Hann, Hamming, Blackman windows with per-`(name, size)` caching.
- `mel-scale` — Slaney/HTK hz↔mel mapping plus a cached triangular filterbank.
- `dct` — type-II DCT with cached kernel matrix.
- **Analyzers** (`./analyzer/*`)
- `SpectrumAnalyzer` — magnitude spectrum + centroid/rolloff/flux, `analyze` / `analyzeStream` / `analyzeBlock`.
- `Spectrogram` — time-frequency magnitude matrix, `analyze` / `analyzeStream`.
- `MFCC` — 13-coefficient mel-frequency cepstral coefficients per frame.
- **Error codes** (`./error-codes`) — `AUDIO_ANALYSIS_SPECTRUM.{INVALID_OPTIONS, INVALID_FRAME, ABORTED}` via `defineCodes`.
- **OTel instrumentation** — `audio.analysis.runs_total`, `audio.analysis.duration_ms`, `audio.analysis.block_duration_ms` counters/histograms; a span per `analyze`/`analyzeStream` invocation. Inert without an exporter.
## Quick start
```ts
import { SpectrumAnalyzer } from "@suaveplan/audio-analysis-spectrum";
import { AudioDocument } from "@suaveplan/audio-core/document/document";
const analyzer = new SpectrumAnalyzer();
const result = await analyzer.analyze(doc, {
bins: 128,
frameSize: 2048,
hopSize: 512,
window: "hann",
});
console.log(result.centroid, result.rolloff, result.flux);
```
## Universal guarantees
- Imports zero browser globals (`window`, `document`, `AudioContext`, `AnalyserNode`).
- Imports zero Node-only modules (`node:fs`, `node:path`, `process`, `Buffer`).
- Runtime deps: `@suaveplan/audio-core`, `@suaveplan/error`, `@suaveplan/types`, `@opentelemetry/api`, `tslib`, `zod`.
- Each analyzer is sidecar — analysis never mutates the source `AudioDocument`.
## Status
`0.1.0`. Pre-publish gate (lint + typecheck + test) and 100% coverage are required on every change.

View File

@@ -0,0 +1,44 @@
# `dct` — type-II discrete cosine transform
## Purpose
The DCT-II module is the cepstral projection step of the MFCC pipeline: it takes the log mel-spectrum of one frame and produces a compact vector of cepstral coefficients. The same routine is reusable anywhere a closed-form orthonormal-with-scale DCT is needed.
This implementation is unscaled (no `sqrt(2/N)` orthonormality factor) because downstream MFCC analysis is unit-agnostic: every coefficient is later compared to peers, not to an external reference. Callers that need an orthonormal form can post-multiply by the standard scaling factor.
## Features
- Closed-form `X_k = Σ x_n · cos((π/N)(n + ½)k)` for `0 ≤ k < coefficients`.
- Cached per `(inputSize, coefficients)` so repeated frames reuse the kernel matrix.
- Pure portable TypeScript over `Float32Array` inputs and outputs.
## Usage: Basic
```ts
import { dctII } from "@suaveplan/audio-analysis-spectrum/algorithms/dct";
const logMel = new Float32Array(40); // log mel-spectrum
const mfcc = dctII(logMel, 13);
```
## Usage: Advanced
```ts
import {
clearDctCache,
dctII,
} from "@suaveplan/audio-analysis-spectrum/algorithms/dct";
const coeffs = dctII(logMel, 20);
// later, before a benchmark sweep
clearDctCache();
```
## API Reference
- `dctII(input, coefficients): Float32Array` — first `coefficients` cepstral values.
- `clearDctCache(): void` — evict every cached kernel.
## Implementation Notes
The kernel is stored as a flat `Float64Array` of `coefficients * inputSize` doubles so the per-frame inner loop is a single linear pass with no nested allocation. Kernel construction is `O(coefficients · inputSize)` and is cached on the `(inputSize, coefficients)` pair; runtime cost is then a single matrix-vector multiply per frame. Boundary checks on `coefficients` (positive, `≤ melBands`) are enforced at the analyzer layer before this routine is called, so this module assumes a validated input.

View File

@@ -0,0 +1,50 @@
# `fft` — real-input FFT over power-of-two frames
## Purpose
The FFT module is the numerical core every analyzer in this package depends on. It runs an in-place Cooley–Tukey radix-2 decimation-in-time transform over a windowed real-valued frame and returns the non-redundant `frameSize / 2 + 1` magnitude bins (and optionally the matching phase array). Twiddle-factor tables and the bit-reverse index are built once per frame size and cached, so repeated frames pay only the per-call working-buffer allocation.
The transform is deterministic — the same input bytes always yield byte-identical magnitudes — and is portable TypeScript with no platform globals, satisfying the universal purity requirement.
## Features
- Radix-2 in-place Cooley–Tukey for every power of two in `[256, 32768]`.
- Cached twiddle (`cos`/`sin` over the unit circle) and bit-reverse tables.
- Optional phase output via `{ withPhase: true }`.
- Boundary `assertValidFrameSize` for callers that own their own input shape.
- `clearFFTCache` for cold-start benchmark measurement.
## Usage: Basic
```ts
import { transform } from "@suaveplan/audio-analysis-spectrum/algorithms/fft";
const frame = new Float32Array(2048);
// fill `frame` with a windowed signal...
const { magnitude } = transform(frame);
```
## Usage: Advanced
```ts
import {
assertValidFrameSize,
clearFFTCache,
transform,
} from "@suaveplan/audio-analysis-spectrum/algorithms/fft";
assertValidFrameSize(4096); // throws AUDIO_ANALYSIS_SPECTRUM.INVALID_FRAME if wrong
const { magnitude, phase } = transform(frame, { withPhase: true });
clearFFTCache(); // evict cached twiddle tables before a benchmark sweep
```
## API Reference
- `transform(frame, opts?): FFTResult` — per-bin `Float32Array` magnitude (length `frameSize/2+1`) plus optional phase.
- `assertValidFrameSize(frameSize): number` — throws `AUDIO_ANALYSIS_SPECTRUM.INVALID_FRAME` if not a power of two in `[256, 32768]`.
- `clearFFTCache(): void` — drop every cached twiddle/bit-reverse table.
- `FFT_FRAME_MIN`, `FFT_FRAME_MAX` — the inclusive bounds enforced above.
## Implementation Notes
Twiddle storage is `Float64Array` to keep the rotation pole-bound errors in float64 even though the output is downcast to `Float32Array`. The bit-reverse table is `Uint32Array` rather than computed per call. Each frame pays the cost of two length-`size` `Float64Array` working buffers, which dominates the per-call allocation budget but avoids leaking mutable state between calls. A pure sinusoid at a bin-centre frequency produces one dominant magnitude bin; an impulse produces a flat magnitude spectrum; both are tested as the floor of correctness.

View File

@@ -0,0 +1,58 @@
# `mel-scale` — hertz↔mel mapping and triangular filterbank
## Purpose
The mel-scale module provides the perceptual frequency warp used by MFCC and any caller that wants a psychoacoustically motivated reduction of an FFT magnitude spectrum. It exposes the Slaney/HTK closed-form `hzToMel` and its inverse `melToHz`, plus a builder that produces a triangular filterbank for a given `(frameSize, sampleRate, melBands, minHz, maxHz)` tuple.
Each filterbank is cached so repeated frames pay zero filter-construction cost; applying a filterbank to a magnitude spectrum is then a single dense matrix-vector multiply.
## Features
- Closed-form hertz↔mel mapping that round-trips to machine precision.
- Triangular filterbank with `melBands` bands evenly spaced on the mel scale.
- Per-spec caching keyed by `(frameSize, sampleRate, melBands, minHz, maxHz)`.
- Boundary validation that `melBands` is a positive integer.
## Usage: Basic
```ts
import {
applyMelFilterbank,
buildMelFilterbank,
} from "@suaveplan/audio-analysis-spectrum/algorithms/mel-scale";
const fb = buildMelFilterbank({
frameSize: 2048,
sampleRate: 48_000,
melBands: 40,
minHz: 0,
maxHz: 24_000,
});
const mel = applyMelFilterbank(spectrum, fb);
```
## Usage: Advanced
```ts
import {
clearMelFilterbankCache,
hzToMel,
melToHz,
} from "@suaveplan/audio-analysis-spectrum/algorithms/mel-scale";
const m = hzToMel(1000);
const back = melToHz(m); // ≈ 1000 within 1e-3
clearMelFilterbankCache(); // before a benchmark sweep
```
## API Reference
- `hzToMel(hz): number` / `melToHz(mel): number` — Slaney/HTK closed forms.
- `buildMelFilterbank(spec): MelFilterbank` — cached triangular filterbank.
- `applyMelFilterbank(spectrum, bank): Float32Array` — dense mel-band vector.
- `clearMelFilterbankCache(): void` — drop every cached filterbank.
## Implementation Notes
The builder allocates `melBands + 2` mel-spaced points so each band has both a lower and an upper neighbour centre. Bin frequencies are computed once as `(i * sampleRate) / frameSize` to avoid a per-band recomputation. Each band's weights peak at `1` and taper linearly to `0` at the neighbour centres; no per-band energy normalisation is applied because the downstream MFCC log+DCT step is insensitive to global scaling.

View File

@@ -0,0 +1,56 @@
# `window` — discrete window functions
## Purpose
Window functions taper the endpoints of each analysis frame so the FFT sees a periodic-looking signal rather than the abrupt discontinuities created by a rectangular slice. Tapering reduces spectral leakage at the cost of a small main-lobe widening, which is the standard trade-off used by every spectral analyzer in this package.
The module exposes three closed-form windows — Hann, Hamming, and Blackman — selectable by the `window` option of `SpectrumAnalyzerOptions`. Vectors are cached per `(name, size)` so the spectrum, spectrogram, and MFCC analyzers share a single allocation across every frame of the same shape.
## Features
- Hann, Hamming, Blackman generators producing `Float32Array` vectors.
- Cached per `(name, size)` to avoid re-allocation on every frame.
- `applyWindow` for in-place multiplication into a caller-provided buffer.
- `clearWindowCache` for benchmarks measuring cold-start cost.
## Usage: Basic
```ts
import {
applyWindow,
getWindow,
} from "@suaveplan/audio-analysis-spectrum/algorithms/window";
const w = getWindow("hann", 2048);
const frame = new Float32Array(2048);
const out = new Float32Array(2048);
applyWindow(frame, w, out);
```
## Usage: Advanced
```ts
import {
clearWindowCache,
getWindow,
} from "@suaveplan/audio-analysis-spectrum/algorithms/window";
// Pre-warm the cache for the working set.
for (const name of ["hann", "hamming", "blackman"] as const) {
for (const size of [1024, 2048, 4096]) getWindow(name, size);
}
// Later, before a benchmark, evict every vector.
clearWindowCache();
```
## API Reference
- `getWindow(name, size): Float32Array` — cached window vector.
- `applyWindow(frame, window, out): void` — element-wise multiply.
- `clearWindowCache(): void` — drop every cached vector.
- `WindowName = "hann" | "hamming" | "blackman"`.
## Implementation Notes
Each closed form is the standard periodic definition with `N - 1` in the denominator, so the endpoints of Hann and Blackman are exact zeros and the centre is exactly `1`. Hamming uses the `(0.54, 0.46)` coefficients from Hamming's 1989 paper. The cache key is `${name}:${size}`; since both inputs are tightly constrained by the analyzer layer the key set is small.

View File

@@ -0,0 +1,69 @@
# `dsp` — shared signal-processing helpers
## Purpose
The dsp module is the per-frame DSP toolbox every analyzer in this package leans on. It owns the document→mono read path, frame windowing+FFT, log-spaced bucket-mean reduction from the FFT bins down to the configured `bins`, and the three scalar spectral features (centroid, rolloff, flux). Centralising these primitives keeps `spectrum-analyzer.ts`, `spectrogram.ts`, and `mfcc.ts` thin orchestration layers focused on result-envelope construction.
## Features
- `readMono` — fold an `AudioDocument` into a single channel-0 `Float32Array`.
- `frameMagnitude` — slice + window + FFT in one call, returning the magnitude bin vector.
- `frameCount` — hop-arithmetic frame count for a given document length.
- `reduceToBins` — average FFT magnitude bins into `bins` log-spaced buckets.
- `spectralCentroid`/`spectralRolloff`/`spectralFlux` — closed-form scalar features.
- `logFloor` — `Math.log` floored to `1e-10` so empty mel bands don't produce `-Infinity`.
## Usage: Basic
```ts
import {
frameCount,
frameMagnitude,
readMono,
reduceToBins,
spectralCentroid,
} from "@suaveplan/audio-analysis-spectrum/analyzer/dsp";
const samples = await readMono(doc);
const count = frameCount(samples.length, 2048, 512);
for (let i = 0; i < count; i++) {
const mag = frameMagnitude(samples, i * 512, 2048, "hann");
const peak = reduceToBins(mag, 128);
const centroidHz = spectralCentroid(mag, doc.sampleRate, 2048);
// ...
}
```
## Usage: Advanced
```ts
import {
logFloor,
spectralFlux,
spectralRolloff,
} from "@suaveplan/audio-analysis-spectrum/analyzer/dsp";
let previous: Float32Array | undefined;
for (let i = 0; i < count; i++) {
const mag = frameMagnitude(samples, i * 512, 2048, "hann");
const flux = spectralFlux(mag, previous);
const rolloffHz = spectralRolloff(mag, doc.sampleRate, 2048, 0.9);
const logMel = mag.map(logFloor);
previous = mag;
}
```
## API Reference
- `readMono(doc): Promise<Float32Array>` — fold channels.
- `frameMagnitude(samples, offset, frameSize, window): Float32Array`.
- `frameCount(length, frameSize, hopSize): number`.
- `reduceToBins(magnitude, bins): Float32Array`.
- `spectralCentroid(magnitude, sampleRate, frameSize): number`.
- `spectralRolloff(magnitude, sampleRate, frameSize, threshold?): number`.
- `spectralFlux(magnitude, previous): number`.
- `logFloor(value): number`.
## Implementation Notes
`reduceToBins` falls back to a direct copy when `bins >= magnitude.length`, since a strict bucket mean would be ill-defined; this is exercised by the spectrum-analyzer tests that request `bins ≥ frameSize/2 + 1`. The flux helper handles a `previous = undefined` first frame by returning `0`, which matches every textbook definition where the first frame has no predecessor.

View File

@@ -0,0 +1,51 @@
# `MFCC` — mel-frequency cepstral coefficients
## Purpose
`MFCC` is the speech/music feature extractor of this package: per frame it builds the magnitude spectrum, applies a triangular mel filterbank, takes the natural log of the mel energies (floored to `1e-10` so empty bands do not produce `-Infinity`), and runs a type-II DCT to produce `coefficients` cepstral values.
The default is 13 coefficients over 40 mel bands — the canonical MFCC-13 configuration used in speaker-ID and music-genre pipelines.
## Features
- 40-band mel filterbank by default; configurable via `melBands`.
- 13 coefficients by default; configurable via `coefficients` (`≤ melBands`).
- `[minHz, maxHz]` configurable; `maxHz` defaults to the Nyquist frequency.
- `analyzeStream` yields one coefficient vector at a time.
## Usage: Basic
```ts
import { MFCC } from "@suaveplan/audio-analysis-spectrum/analyzer/mfcc";
const analyzer = new MFCC();
const result = await analyzer.analyze(doc);
console.log(result.mfcc.length, result.mfcc[0]?.length); // frame count, 13
```
## Usage: Advanced
```ts
const analyzer = new MFCC();
for await (const partial of analyzer.analyzeStream(doc, {
frameSize: 4096,
hopSize: 1024,
melBands: 26,
coefficients: 20,
minHz: 80,
maxHz: 8000,
window: "hamming",
})) {
// partial.mfcc is a one-element array — accumulate as needed
}
```
## API Reference
- `name`, `description`, `analyze(doc, opts?)`, `analyzeStream(doc, opts?)`.
- `opts` includes `melBands`, `coefficients`, `minHz`, `maxHz`, `frameSize`, `hopSize`, `window`, `signal`.
- Result extends `AnalysisResult` with `op: "mfcc"` and `mfcc: Float32Array[]`.
## Implementation Notes
`coefficients > melBands` is an unconditional `AUDIO_ANALYSIS_SPECTRUM.INVALID_OPTIONS` because the DCT projection would be ill-defined. The log floor is `1e-10` — small enough to be invisible to every downstream comparator, large enough to keep `Math.log` finite. The mel filterbank is keyed on `(frameSize, sampleRate, melBands, minHz, maxHz)` so repeated frames of the same document share a single matrix.

View File

@@ -0,0 +1,57 @@
# `runtime` — shared analyzer plumbing
## Purpose
The runtime module is the shared spine for `SpectrumAnalyzer`, `Spectrogram`, and `MFCC`. It owns option parsing through the canonical Zod schema, abort-signal short-circuits, OTel span+counter+histogram instrumentation, and construction of the audio-core `AnalysisResult` envelope. Centralising this scaffolding means every analyzer stays focused on its DSP and the package as a whole emits identical telemetry shapes.
## Features
- `parseOptions` — single boundary into `SpectrumAnalyzerOptionsSchema` that throws `AUDIO_ANALYSIS_SPECTRUM.INVALID_OPTIONS` with the Zod issues.
- `ensureNotAborted` — throws `AUDIO_ANALYSIS_SPECTRUM.ABORTED` if a caller-supplied `AbortSignal` is already aborted.
- `startRun` — opens a span, records sample-rate/duration attributes, and returns `end`/`fail` callbacks bound to the runs counter and duration histogram.
- `recordBlock` — bumps the block-duration histogram for `analyzeBlock`.
- `buildBase` — produces the `AnalysisResult` envelope (`analyserName`, `analyserVersion`, `sampleRate`, `durationSamples`, `computedAt`).
## Usage: Basic
```ts
import {
buildBase,
ensureNotAborted,
parseOptions,
startRun,
} from "@suaveplan/audio-analysis-spectrum/analyzer/runtime";
const opts = parseOptions(raw);
ensureNotAborted(opts.signal);
const handle = startRun("spectrum-analyzer", doc, "analyze");
try {
// ... run the DSP ...
handle.end();
} catch (err) {
handle.fail(err);
throw err;
}
```
## Usage: Advanced
```ts
import { recordBlock } from "@suaveplan/audio-analysis-spectrum/analyzer/runtime";
const startedAt = performance.now();
const result = computeBlock();
recordBlock("spectrum-analyzer", performance.now() - startedAt);
```
## API Reference
- `parseOptions(raw): SpectrumAnalyzerOptions` — schema-validated options.
- `ensureNotAborted(signal): void` — throws `ABORTED` on pre-aborted signals.
- `startRun(op, doc, mode): { end, fail }` — OTel run handle.
- `recordBlock(op, durationMs): void` — record a single block timing.
- `buildBase(op, doc): { analyserName, analyserVersion, sampleRate, durationSamples, computedAt }`.
## Implementation Notes
Instrumentation uses `@opentelemetry/api` which short-circuits to a no-op when no SDK is registered, so importing this module mutates no global state and runs free in suites that don't configure a `MeterProvider`. Tags on the counter and histograms are intentionally minimal — `op` and `mode` — so cardinality stays bounded even with high analyzer fan-out.

View File

@@ -0,0 +1,48 @@
# `Spectrogram` — time-frequency magnitude matrix
## Purpose
`Spectrogram` produces the ordered list of per-frame magnitude spectra for a document, slicing by the same `frameSize`/`hopSize`/`window` options as `SpectrumAnalyzer`. The result is the canonical input for downstream beat, pitch, and chromagram analyzers. Documents shorter than `frameSize` cleanly yield an empty matrix rather than throwing.
`analyze` returns the entire matrix in one allocation; `analyzeStream` yields one frame at a time so a visualiser can update during the scan. Concatenating the per-frame matrices yielded by the stream matches `analyze`'s `spectrogram` exactly.
## Features
- Frame count matches `floor((doc.length - frameSize) / hopSize) + 1` exactly.
- Each row is the full `frameSize / 2 + 1` magnitude vector.
- Empty result for documents shorter than one frame — no error thrown.
- Streamed frames are deep-equal to the single-shot matrix.
## Usage: Basic
```ts
import { Spectrogram } from "@suaveplan/audio-analysis-spectrum/analyzer/spectrogram";
const analyzer = new Spectrogram();
const result = await analyzer.analyze(doc);
console.log(result.spectrogram.length, result.spectrogram[0]?.length);
```
## Usage: Advanced
```ts
const analyzer = new Spectrogram();
const all: Float32Array[] = [];
for await (const partial of analyzer.analyzeStream(doc, {
frameSize: 4096,
hopSize: 1024,
window: "blackman",
})) {
for (const frame of partial.spectrogram) all.push(frame);
}
```
## API Reference
- `name`, `description`, `analyze(doc, opts?)`, `analyzeStream(doc, opts?)`.
- `opts` includes `frameSize`, `hopSize`, `window`, `signal`.
- Result extends `AnalysisResult` with `op: "spectrogram"` and `spectrogram: Float32Array[]`.
## Implementation Notes
`analyzeStream` yields one `SpectrogramResult` per frame whose `spectrogram` array has length `1`. The empty-result case yields a single zero-frame result so callers can rely on the generator producing at least one event. Like `SpectrumAnalyzer`, the abort signal is checked before each frame to honour the mid-scan abort semantics.

View File

@@ -0,0 +1,62 @@
# `SpectrumAnalyzer` — magnitude spectrum + scalar features
## Purpose
`SpectrumAnalyzer` is the workhorse analysis op of this package: it slices an `AudioDocument` into overlapping frames, windows each frame, runs the real FFT, and reduces the per-bin magnitudes into a fixed-length `spectrum` plus three scalar features — centroid, rolloff, flux. The default 128-bin output is the standard size used by visualisers and feature pipelines.
The class implements `analyze` (single-shot whole document), `analyzeStream` (per-frame async generator suitable for real-time visualisation), and `analyzeBlock` (one already-windowed block via a `BlockContext`).
## Features
- Slices `doc` into `frameSize`/`hopSize` overlapping frames (defaults `2048`/`512`).
- Hann window by default; Hamming or Blackman selectable via `window`.
- Log-spaced bucket-mean reduction to the configured `bins` (default `128`).
- Centroid, 85% rolloff, and positive-flux scalars per result.
- `analyzeStream` yields per-frame results; the final frame's vectors match `analyze`.
- `analyzeBlock` runs a single FFT-sized block with `BlockContext.sampleRate`.
## Usage: Basic
```ts
import { SpectrumAnalyzer } from "@suaveplan/audio-analysis-spectrum/analyzer/spectrum-analyzer";
const analyzer = new SpectrumAnalyzer();
const result = await analyzer.analyze(doc);
console.log(result.centroid, result.rolloff, result.flux, result.spectrum);
```
## Usage: Advanced
```ts
const analyzer = new SpectrumAnalyzer();
const ctrl = new AbortController();
setTimeout(() => ctrl.abort(), 50);
try {
for await (const frame of analyzer.analyzeStream(doc, {
frameSize: 4096,
hopSize: 1024,
bins: 256,
window: "blackman",
signal: ctrl.signal,
})) {
updateVisualiser(frame.spectrum);
}
} catch (err) {
// AUDIO_ANALYSIS_SPECTRUM.ABORTED expected on early cancel.
}
const block = analyzer.analyzeBlock([new Float32Array(2048)], {
sampleRate: 48_000,
startSample: 0,
});
```
## API Reference
- `name`, `description`, `analyze(doc, opts?)`, `analyzeStream(doc, opts?)`, `analyzeBlock(block, ctx)`.
- `opts` includes `bins`, `frameSize`, `hopSize`, `window`, `signal`; defaults from the Zod schema.
- Result extends `AnalysisResult` with `op: "spectrum-analyzer"`, `spectrum`, `centroid`, `rolloff`, `flux`.
## Implementation Notes
The DSP pipeline lives in the `dsp` module so `SpectrumAnalyzer` stays an orchestrator. `analyze` retains only the final frame's `spectrum`/`magnitude` and the previous frame's `magnitude` so flux is well-defined. Each frame revalidates the abort signal between iterations, satisfying the mid-scan abort scenario. `analyzeBlock` does not consult an `AbortSignal` — block calls are bounded-time and the call site owns its own cancellation.

View File

@@ -0,0 +1,57 @@
# Error codes — `@suaveplan/audio-analysis-spectrum`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The error-codes module owns the stable `AUDIO_ANALYSIS_SPECTRUM.*` code registry used by every analyzer in this package. Each error thrown by the FFT, window, mel filterbank, DCT, `SpectrumAnalyzer`, `Spectrogram`, or `MFCC` carries one of these codes so consumers can branch on machine-readable identifiers rather than message strings.
The module is import-time pure — `registerAudioAnalysisSpectrumErrorCodes` is a no-op because metadata is already frozen at module load — but it is exported so OTel registries and report builders can declare an explicit dependency on the codes existing.
## Features
- Three stable codes: `INVALID_OPTIONS`, `INVALID_FRAME`, `ABORTED`.
- Frozen metadata record with `description` and `severity` for every code.
- `AudioAnalysisSpectrumError` subclass of `SuaveplanError` with a public constructor and a `category: "audio-analysis-spectrum"` discriminator.
## Usage: Basic
```ts
import {
AudioAnalysisSpectrumError,
AUDIO_ANALYSIS_SPECTRUM_CODES,
} from "@suaveplan/audio-analysis-spectrum/error-codes";
throw new AudioAnalysisSpectrumError(
AUDIO_ANALYSIS_SPECTRUM_CODES.ABORTED,
"audio-analysis-spectrum.aborted",
);
```
## Usage: Advanced
```ts
import {
AUDIO_ANALYSIS_SPECTRUM_CODES,
AUDIO_ANALYSIS_SPECTRUM_ERROR_METADATA,
} from "@suaveplan/audio-analysis-spectrum/error-codes";
for (const code of Object.values(AUDIO_ANALYSIS_SPECTRUM_CODES)) {
const meta = AUDIO_ANALYSIS_SPECTRUM_ERROR_METADATA[code];
console.log(code, meta.severity, meta.description);
}
```
## API Reference
- `AUDIO_ANALYSIS_SPECTRUM_CODES` — frozen record produced by `defineCodes`.
- `AudioAnalysisSpectrumErrorCode` — string-literal union of the three codes.
- `AUDIO_ANALYSIS_SPECTRUM_ERROR_METADATA` — `Record<code, { description; severity }>`.
- `AudioAnalysisSpectrumError` — `SuaveplanError` subclass with `category` and a public constructor.
- `registerAudioAnalysisSpectrumErrorCodes(): void` — idempotent no-op for startup hooks.
## Implementation Notes
The class widens `SuaveplanError`'s `protected` constructor to `public` via subclass declaration. The `category` literal field defeats biome's "useless class" rule without requiring an inline suppression — see CLAUDE.md rule 9. Every code in `AUDIO_ANALYSIS_SPECTRUM_ERROR_METADATA` must be reachable from at least one test (`error-codes.test.ts` walks the metadata and asserts each is thrown from a real call site).

View File

@@ -0,0 +1,104 @@
# @suaveplan/audio-cli - Server-tier `audio` command-line tool: chainable pipeline grammar, batch glob processing, analysis JSON reports, codec conversion, info, play, record. Thin shell over @suaveplan/audio-core + audio-codecs + audio-engine-server.
Server-tier `audio` command-line tool. Thin shell over
`@suaveplan/audio-core`, `@suaveplan/audio-codecs`, and
`@suaveplan/audio-engine-server`: every audio transform delegates to the
universal layer; the CLI itself owns argument parsing, the pipeline grammar,
command dispatch, exit-code mapping, and SIGINT handling.
<!-- begin:auto-docs -->
## Documentation tree
- [bin](./src/bin/bin.md) — This module is the `bin` shebang entry for the `suaveplan-audio` command. It is the only file in the package with side…
- [bin](./src/bin/bin.md) — This module is the `bin` shebang entry for the `suaveplan-audio` command. It is the only file in the package with side…
- [cli](./src/cli/cli.md) — `AudioCLI` is the application-class wrapper for the `audio` command-line tool. It owns the default codec registry, the…
- [cli](./src/cli/cli.md) — `AudioCLI` is the application-class wrapper for the `audio` command-line tool. It owns the default codec registry, the…
- [analyze](./src/commands/analyze.md) — `runAnalyze` is the analysis pipeline entry. It loads an input file through the `FileSourceAdapter`, runs the requested…
- [batch](./src/commands/batch.md) — `runBatch` is the bulk-processing entry point. It expands a glob pattern or a comma-separated list of explicit paths in…
- [convert](./src/commands/convert.md) — `runConvert` is a deliberately thin wrapper around `runProcess`. It validates the small `ConvertOptions` surface (`form…
- [info](./src/commands/info.md) — `runInfo` produces a one-shot metadata + statistics report for a file: sample rate, channel count, duration, total samp…
- [play](./src/commands/play.md) — `runPlay` plays a file through an injected `SpeakerAdapter`. The CLI package intentionally carries no platform audio bi…
- [process](./src/commands/process.md) — `runProcess` is the engine of the `audio process` and bare-op invocations. It loads the input file via `FileSourceAdapt…
- [record](./src/commands/record.md) — `runRecord` captures audio from an injected `MicrophoneAdapter` for a fixed duration and saves the resulting `AudioDocu…
- [error-codes](./src/error-codes/error-codes.md) — This module is the structured error and exit-code boundary for `@suaveplan/audio-cli`. It defines the six `AUDIO_CLI.*`…
- [error-codes](./src/error-codes/error-codes.md) — This module is the structured error and exit-code boundary for `@suaveplan/audio-cli`. It defines the six `AUDIO_CLI.*`…
- [pipeline](./src/pipeline/parser.md) — This module turns a CLI user's free-form pipeline expression into a typed `[opName, params]` chain that the rest of the…
- [parser](./src/pipeline/parser.md) — This module turns a CLI user's free-form pipeline expression into a typed `[opName, params]` chain that the rest of the…
<!-- end:auto-docs -->
## Install
```sh
bun add @suaveplan/audio-cli
```
The `suaveplan-audio` bin is installed alongside the package.
## CLI
```sh
suaveplan-audio --version
suaveplan-audio --help
# Process: load → ops → save
suaveplan-audio process input.wav output.flac --pipeline "trim 30s | normalize -16lufs"
# Batch: glob + shared pipeline
suaveplan-audio batch --input "tracks/*.mp3" --output processed/ --pipeline "normalize" --workers 4
# Analyze: produce a JSON report
suaveplan-audio analyze input.mp3 --bpm --key --lufs --output report.json
# Format conversion (no ops)
suaveplan-audio convert input.wav output.flac
# Print metadata + computeStats
suaveplan-audio info input.mp3
# Headless play (uses an injected SpeakerAdapter)
suaveplan-audio play input.wav
# Record silence (default mic) for N seconds
suaveplan-audio record out.wav --duration 60
```
## Programmatic API
```ts
import { AudioCLI } from "@suaveplan/audio-cli";
const cli = new AudioCLI();
await cli.process("input.wav", "output.flac", { pipeline: "trim 30s | normalize" });
const report = await cli.analyze("input.mp3", { bpm: true, key: true, lufs: true });
```
Subpath exports for tree-shaken consumption:
- `@suaveplan/audio-cli/cli` — the `AudioCLI` class.
- `@suaveplan/audio-cli/pipeline/parser` — `parsePipeline` / `validatePipeline`.
- `@suaveplan/audio-cli/error-codes` — `AUDIO_CLI_CODES`, `AudioCliError`,
`EXIT_CODES`, `exitCodeFor`.
- `@suaveplan/audio-cli/commands/<command>` — each `runX` entry.
## Exit codes
Stable mapping from `AUDIO_CLI.*` codes onto Unix exit codes:
| Code | Exit |
|---|---|
| `AUDIO_CLI.INVALID_ARGS` | `2` |
| `AUDIO_CLI.UNKNOWN_OP` | `2` |
| `AUDIO_CLI.INPUT_NOT_FOUND` | `3` |
| `AUDIO_CLI.BAD_OUTPUT` | `4` |
| `AUDIO_CLI.PIPELINE_FAILED` | `5` |
| `AUDIO_CLI.ABORTED` | `130` |
`0` is reserved for success. Any uncaught non-`AudioCliError` exits `1`.
## Quality
- Server-tier, Node.js >=22.
- 100/100/100/100 coverage on every implementation file.
- Zero mocks — tests drive the real CLI against real `mkdtemp` temp files
and the real `@suaveplan/audio-engine-server` adapters.

View File

@@ -0,0 +1,59 @@
# `bin` — `suaveplan-audio` entry point
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module is the `bin` shebang entry for the `suaveplan-audio` command. It
is the only file in the package with side effects: when executed as a script
it installs a `SIGINT` handler, constructs an `AudioCLI`, dispatches
`process.argv`, and calls `process.exit` with the returned exit code. The
function `main` is also exported so tests and programmatic callers can drive
the exact dispatch path without spawning a subprocess.
The root barrel deliberately does NOT re-export this module — importing
`@suaveplan/audio-cli` should never read `process.argv` or attach signal
listeners.
## Features
- `#!/usr/bin/env bun` shebang so the installed `bin` runs through Bun.
- Single `AbortController` per invocation, threaded into every long-running
command via `cli.dispatch`.
- Two-tick `SIGINT` handling: first signal aborts cooperatively, second
signal calls `process.exit(130)` for force-exit.
- `import.meta.url` self-test guard so importing the file as a module is a
pure type/export operation.
## Usage: Basic
```sh
$ suaveplan-audio --version
0.1.0
$ suaveplan-audio convert input.wav output.flac
```
## Usage: Advanced
```ts
import { main } from "@suaveplan/audio-cli/bin";
const exitCode = await main(["analyze", "track.wav", "--lufs"]);
console.log("exit:", exitCode);
```
## API Reference
- `main(argv: readonly string[]): Promise<number>` — dispatch and return
exit code without calling `process.exit` (caller's responsibility).
## Implementation Notes
The `import.meta.url === \`file://\${process.argv[1]}\`` check is the
canonical Node ESM way to ask "am I running as the entry script?" It avoids
the historical `require.main === module` CJS idiom and keeps the file safe
to import from tests. The single `AbortController` is threaded via the
dispatch's per-command argument extraction so the same signal reaches the
engine adapters as the spec mandates.

View File

@@ -0,0 +1,76 @@
# `cli` — `AudioCLI` class
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`AudioCLI` is the application-class wrapper for the `audio` command-line tool.
It owns the default codec registry, the default op registry, the
`FileSourceAdapter` / `FileSinkAdapter` pair from `@suaveplan/audio-engine-server`,
the stdout/stderr streams, the OTel instrumentation, and the argv dispatch
logic. Both the `bin` entry and programmatic consumers construct an instance
of this class and call its public methods, so the test surface and the binary
exercise the same code path.
The class deliberately holds only wiring: every audio transform is delegated
to the seven `runX` command modules, the pipeline parser, and the underlying
`@suaveplan/audio-*` packages.
## Features
- Default `CodecRegistry` populated with the 10 built-in codecs.
- Default `OpRegistry.createWithDefaults()` plus user-supplied overrides.
- Injectable `FileSourceAdapter` / `FileSinkAdapter` for tests without
patching `node:fs`.
- OTel span (`audio_cli.<command>`), command counter, duration histogram,
files-processed counter, all tagged with `{ command, outcome }`.
- `dispatch(argv)` returns a numeric exit code, never throws — failures are
formatted onto stderr and mapped through the `EXIT_CODES` table.
- `helpText()` lists every subcommand and global flag for `--help` rendering.
- `detectCodecForOutput(path, registry)` exposed for tests and the convert
command: extension → codec name, throws `AUDIO_CLI.BAD_OUTPUT` on miss.
## Usage: Basic
```ts
import { AudioCLI } from "@suaveplan/audio-cli/cli";
const cli = new AudioCLI();
await cli.process("input.wav", "output.flac");
```
## Usage: Advanced
```ts
import { AudioCLI } from "@suaveplan/audio-cli/cli";
import { CodecRegistry, WavCodec } from "@suaveplan/audio-codecs";
const wavOnly = new CodecRegistry({ codecs: [WavCodec] });
const cli = new AudioCLI({ registry: wavOnly, version: "1.2.3" });
const exit = await cli.dispatch(["--version"]); // prints "1.2.3", returns 0
```
## API Reference
- `AudioCLI(options?: AudioCliOptions)` — constructor.
- `AudioCliOptions` — `{ registry, ops, source, sink, stdout, stderr, version }`.
- `cli.process / batch / analyze / convert / info / play / record` — typed
promises, each delegating to its `runX` module.
- `cli.dispatch(argv)` — exit-code-returning dispatcher.
- `cli.helpText()` — rendered help string.
- `createDefaultCodecRegistry()` — top-level helper used by the default
constructor path.
- `detectCodecForOutput(path, registry)` — extension → codec-name lookup.
## Implementation Notes
The OTel wrapper records the start time at the top of every command, records
the outcome attribute in `finally`, and lets the `AudioCliError` propagate.
`#reportAndExit` is the single point that converts a thrown value into a
numeric exit code; it writes a single human-readable diagnostic line to
stderr (`code: message`) so the binary composes cleanly inside shell
pipelines. The `dispatch` switch is deliberately flat — every subcommand owns
its argument-parsing helper, validates inputs with a Zod schema, and either
returns `0` or throws an `AudioCliError`.

View File

@@ -0,0 +1,63 @@
# `commands/analyze` — `runAnalyze`
## Purpose
`runAnalyze` is the analysis pipeline entry. It loads an input file through
the `FileSourceAdapter`, runs the requested analysis ops, and returns (or
writes to `--output`) a structured `AnalyzeReport` JSON payload. The default
behaviour — when no analysis flag is passed — runs the universal
`computeStats` from `@suaveplan/audio-core` so a `peak/rms/lufs` summary is
always available.
The module is deliberately a composition point: the `--bpm`, `--key`, and
`--lufs` flags are wired so the heavier
`@suaveplan/audio-analysis-*` packages can be plugged in via OpRegistry-style
extension without modifying this code path.
## Features
- Zod-validated `AnalyzeOptions` (`bpm`, `key`, `lufs`, `stats`, `output`,
`signal`).
- Default fallback to `computeStats` when no flag is given.
- Optional `--output report.json` writes pretty-printed JSON to disk.
- `--lufs` is independently selectable so a CI gate can produce just LUFS
without re-running BPM/key.
- Pre-aborted signal short-circuits before any I/O.
## Usage: Basic
```ts
import { AudioCLI } from "@suaveplan/audio-cli";
const cli = new AudioCLI();
const report = await cli.analyze("input.mp3");
console.log(report.peak, report.rms);
```
## Usage: Advanced
```ts
import { runAnalyze } from "@suaveplan/audio-cli/commands/analyze";
await runAnalyze(cli, "track.wav", {
bpm: true,
key: true,
lufs: true,
output: "report.json",
});
```
## API Reference
- `runAnalyze(cli, input, opts?): Promise<AnalyzeReport>`
- `AnalyzeOptions` — `{ bpm?, key?, lufs?, stats?, output?, signal? }`
- `AnalyzeReport` — `{ input, sampleRate, channels, duration, peak?, rms?,
lufs?, bpm?, key? }`
## Implementation Notes
`computeStats` is called once per branch — the `wantStats` and `--lufs` paths
both delegate to it rather than re-implementing the loop. When `--output` is
provided the function still returns the report so a programmatic caller can
inspect the same payload that was just persisted. The signal threading
mirrors `runProcess` so the abort guarantees are uniform across commands.

View File

@@ -0,0 +1,70 @@
# `commands/batch` — `runBatch`
## Purpose
`runBatch` is the bulk-processing entry point. It expands a glob pattern or a
comma-separated list of explicit paths into a concrete file list, runs the
shared pipeline against every file, and returns a structured `BatchReport`
that carries per-file success/failure rows alongside aggregate counts.
Per-file failures do not abort the batch — the report flags them and the run
continues — but an `AbortSignal` is honoured cooperatively at every task
boundary.
## Features
- Glob expansion: `*.wav`, `tracks/*.mp3`, `**/foo.flac` (basename glob).
- Comma-separated explicit list: `a.wav,b.wav,c.wav`.
- Output naming preserves the basename and applies `outputExt` (defaulting to
the source extension) so `tracks/song.mp3` → `out/song.<ext>`.
- Configurable `workers` lane count (>= 1) using a cursor-based pool so
exceptions in one lane do not stall others.
- Pre-aborted signal short-circuits before any read; mid-flight abort throws
`AUDIO_CLI.ABORTED` with a `{ completed, total }` context.
- Per-file failures land in the report as `{ ok: false, error }` entries.
## Usage: Basic
```ts
import { AudioCLI } from "@suaveplan/audio-cli";
const cli = new AudioCLI();
const report = await cli.batch({
input: "tracks/*.mp3",
output: "processed/",
pipeline: "trim 30s | normalize",
});
console.log(report.succeeded, "/", report.total);
```
## Usage: Advanced
```ts
import { runBatch } from "@suaveplan/audio-cli/commands/batch";
const ac = new AbortController();
const report = await runBatch(cli, {
input: "a.wav,b.wav,c.wav",
output: "out/",
outputExt: "flac",
workers: 4,
pipeline: "normalize -16lufs",
signal: ac.signal,
});
for (const e of report.entries) if (!e.ok) console.error(e.input, e.error);
```
## API Reference
- `runBatch(cli, opts: BatchOptions): Promise<BatchReport>`
- `BatchOptions` — `{ input, output, pipeline, outputExt?, workers?, signal? }`
- `BatchEntry` — `{ input, output, ok, error?, durationMs }`
- `BatchReport` — `{ total, succeeded, failed, entries }`
## Implementation Notes
The glob expansion is deliberately small and dependency-free: a `readdir`
call on the parent directory plus a regex compiled from the pattern. Comma
forms bypass `readdir` entirely. The worker pool uses an integer cursor so
adding more lanes than tasks is a no-op rather than an error. The output
directory is `dirname`-touched once so callers passing a deep `--output` path
do not silently lose the prefix to dead-code elimination during minification.

View File

@@ -0,0 +1,63 @@
# `commands/convert` — `runConvert`
## Purpose
`runConvert` is a deliberately thin wrapper around `runProcess`. It validates
the small `ConvertOptions` surface (`format`, `bitDepth`, `signal`) and
forwards into the shared decode/encode path with no pipeline ops. Keeping it
this thin means the format mapping, codec lookup, abort handling, and error
translation live in exactly one place — the `process` command — which the
spec calls out as the single source of truth for streaming through
`audio-core`/`audio-codecs`/`audio-engine-server`.
## Features
- Zod-strict validation of `ConvertOptions`.
- Optional explicit `format` overriding extension detection.
- Optional `bitDepth` forwarded to the codec encoder where supported.
- Pre-aborted signal short-circuits via `runProcess`.
## Usage: Basic
```ts
import { AudioCLI } from "@suaveplan/audio-cli";
const cli = new AudioCLI();
await cli.convert("input.wav", "output.flac");
```
## Usage: Advanced
```ts
import { runConvert } from "@suaveplan/audio-cli/commands/convert";
await runConvert(cli, "input.wav", "output.aiff", { bitDepth: 24 });
```
## API Reference
- `runConvert(cli, input, output, opts?): Promise<void>`
- `ConvertOptions` — `{ format?, bitDepth?, signal? }`
- `ConvertOptionsSchema` — Zod boundary validation.
## Implementation Notes
Because the implementation forwards into `runProcess`, every error code that
`runProcess` produces (`INPUT_NOT_FOUND`, `BAD_OUTPUT`, `PIPELINE_FAILED`,
`ABORTED`) is reachable from `convert` as well. The empty pipeline path is
not a special case in `runProcess`: an empty edit chain just means
"decode → re-encode", which is exactly the conversion semantic.
The Zod schema is `strict()`, so a typo in an option key (`{ formt: "wav" }`)
fails fast at the boundary with `AUDIO_CLI.INVALID_ARGS` rather than being
silently dropped. The `bitDepth` field is constrained to the union
`8 | 16 | 24 | 32` so encoders that only accept those depths receive a
correctly-typed value. Passing an `AbortSignal` that is already aborted at
call time short-circuits inside `runProcess` before any file is opened, so
`convert` participates in the same SIGINT contract as every other command.
`convert` is the cheapest way to migrate a file between formats in shell
pipelines: it skips the planner and the op chain entirely, so a file with
no edits goes straight through the codec stack. For copying with an op
chain attached, prefer the `process` command directly — `convert` is
deliberately a forward-only wrapper with a smaller option surface.

View File

@@ -0,0 +1,70 @@
# `commands/info` — `runInfo`
## Purpose
`runInfo` produces a one-shot metadata + statistics report for a file:
sample rate, channel count, duration, total samples, peak/RMS/DC-offset, and
LUFS (when available). It is the read-only counterpart to `analyze`: cheap,
predictable, intended for shell-pipeline composition (`audio info x.wav |
jq '.lufs'`).
The function returns the structured `InfoReport` so programmatic consumers
get the same payload the CLI writes to stdout — there is no formatting
divergence between programmatic and shell usage.
## Features
- Zod-strict `InfoOptions` (currently just an optional `signal`).
- Delegates to `computeStats` from `@suaveplan/audio-core`.
- Pre-aborted signal short-circuits before any I/O.
- Distinguishes "no LUFS" (`null`) from "0 LUFS" so JSON consumers can
branch on a missing measurement.
## Usage: Basic
```ts
import { AudioCLI } from "@suaveplan/audio-cli";
const cli = new AudioCLI();
const info = await cli.info("input.wav");
console.log(info.duration, info.peak, info.lufs);
```
## Usage: Advanced
```ts
import { runInfo } from "@suaveplan/audio-cli/commands/info";
const ac = new AbortController();
const info = await runInfo(cli, "input.flac", { signal: ac.signal });
if (info.lufs !== null && info.lufs > -14) {
console.warn("Above broadcast loudness target");
}
```
## API Reference
- `runInfo(cli, input, opts?): Promise<InfoReport>`
- `InfoOptions` — `{ signal? }`
- `InfoReport` — `{ input, sampleRate, channels, duration, samples, peak,
rms, dcOffset, lufs }`
## Implementation Notes
`computeStats` walks the file once; the LUFS column is `null`-mapped from the
optional `stats.lufs` field so the JSON shape is always uniform. The function
intentionally does not retain the decoded `AudioDocument` past the stats call
so the GC can reclaim the page memory as soon as the report is constructed.
The `samples` field comes from `AudioDocument.sourceLength`, not from
recomputing the page lengths, so it stays accurate even when the document
carries non-trivial edits. The `duration` field is the document's own
`duration` getter (length / sample rate), so callers reading the JSON do not
need to derive it.
`info` is intentionally a read-only operation: it never opens an output
sink and never participates in the codec write path, so it is safe to chain
into a shell pipeline that processes the JSON downstream. The Zod option
surface is currently a single optional `signal`, but the schema is
declared `strict()` so adding a future option there is a non-breaking
change for callers that pass an empty options object.

View File

@@ -0,0 +1,60 @@
# `commands/play` — `runPlay`
## Purpose
`runPlay` plays a file through an injected `SpeakerAdapter`. The CLI package
intentionally carries no platform audio binding — naming a specific speaker
binary (sox, mpv, ffplay) would couple the package to whichever the user has
installed. Instead, the function exercises argument validation, the input
load path, and the adapter dispatch, and consumers (or installer hooks) wire
the actual hardware adapter behind the `SpeakerAdapter` interface.
A `noopSpeaker` is exported so headless servers, CI, and tests can run the
`play` code path without audio output.
## Features
- Zod-strict `PlayOptions` accepting an injected `SpeakerAdapter` and an
`AbortSignal`.
- Default `noopSpeaker` adapter for headless environments.
- Same input-error mapping (`AUDIO_CLI.INPUT_NOT_FOUND`) as the other
load-driven commands.
- Adapter failures wrap as `AUDIO_CLI.PIPELINE_FAILED` with the original
cause attached.
## Usage: Basic
```ts
import { AudioCLI } from "@suaveplan/audio-cli";
const cli = new AudioCLI();
await cli.play("input.wav"); // noop on a headless host
```
## Usage: Advanced
```ts
import { runPlay } from "@suaveplan/audio-cli/commands/play";
import type { SpeakerAdapter } from "@suaveplan/audio-cli/commands/play";
const mySpeaker: SpeakerAdapter = {
async play(doc, { signal }) { /* ...write doc to a DAC... */ },
};
await runPlay(cli, "song.flac", { speaker: mySpeaker });
```
## API Reference
- `runPlay(cli, input, opts?): Promise<void>`
- `PlayOptions` — `{ speaker?, signal? }`
- `SpeakerAdapter.play(doc, { signal? }): Promise<void>`
- `noopSpeaker: SpeakerAdapter`
## Implementation Notes
The function deliberately structures the failure modes so a missing speaker
falls through to the no-op rather than throwing — this matches `audio play`
on a headless box: argv parses, the file decodes, and the command returns
`0`. Real hardware bindings ship as separate adapters consumers add at
construction time via `new AudioCLI({ ... })` or by passing `opts.speaker`
directly.

View File

@@ -0,0 +1,66 @@
# `commands/process` — `runProcess`
## Purpose
`runProcess` is the engine of the `audio process` and bare-op invocations. It
loads the input file via `FileSourceAdapter`, validates and appends the
parsed pipeline as `AudioEdit` records on the resulting `AudioDocument`, and
streams the compiled plan into `FileSinkAdapter.save` against the
extension-inferred (or explicit) output codec. Every audio transform is
delegated to `@suaveplan/audio-core` and `@suaveplan/audio-codecs`; this
module owns only argument validation, error-code translation, and the
sequencing.
## Features
- Zod validation of `ProcessOptions` (`pipeline`, `format`, `bitDepth`,
`signal`).
- Accepts both string and pre-parsed `ParsedPipeline` for the `pipeline`
field; the string form is parsed via `parsePipeline`.
- Treats a `save` token in the chain as a sentinel and skips it during
registry validation (the bare-op form ends with `save <output>`).
- Maps every engine error to an `AUDIO_CLI.*` code with `cause` preserved:
- `AUDIO_ENGINE_SERVER.FILE_NOT_FOUND` / `IO_FAILED` →
`AUDIO_CLI.INPUT_NOT_FOUND` (exit 3).
- `AUDIO_ENGINE_SERVER.ABORTED` → `AUDIO_CLI.ABORTED` (exit 130).
- Anything else during load/run/save → `AUDIO_CLI.PIPELINE_FAILED` (exit 5).
- Pre-aborted `AbortSignal` short-circuits before any I/O.
## Usage: Basic
```ts
import { AudioCLI } from "@suaveplan/audio-cli";
const cli = new AudioCLI();
await cli.process("input.wav", "output.flac", { pipeline: "normalize -16lufs" });
```
## Usage: Advanced
```ts
import { runProcess } from "@suaveplan/audio-cli/commands/process";
const ac = new AbortController();
setTimeout(() => ac.abort(), 250);
await runProcess(cli, "input.wav", "output.flac", {
pipeline: [["trim", { start: 30 }], ["normalize", { lufs: -16 }]],
bitDepth: 24,
signal: ac.signal,
});
```
## API Reference
- `runProcess(cli, input, output, opts?)` — async, returns `void`, throws
`AudioCliError` on failure.
- `ProcessOptions` — `{ pipeline?, format?, bitDepth?, signal? }`.
- `ProcessOptionsSchema` — Zod strict schema for boundary validation.
## Implementation Notes
The function intentionally builds the executable chain in three stages:
parse (or accept), filter out the `save` sentinel, then validate names
against the registry. That keeps the pipe-form and bare-op-form code paths
identical past the parser. The engine error mapping mirrors the spec's
`AUDIO_CLI.*` requirements and is the only place those translations live — the
CLI dispatch layer just propagates whatever this throws.

View File

@@ -0,0 +1,62 @@
# `commands/record` — `runRecord`
## Purpose
`runRecord` captures audio from an injected `MicrophoneAdapter` for a fixed
duration and saves the resulting `AudioDocument` through `FileSinkAdapter`.
The CLI ships only the adapter contract and a silence-producing default —
specific platform bindings (CoreAudio, ALSA, WebAudio) ship as separate
adapter packages or installer hooks, so this command works identically on
CI/server hosts and on real hardware.
## Features
- Zod-strict `RecordOptions` (`duration`, `sampleRate`, `channels`,
`format`, `bitDepth`, `microphone`, `signal`).
- Default `createSilentMicrophone()` adapter produces a deterministic silent
document — useful for CI and tests.
- Output format inferred from extension (or set via `opts.format`).
- Default 48 kHz / 2 channels matches mainstream broadcast/recording targets.
- Pre-aborted signal short-circuits before any adapter call.
## Usage: Basic
```ts
import { AudioCLI } from "@suaveplan/audio-cli";
const cli = new AudioCLI();
await cli.record("out.wav", { duration: 60 }); // silent on headless boxes
```
## Usage: Advanced
```ts
import { runRecord, createSilentMicrophone } from "@suaveplan/audio-cli/commands/record";
import type { MicrophoneAdapter } from "@suaveplan/audio-cli/commands/record";
const myMic: MicrophoneAdapter = {
async capture({ duration, sampleRate, channels }) {
// ...read from a real device...
return createSilentMicrophone().capture({ duration, sampleRate, channels });
},
};
await runRecord(cli, "out.flac", { duration: 30, microphone: myMic, bitDepth: 24 });
```
## API Reference
- `runRecord(cli, output, opts): Promise<void>`
- `RecordOptions` — `{ duration, sampleRate?, channels?, format?, bitDepth?,
microphone?, signal? }`
- `MicrophoneAdapter.capture({ duration, sampleRate, channels, signal? }): Promise<AudioDocument>`
- `createSilentMicrophone(): MicrophoneAdapter`
## Implementation Notes
The silent default produces `Math.round(duration * sampleRate)` samples per
channel so byte-exact tests can assert document length without a hardware
dependency. The save path mirrors `runProcess`: failures wrap to
`AUDIO_CLI.PIPELINE_FAILED` with `cause` and a `{ stage, output, format }`
context. The `--duration` argument is mandatory because indefinite recording
needs a different surface (an interactive stop signal) than what `audio
record <output> --duration <s>` exposes.

View File

@@ -0,0 +1,79 @@
# `error-codes` — AudioCliError and EXIT_CODES
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module is the structured error and exit-code boundary for
`@suaveplan/audio-cli`. It defines the six `AUDIO_CLI.*` codes the package
throws, the `AudioCliError` class extending `SuaveplanError` carrying `code`,
`cause`, and `context`, the `AUDIO_CLI_ERROR_METADATA` table with descriptions
and severity, and the `EXIT_CODES` table that maps every code onto a stable
non-zero Unix exit integer.
It also exports `exitCodeFor`, which the CLI entry point calls in its global
catch to convert any thrown value into a `process.exit` argument. The mapping
is part of the public contract: CI pipelines and shell scripts depend on it,
so changing a code-to-integer mapping is a breaking change.
## Features
- Six-code registry (`INVALID_ARGS`, `UNKNOWN_OP`, `INPUT_NOT_FOUND`,
`BAD_OUTPUT`, `PIPELINE_FAILED`, `ABORTED`) via `defineCodes`.
- `AudioCliError extends SuaveplanError` with strongly-typed `code`.
- `EXIT_CODES` immutable table (`0`-reserved, `2/3/4/5/130`).
- `exitCodeFor(err)` unknown-shape fallback to `1`.
- Idempotent `registerAudioCliErrorCodes()` for cross-package code-table fans.
## Usage: Basic
```ts
import { AudioCliError, AUDIO_CLI_CODES, exitCodeFor } from "@suaveplan/audio-cli/error-codes";
try {
throw new AudioCliError(AUDIO_CLI_CODES.INPUT_NOT_FOUND, "audio-cli.input-not-found", {
context: { path: "missing.wav" },
});
} catch (err) {
process.exit(exitCodeFor(err));
}
```
## Usage: Advanced
```ts
import {
AUDIO_CLI_CODES,
AUDIO_CLI_ERROR_METADATA,
EXIT_CODES,
type AudioCliErrorCode,
} from "@suaveplan/audio-cli/error-codes";
// Render a help table of every code, its severity, and the exit it produces.
for (const [code, meta] of Object.entries(AUDIO_CLI_ERROR_METADATA)) {
const c = code as AudioCliErrorCode;
console.log(`${c}\t${meta.severity}\texit=${EXIT_CODES[c]}\t${meta.description}`);
}
```
## API Reference
- `AUDIO_CLI_CODES` — frozen string-literal record of the six codes.
- `AudioCliErrorCode` — union of those code values.
- `AudioCliError(code, messageKey, options?)` — `SuaveplanError` subclass.
- `AUDIO_CLI_ERROR_METADATA` — per-code `{ description, severity }`.
- `EXIT_CODES` — per-code non-zero integer.
- `registerAudioCliErrorCodes()` — idempotent registry hook.
- `exitCodeFor(err)` — map a thrown value to an exit code (`0`/`1`/EXIT_CODES).
## Implementation Notes
The error class follows the same pattern as `AudioEngineServerError` and
`AudioCodecsError` in sibling packages. `defineCodes` ensures the literal
record is frozen at the type level so a typo in `AUDIO_CLI_CODES.foo` fails to
compile. `exitCodeFor` walks two shapes: an `AudioCliError` instance (cheap
typeof check) and a duck-typed `{ code: string }` so foreign errors carrying a
known `AUDIO_CLI.*` code still map correctly. Anything else falls through to
`1`, the canonical "uncaught error" Unix convention.

View File

@@ -0,0 +1,69 @@
# `pipeline/parser` — `parsePipeline` and `validatePipeline`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module turns a CLI user's free-form pipeline expression into a typed
`[opName, params]` chain that the rest of the CLI feeds into
`AudioDocument.run`. It supports both the **pipe-form** string —
`"trim 30s | normalize -16lufs | fade 0.5s 1s"` — and the **positional
bare-op** token array — `["trim", "30s", "normalize", "-16lufs"]` — and
collapses both surface forms onto the same internal representation so there is
exactly one downstream code path.
It also exposes `validatePipeline(chain, knownOps)` which the CLI calls before
opening any file, so an unknown op name throws `AUDIO_CLI.UNKNOWN_OP` fast and
no I/O is wasted.
## Features
- `tokenise` respects double-quoted strings (`"a b"`) and backslash escapes
(`a\ b`) so a single op can carry a string parameter with spaces.
- Unit suffix coercion: `30s → start=30`, `500ms → start=0.5`,
`-16lufs → lufs=-16`, `6db → db=6`, `44100hz → rate=44100`,
`48khz → rate=48000`.
- `key=value` arguments (`foo=bar`) become named params; numeric literals
without a unit become positional `start`/`duration`.
- `validatePipeline` lists the registry up-front so the diagnostic names the
offending op alongside the sorted set of known ops.
## Usage: Basic
```ts
import { parsePipeline } from "@suaveplan/audio-cli/pipeline/parser";
const chain = parsePipeline("trim 30s | normalize -16lufs");
// → [["trim", { start: 30 }], ["normalize", { lufs: -16 }]]
```
## Usage: Advanced
```ts
import { parsePipeline, validatePipeline } from "@suaveplan/audio-cli/pipeline/parser";
import { OpRegistry } from "@suaveplan/audio-core";
const registry = OpRegistry.createWithDefaults();
const known = new Set(registry.list().map((d) => d.name));
const chain = parsePipeline(["trim", "30s", "normalize", "-16lufs", "fade", "0.5s", "1s"]);
validatePipeline(chain, known); // throws AUDIO_CLI.UNKNOWN_OP if a name is unknown
```
## API Reference
- `ParsedPipeline` — `ReadonlyArray<readonly [string, Record<string, unknown>]>`.
- `parsePipeline(spec: string | readonly string[])` — produce the chain.
- `validatePipeline(chain, knownOps: ReadonlySet<string>)` — throws
`AudioCliError(AUDIO_CLI.UNKNOWN_OP)` on the first unknown op name.
## Implementation Notes
The hand-written tokenizer is deliberately small: a single forward pass with
two flags (`inQuote`, `escaped`) and a per-stage accumulator. The pipe-form
splits on `|`; the bare-op form treats every identifier-shaped token (matching
`/^[a-zA-Z][a-zA-Z0-9_-]*$/`) as the start of a new stage so positional
arguments belonging to the previous op are correctly grouped. Unit coercion
runs as a small regex per argument so `trim 30s` always lands as
`{ start: 30 }` regardless of which surface form it arrived through.

View File

@@ -0,0 +1,81 @@
# @suaveplan/audio-codec-ffmpeg-native - Server-tier ffmpeg-native AudioCodec plugin for @suaveplan/audio-codecs: spawns the system ffmpeg binary via fluent-ffmpeg, streams stdin/stdout for true chunked decode/encode of wav/mp3/ogg/opus/flac/aac/webm, bounded concurrency, AbortSignal cleanup with SIGTERM->SIGKILL escalation, and a one-line register(registry) helper.
Server-tier `AudioCodec` plugin for `@suaveplan/audio-codecs` that shells out
to the system `ffmpeg` binary via `fluent-ffmpeg` and streams audio through
the subprocess' stdin/stdout. Spec ID: `enterprise-audio-codec-ffmpeg-native`.
<!-- begin:auto-docs -->
## Documentation tree
- [binary](./src/binary/binary.md) — This module owns ffmpeg-binary discovery for `@suaveplan/audio-codec-ffmpeg-native`. It implements a fixed four-step pr…
- [binary](./src/binary/binary.md) — This module owns ffmpeg-binary discovery for `@suaveplan/audio-codec-ffmpeg-native`. It implements a fixed four-step pr…
- [codec](./src/codec/codec.md) — `FfmpegNativeCodec` is the server-tier concrete `AudioCodec` implementation that backs `@suaveplan/audio-codec-ffmpeg-n…
- [codec](./src/codec/codec.md) — `FfmpegNativeCodec` is the server-tier concrete `AudioCodec` implementation that backs `@suaveplan/audio-codec-ffmpeg-n…
- [pcm](./src/codec/pcm.md) — PCM helper module for `@suaveplan/audio-codec-ffmpeg-native`. The ffmpeg subprocess speaks `f32le` (planar-interleaved…
- [error-codes](./src/error-codes/error-codes.md) — This module owns the stable, namespaced error-code registry for `@suaveplan/audio-codec-ffmpeg-native`. Every operation…
- [error-codes](./src/error-codes/error-codes.md) — This module owns the stable, namespaced error-code registry for `@suaveplan/audio-codec-ffmpeg-native`. Every operation…
- [register](./src/register/register.md) — `register(registry, options?)` is the one-line entry point applications use to wire `@suaveplan/audio-codec-ffmpeg-nati…
- [register](./src/register/register.md) — `register(registry, options?)` is the one-line entry point applications use to wire `@suaveplan/audio-codec-ffmpeg-nati…
- [runner](./src/runner/runner.md) — This module is the single place in `@suaveplan/audio-codec-ffmpeg-native` that imports `node:child_process`. It owns sp…
- [runner](./src/runner/runner.md) — This module is the single place in `@suaveplan/audio-codec-ffmpeg-native` that imports `node:child_process`. It owns sp…
- [stream-decoder](./src/stream/stream-decoder.md) — `createStreamingDecoder` returns a `FfmpegStreamingDecoder` — the `@suaveplan/audio-codecs` `StreamingDecoder` plus an…
- [stream-encoder](./src/stream/stream-encoder.md) — `createStreamingEncoder` returns a `FfmpegStreamingEncoder` — the `@suaveplan/audio-codecs` `StreamingEncoder` plus an…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/audio-codec-ffmpeg-native @suaveplan/audio-codecs
# Optional bundled static binary:
bun add @ffmpeg-installer/ffmpeg
```
## Quickstart
```ts
import { CodecRegistry } from "@suaveplan/audio-codecs";
import { register } from "@suaveplan/audio-codec-ffmpeg-native";
const registry = new CodecRegistry();
register(registry, { concurrencyLimit: 8, timeoutMs: 30_000 });
const codec = registry.byName("ffmpeg-native");
const decoded = await codec.decode(mp3Bytes);
const encoded = await codec.encode(decoded.channelData, {
format: "flac",
sampleRate: decoded.sampleRate,
});
```
## Streaming
```ts
import { FfmpegNativeCodec } from "@suaveplan/audio-codec-ffmpeg-native";
const decoder = new FfmpegNativeCodec().createDecoder({ format: "ogg" });
for (const chunk of oggChunks) decoder.write(chunk);
const trailing = await decoder.flushAsync(); // additive Promise companion.
decoder.close();
```
`flushAsync()` is an additive method on the streaming decoder/encoder that
the spec asks for, layered over the synchronous base contract from
`@suaveplan/audio-codecs`. See `src/stream/*.md` for details.
## Engines
Node.js 22+. Server-tier only — no browser support, no `worker_threads`
plumbing.
## Error codes
Eight codes namespaced under `AUDIO_CODEC_FFMPEG_NATIVE.*`:
`BINARY_MISSING`, `SPAWN_FAILED`, `DECODE_FAILED`, `ENCODE_FAILED`,
`TIMEOUT`, `ABORTED`, `UNSUPPORTED_CODEC`, `INVALID_PCM`.
## Status
`0.1.0` — first-tier landing. Subpath exports cover every implementation
module so consumers can tree-shake. See the package's OpenSpec change for
the full specification.

View File

@@ -0,0 +1,79 @@
# `binary`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns ffmpeg-binary discovery for `@suaveplan/audio-codec-ffmpeg-native`.
It implements a fixed four-step precedence chain — caller-supplied provider,
explicit absolute path, dynamically imported `@ffmpeg-installer/ffmpeg`
optional dependency, then PATH-based discovery — and memoises successful
resolutions by options-object identity so that high-traffic decode/encode
pipelines pay the resolution cost exactly once per shared options bundle.
The resolver throws `AudioCodecFfmpegNativeError("AUDIO_CODEC_FFMPEG_NATIVE.BINARY_MISSING")`
when every step fails, which lets the codec surface an actionable error to the
caller instead of waiting for a downstream `ENOENT` from `child_process.spawn`.
## Features
- Strict precedence: provider → explicit path → installer → PATH lookup.
- Promise-returning provider supported (sync providers also accepted).
- Dynamic `import("@ffmpeg-installer/ffmpeg")` so the optional dependency is
never a hard requirement at module load.
- Test-injectable `installerLoader` and `pathLookup` hooks decouple resolution
from real filesystem state.
- `BINARY_MISSING` error code on full-chain failure with no leaked stack from
the underlying optional-dep failure.
- WeakMap-keyed memoisation that automatically GCs entries when the caller
drops their options reference.
## Usage: Basic
```ts
import { resolveBinary } from "@suaveplan/audio-codec-ffmpeg-native/binary/binary";
const { path, source } = await resolveBinary();
console.log(`Using ffmpeg at ${path} via ${source}`);
```
## Usage: Advanced
```ts
import { resolveBinary } from "@suaveplan/audio-codec-ffmpeg-native/binary/binary";
const opts = {
binaryProvider: async () => process.env.FFMPEG_PATH ?? "",
binaryPath: "/usr/local/bin/ffmpeg",
installerLoader: async () => ({ path: "/opt/ffmpeg" }),
pathLookup: () => "/usr/bin/ffmpeg",
} as const;
const first = await resolveBinary(opts);
const second = await resolveBinary(opts); // memoised — no re-resolution.
```
## API Reference
- `resolveBinary(opts?): Promise<ResolvedBinary>` — returns `{ path, source }`.
- `BinaryResolverOptions` — `binaryProvider`, `binaryPath`, `installerLoader`,
`pathLookup` (all optional; first match wins per the precedence chain).
- `ResolvedBinary.source` — `"provider" | "explicit" | "installer" | "path"`.
## Implementation Notes
The four-step chain is hard-coded — there is no plugin slot — because deviation
in precedence would silently change which binary actually runs on production
servers. Empty strings are treated as "miss" so callers can write `() => ""`
in a provider to opt into the next strategy without throwing.
The installer is imported via dynamic `import()` because static imports would
make `@ffmpeg-installer/ffmpeg` a hard dependency rather than the optional one
the spec requires. Any failure inside the installer load (missing module,
platform mismatch, exec-bit stripped) is swallowed and the chain advances —
the only surfaced error is `BINARY_MISSING` after the entire chain misses.
Memoisation uses a `WeakMap` keyed by the options object reference; callers
who want forced re-resolution pass a fresh options bundle.

View File

@@ -0,0 +1,96 @@
# `codec`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`FfmpegNativeCodec` is the server-tier concrete `AudioCodec` implementation
that backs `@suaveplan/audio-codec-ffmpeg-native`. It satisfies the
`@suaveplan/audio-codecs` plugin contract — `canDecode`, `decode`, `encode`,
`createDecoder`, `createEncoder` — by spawning a child `ffmpeg` process per
operation, piping bytes through stdin and reading streamed PCM out of stdout
without ever buffering the full input.
The class also owns the per-instance concurrency limit (a tiny FIFO
semaphore) so that multi-tenant servers cannot launch unbounded ffmpeg
children, and threads the caller's `AbortSignal` through to the
`runner` module so that cancellation reliably reaps the subprocess.
## Features
- Full `AudioCodec` surface: `canDecode`, `decode`, `encode`, plus the
optional `createDecoder` / `createEncoder` streaming factories.
- Configurable concurrency (`concurrencyLimit`, default 4) enforced through
an in-instance FIFO waiter queue.
- Per-operation `AbortSignal` and per-instance `timeoutMs` plumbed through
the runner; both escalate SIGTERM → SIGKILL.
- Strict PCM validation on encode (`validatePcm` raises `INVALID_PCM`).
- Strict encode-format allowlist (`UNSUPPORTED_CODEC` for anything outside
`wav | mp3 | ogg | opus | flac | aac | m4a | webm | matroska | wma`).
- f32le interleaving on encode + de-interleaving on decode (see `pcm.ts`).
## Usage: Basic
```ts
import { FfmpegNativeCodec } from "@suaveplan/audio-codec-ffmpeg-native/codec/codec";
const codec = new FfmpegNativeCodec();
const decoded = await codec.decode(mp3Bytes);
const wav = await codec.encode(decoded.channelData, {
sampleRate: decoded.sampleRate,
format: "wav",
});
```
## Usage: Advanced
```ts
import { FfmpegNativeCodec } from "@suaveplan/audio-codec-ffmpeg-native/codec/codec";
const codec = new FfmpegNativeCodec({
binaryProvider: () => process.env.FFMPEG_PATH ?? "",
concurrencyLimit: 8,
timeoutMs: 15_000,
channels: 1,
sampleRate: 16_000,
defaultEncodeFormat: "opus",
});
const controller = new AbortController();
setTimeout(() => controller.abort(), 5_000);
const encoded = await codec.encode([Float32Array.from([0.1, -0.1])], {
signal: controller.signal,
format: "flac",
});
```
## API Reference
- `new FfmpegNativeCodec(options?, spawn?)` — `options` matches
`FfmpegNativeCodecOptions`; the second positional `spawn` argument is the
test-only `Spawn` injector that bypasses `node:child_process.spawn`.
- `name`, `extensions`, `mimeTypes` — read-only metadata for registry
dispatch.
- `decode(bytes, opts?)` / `encode(channelData, opts?)` — full-buffer paths.
- `createDecoder(opts?)` / `createEncoder(opts?)` — streaming-factory
returning the additive companion shapes from `stream/*.ts`.
## Implementation Notes
The semaphore is intentionally inline (one private counter + one waiter
array) rather than a separate module because it never leaves this class —
extracting it would inflate the public surface for zero callers. The same
reasoning applies to the singleton `singleChunk` async generator used by
`encode`: it exists to feed `runEncode`'s `AsyncIterable<Uint8Array>` contract
from a single buffered byte string.
Decode and encode share argv templates (`decodeArgs` / `encodeArgs`) so the
streaming factories can reuse the exact same ffmpeg argv as the full-buffer
paths. This keeps the on-the-wire ffmpeg behaviour stable across the four
public methods.
`canDecode` deliberately leans on the magic-byte sniffer in `pcm.ts` rather
than running ffmpeg in a probe mode; we want `canDecode` to be cheap and
synchronous because the `CodecRegistry` calls it on every dispatch.

View File

@@ -0,0 +1,79 @@
# `pcm`
## Purpose
PCM helper module for `@suaveplan/audio-codec-ffmpeg-native`. The ffmpeg
subprocess speaks `f32le` (planar-interleaved 32-bit little-endian float) on
its stdin and stdout pipes; this module owns the conversion between that wire
shape and the channel-major `Float32Array[]` shape that the
`@suaveplan/audio-codecs` `DecodeResult` and encode input expect.
It also owns the conservative magic-byte sniffer used by `canDecode`, kept
here because both the codec and the streaming decoder lean on it for input
type-checks.
## Features
- `validatePcm(channels)` — enforces non-empty channel list, equal channel
lengths, raises `INVALID_PCM` on every divergence.
- `interleaveF32(channels)` — packs `Float32Array[]` into a single
`Uint8Array` view in f32le interleaved order.
- `deinterleaveF32(bytes, channels)` — inverse: splits an interleaved
`Uint8Array` into `channels` per-channel `Float32Array`s.
- `concat(chunks)` — single-pass `Uint8Array` concatenation used to merge
stdout chunks.
- `sniff(bytes)` — magic-byte format detection for the major container
formats (`wav`, `flac`, `ogg`, `mp3`, `matroska`/webm, `m4a`).
## Usage: Basic
```ts
import {
interleaveF32,
deinterleaveF32,
} from "@suaveplan/audio-codec-ffmpeg-native/codec/pcm";
const bytes = interleaveF32([leftChannel, rightChannel]);
const [left, right] = deinterleaveF32(bytes, 2);
```
## Usage: Advanced
```ts
import {
concat,
sniff,
validatePcm,
} from "@suaveplan/audio-codec-ffmpeg-native/codec/pcm";
const detected = sniff(headerSlice);
if (detected === "flac") console.log("FLAC stream confirmed");
validatePcm(channelData); // throws INVALID_PCM if uneven channels
const merged = concat(stdoutChunks);
```
## API Reference
- `validatePcm(channels: readonly Float32Array[]): void`
- `interleaveF32(channels: readonly Float32Array[]): Uint8Array`
- `deinterleaveF32(bytes: Uint8Array, channels: number): readonly Float32Array[]`
- `concat(chunks: readonly Uint8Array[]): Uint8Array`
- `sniff(bytes: Uint8Array): string | null`
## Implementation Notes
Interleave allocates one `Float32Array(channels * length)` then writes a
column-major copy; the returned `Uint8Array` is a zero-copy view over the
same backing buffer, so handing it directly to `child.stdin.write` avoids a
second allocation.
`deinterleaveF32` uses `bytes.buffer.slice(...)` rather than aliasing the
incoming buffer because callers frequently hand in `Uint8Array` views over a
shared Node `Buffer` pool — aliasing would otherwise yield channels that
silently mutate when the pool recycles its slab.
`sniff` returns `null` (rather than `"unknown"`) so the codec's `canDecode`
can short-circuit on a single nullability check. The mp3 sync pattern
`0xFF 0xEx` also matches AAC ADTS, which is acceptable because ffmpeg accepts
both with the same `pipe:0` decoder argv.

View File

@@ -0,0 +1,90 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the stable, namespaced error-code registry for
`@suaveplan/audio-codec-ffmpeg-native`. Every operational failure raised by the
package — binary-resolution mishaps, spawn/IO errors, codec-level decode/encode
failures, lifecycle events (timeout, abort) and input-validation rejections —
flows through a single `AudioCodecFfmpegNativeError` typed subclass whose code
field is narrowed to the union of `AUDIO_CODEC_FFMPEG_NATIVE.*` strings.
This centralisation lets consumers do exhaustive `switch (err.code)` dispatch
with TypeScript checking, and it gives observability the canonical label set to
attribute span events and metric counters against without per-call-site
stringly-typed labels.
## Features
- Eight stable codes covering binary, spawn, decode, encode, timeout, abort,
unsupported-codec and invalid-PCM failure modes.
- `defineCodes("AUDIO_CODEC_FFMPEG_NATIVE", [...])` produces frozen
`<namespace>.<reason>` constants and a precise literal-union type.
- `AudioCodecFfmpegNativeError` subclass of `SuaveplanError` preserves
`cause`, `context`, `traceId`, and the inherited timestamp.
- `AUDIO_CODEC_FFMPEG_NATIVE_ERROR_METADATA` records per-code description +
severity for human-readable surfacing (logs, dashboards).
## Usage: Basic
```ts
import {
AUDIO_CODEC_FFMPEG_NATIVE_CODES,
AudioCodecFfmpegNativeError,
} from "@suaveplan/audio-codec-ffmpeg-native/error-codes";
throw new AudioCodecFfmpegNativeError(
AUDIO_CODEC_FFMPEG_NATIVE_CODES.BINARY_MISSING,
"ffmpeg-native.binary.missing",
);
```
## Usage: Advanced
```ts
import {
AUDIO_CODEC_FFMPEG_NATIVE_CODES,
AUDIO_CODEC_FFMPEG_NATIVE_ERROR_METADATA,
AudioCodecFfmpegNativeError,
} from "@suaveplan/audio-codec-ffmpeg-native/error-codes";
try {
await someFfmpegOperation();
} catch (cause) {
const code = AUDIO_CODEC_FFMPEG_NATIVE_CODES.DECODE_FAILED;
throw new AudioCodecFfmpegNativeError(code, "ffmpeg-native.decode.failed", {
cause,
context: {
severity: AUDIO_CODEC_FFMPEG_NATIVE_ERROR_METADATA[code].severity,
stderr: "...last 4 KiB of ffmpeg stderr...",
},
});
}
```
## API Reference
- `AUDIO_CODEC_FFMPEG_NATIVE_CODES` — frozen record of namespaced codes.
- `AudioCodecFfmpegNativeErrorCode` — string-literal union of all codes.
- `AUDIO_CODEC_FFMPEG_NATIVE_ERROR_METADATA` — `Record<Code, { description, severity }>`.
- `AudioCodecFfmpegNativeError` — concrete `SuaveplanError` subclass; constructor
takes `(code, messageKey, options)`; `options.context.stderr` carries the
ffmpeg stderr tail when available.
## Implementation Notes
The eight codes are intentionally minimal: spec-level failure scenarios collapse
into one of these eight. `BINARY_MISSING` is reserved for the resolution
preflight, `SPAWN_FAILED` for `child_process.spawn` errors before any output is
read, and `DECODE_FAILED` / `ENCODE_FAILED` are the post-spawn ffmpeg-exit
failures. `TIMEOUT` and `ABORTED` share the SIGTERM-then-SIGKILL escalation
path; only their code distinguishes the trigger.
`AudioCodecFfmpegNativeError` deliberately mirrors the `AudioCodecsError`
shape (public constructor + `category` literal + `name` override) so that
multi-codec error funnels can discriminate by `category` without
runtime-importing every package.

View File

@@ -0,0 +1,77 @@
# `register`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`register(registry, options?)` is the one-line entry point applications use
to wire `@suaveplan/audio-codec-ffmpeg-native` into a
`@suaveplan/audio-codecs` `CodecRegistry`. It encapsulates construction of a
`FfmpegNativeCodec` with the given options and the structural `register`
call against the registry, returning the constructed codec for callers that
want to retain a direct reference (for later inspection, instrumentation, or
unregistration).
This helper exists so that downstream services do not need to import and
construct `FfmpegNativeCodec` directly; the import shape mirrors every other
codec plugin (`@suaveplan/audio-codecs` siblings, future
`audio-codec-ffmpeg-wasm`) for ergonomic consistency.
## Features
- Single function call, single import.
- Accepts the full `FfmpegNativeCodecOptions` bundle — binary providers,
concurrency, timeout, sample rate, etc.
- Optional `spawn` injection point for tests that want to register a codec
driven by a fake subprocess.
- Returns the constructed `FfmpegNativeCodec` for chained registry hooks.
- Operates against a structural `RegistryLike` view so registries from
different bundle versions remain interoperable.
## Usage: Basic
```ts
import { CodecRegistry } from "@suaveplan/audio-codecs";
import { register } from "@suaveplan/audio-codec-ffmpeg-native/register/register";
const registry = new CodecRegistry();
register(registry);
```
## Usage: Advanced
```ts
import { CodecRegistry } from "@suaveplan/audio-codecs";
import { register } from "@suaveplan/audio-codec-ffmpeg-native/register/register";
const registry = new CodecRegistry();
const codec = register(registry, {
binaryProvider: () => process.env.FFMPEG_PATH ?? "",
concurrencyLimit: 16,
timeoutMs: 30_000,
defaultEncodeFormat: "opus",
});
// Optionally swap the codec out later (multi-tenant test seam):
registry.unregister(codec.name);
```
## API Reference
- `register(registry, options?, spawn?): FfmpegNativeCodec`
- `RegisterOptions` — alias for `FfmpegNativeCodecOptions`.
- `RegistryLike` — `{ register(codec: AudioCodec): void }`.
## Implementation Notes
The function is intentionally a thin wrapper — it does not memoise, throttle,
or de-duplicate calls, because doing so would silently override the
registry's own replace-on-name-collision semantics documented in
`@suaveplan/audio-codecs`. Each `register` call constructs a fresh codec; if
the caller wants a singleton, they keep the returned reference around.
The `spawn` parameter is positional rather than nested inside `options` so
that the public surface in production code remains a clean two-argument
shape, and the test-only seam is opt-in.

View File

@@ -0,0 +1,99 @@
# `runner`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module is the single place in `@suaveplan/audio-codec-ffmpeg-native` that
imports `node:child_process`. It owns spawning ffmpeg, piping bytes through
stdin, streaming stdout chunks to a callback (never buffering the full
output), enforcing optional timeouts with a SIGTERM-then-SIGKILL escalation,
honouring caller-supplied AbortSignals with the same escalation path, and
mapping all post-spawn failure modes onto the typed
`AudioCodecFfmpegNativeError` codes.
Centralising spawn lifecycle here means decode, encode and the streaming
adapters never re-implement timeout/abort/exit handling — they only describe
the ffmpeg argv they want to run.
## Features
- `runDecode` — feeds a single `Uint8Array` into stdin then ends it.
- `runEncode` — drains an `AsyncIterable<Uint8Array>` into stdin with
backpressure-aware `drain` waits.
- `onStdout(chunk)` callback called per stdout chunk; nothing is buffered
inside the runner.
- Optional `timeoutMs` triggers SIGTERM with a 250 ms grace window before
SIGKILL.
- AbortSignal listeners use `{ once: true }` and are removed on settle.
- Rolling stderr ring buffer (default 4 KiB) preserved on
`error.context.stderr`.
- Injectable `spawn` for tests; defaults to `node:child_process.spawn`.
## Usage: Basic
```ts
import { runDecode } from "@suaveplan/audio-codec-ffmpeg-native/runner/runner";
const chunks: Uint8Array[] = [];
await runDecode({
binaryPath: "/usr/bin/ffmpeg",
args: ["-i", "pipe:0", "-f", "f32le", "-ac", "1", "pipe:1"],
input: compressedBytes,
onStdout: (chunk) => chunks.push(chunk),
});
```
## Usage: Advanced
```ts
import { runEncode } from "@suaveplan/audio-codec-ffmpeg-native/runner/runner";
async function* pcm(): AsyncIterable<Uint8Array> {
yield interleaveFloat32(channelDataA);
yield interleaveFloat32(channelDataB);
}
const out: Uint8Array[] = [];
const controller = new AbortController();
const result = await runEncode({
binaryPath: "/opt/ffmpeg",
args: ["-f", "f32le", "-ar", "48000", "-ac", "2", "-i", "pipe:0", "-f", "flac", "pipe:1"],
inputs: pcm(),
onStdout: (chunk) => out.push(chunk),
signal: controller.signal,
timeoutMs: 30_000,
stderrCap: 8192,
});
console.log("stderr tail:", result.stderr);
```
## API Reference
- `runDecode(opts: RunDecodeOptions): Promise<RunResult>`
- `runEncode(opts: RunEncodeOptions): Promise<RunResult>`
- `RunnerCommon` — `binaryPath`, `args`, `signal?`, `timeoutMs?`, `spawn?`, `stderrCap?`.
- `RunResult` — `{ stderr, exitCode }` (exitCode is always `0` on resolve).
- Error codes raised: `SPAWN_FAILED`, `DECODE_FAILED`, `ENCODE_FAILED`,
`TIMEOUT`, `ABORTED`.
## Implementation Notes
The single shared `settle` guard guarantees the returned promise resolves or
rejects exactly once even when stdin pump, child `error` and `close` events
all fire in close succession. `child.on("error", ...)` is the spawn-time
failure path (ENOENT/EACCES); `child.on("close", ...)` is the post-exit
discriminator that funnels exit codes into `DECODE_FAILED` / `ENCODE_FAILED`
depending on which entry point caller used.
Timeout and abort both go through the same `killEscalate` helper. The 250 ms
grace window is deliberately short — ffmpeg honours SIGTERM cleanly in the
common case, and on misbehaving builds SIGKILL guarantees process death
without leaking PIDs. `timeout?.unref?.()` keeps the Node event loop from
being kept alive by the timer.
The stderr ring buffer keeps the **tail** of stderr because ffmpeg emits its
informative diagnostics (codec banner, then per-frame progress) at the
**start** of a successful run but the **end** of a failing run.

View File

@@ -0,0 +1,80 @@
# `stream-decoder`
## Purpose
`createStreamingDecoder` returns a `FfmpegStreamingDecoder` — the
`@suaveplan/audio-codecs` `StreamingDecoder` plus an additive Promise-returning
`flushAsync()` companion. It is backed by a single long-running ffmpeg child
process per decoder instance, lazily spawned on the first `write` so that a
decoder constructed but never fed never spends a PID.
`write` and `flush` honour the underlying interface's synchronous contract by
returning whatever pages have already been buffered out of stdout; `flushAsync`
is the safe end-of-stream method that ends stdin, awaits the child to exit,
and yields any trailing pages.
## Features
- Single persistent ffmpeg subprocess per decoder.
- Lazy spawn — the child is only created once the first chunk arrives.
- Sync `write(chunk): DecodeResult[]` returning all pages buffered to date.
- Sync `flush(): DecodeResult[]` for callers that pump synchronously.
- Additive `flushAsync(): Promise<DecodeResult[]>` for safe end-of-stream
semantics (mandatory whenever the caller wants the trailing decoded frames).
- `AbortSignal` and `timeoutMs` integrated — both raise the canonical
`ABORTED` / `TIMEOUT` codes on the next `flushAsync` awaited.
## Usage: Basic
```ts
import { FfmpegNativeCodec } from "@suaveplan/audio-codec-ffmpeg-native";
const decoder = new FfmpegNativeCodec().createDecoder({ format: "ogg" });
for (const chunk of oggChunks) decoder.write(chunk);
const pages = await decoder.flushAsync();
decoder.close();
```
## Usage: Advanced
```ts
import { FfmpegNativeCodec } from "@suaveplan/audio-codec-ffmpeg-native";
const codec = new FfmpegNativeCodec({ channels: 2, sampleRate: 48_000 });
const controller = new AbortController();
const decoder = codec.createDecoder({ signal: controller.signal });
try {
for await (const chunk of network()) decoder.write(chunk);
const tail = await decoder.flushAsync();
process.stdout.write(`decoded ${tail.length} trailing pages\n`);
} finally {
decoder.close();
}
```
## API Reference
- `createStreamingDecoder(opts: StreamingDecoderOptions): FfmpegStreamingDecoder`
- `FfmpegStreamingDecoder` — extends `StreamingDecoder` with
`flushAsync(): Promise<readonly DecodeResult[]>`.
- `StreamingDecoderOptions` — `binaryProvider`, `args`, `channels`,
`sampleRate`, `format`, optional `signal`, `timeoutMs`, `spawn`.
## Implementation Notes
The `@suaveplan/audio-codecs` `StreamingDecoder` contract is synchronous —
`write(chunk): readonly DecodeResult[]`. Since the underlying ffmpeg
subprocess is fundamentally asynchronous, the synchronous `write` returns
only the pages already buffered from earlier `stdout data` events; the
new chunk is piped into stdin in the same call but its decoded output
arrives in subsequent ticks.
This is why `flushAsync` exists and why the package docs guide callers to
prefer it. The base `flush` is still useful for callers driving the decoder
from an event loop tick that already drained stdout (e.g. a `WritableStream`
pipe).
Frame alignment matters: ffmpeg's `-f f32le` output is a stream of
4-byte-per-sample, channels-interleaved frames. The sync drain only
de-interleaves a whole number of frames and re-buffers any trailing bytes
for the next call, so partial chunks never corrupt the channel split.

View File

@@ -0,0 +1,75 @@
# `stream-encoder`
## Purpose
`createStreamingEncoder` returns a `FfmpegStreamingEncoder` — the
`@suaveplan/audio-codecs` `StreamingEncoder` plus an additive Promise-returning
`flushAsync()` companion. It backs the encoder with a single long-running
ffmpeg subprocess that consumes interleaved f32le PCM on stdin and emits the
selected container format on stdout, lazily spawned on the first `start()` so
that an encoder constructed but never used pays no process cost.
## Features
- Single persistent ffmpeg subprocess per encoder.
- Lazy spawn on first `start()`; channel count chosen by the caller.
- Strict `validatePcm` on every `write` raises `INVALID_PCM` on bad input.
- Sync `start` / `write` / `flush` honour the base contract; additive
`flushAsync(): Promise<Uint8Array>` waits for ffmpeg to drain.
- `AbortSignal` and `timeoutMs` integrated — both raise canonical
`ABORTED` / `TIMEOUT` codes on the next `flushAsync`.
## Usage: Basic
```ts
import { FfmpegNativeCodec } from "@suaveplan/audio-codec-ffmpeg-native";
const encoder = new FfmpegNativeCodec().createEncoder({
sampleRate: 48_000,
format: "flac",
});
encoder.start(48_000, channelData.length);
encoder.write(channelData);
const tail = await encoder.flushAsync();
process.stdout.write(tail);
encoder.close();
```
## Usage: Advanced
```ts
import { FfmpegNativeCodec } from "@suaveplan/audio-codec-ffmpeg-native";
const codec = new FfmpegNativeCodec({ timeoutMs: 10_000 });
const encoder = codec.createEncoder({ format: "opus" });
encoder.start(48_000, 2);
for (const block of pcmBlocks()) encoder.write(block);
const finalBytes = await encoder.flushAsync();
encoder.close();
```
## API Reference
- `createStreamingEncoder(opts: StreamingEncoderOptions): FfmpegStreamingEncoder`
- `FfmpegStreamingEncoder` — extends `StreamingEncoder` with
`flushAsync(): Promise<Uint8Array>`.
- `StreamingEncoderOptions` — `binaryProvider`, `argsFor(channels)`,
optional `signal`, `timeoutMs`, `spawn`.
## Implementation Notes
The base `StreamingEncoder.start()` is documented as returning header bytes;
ffmpeg emits headers on stdout as part of the normal encode flow, so we
return an empty `Uint8Array` from `start()` and let the headers fall out of
the first `write` / `flushAsync` call. This is consistent with how all the
real-world container formats (FLAC, Opus, MP3) actually framing their headers
on the same byte stream as their data.
`write` returns synchronously with whatever bytes ffmpeg has already pushed
out of stdout. For most encoders the first synchronous return after a single
`write` is empty because ffmpeg buffers frames; callers MUST await
`flushAsync` to retrieve trailing bytes.
`chosenChannels` is captured in `start()` (or, defensively, the length of
the first `write` call's `channelData`) because the encode argv embeds the
`-ac <n>` flag — once ffmpeg is running, the channel count cannot change.

View File

@@ -0,0 +1,127 @@
# @suaveplan/audio-codec-ffmpeg-wasm - Browser-tier ffmpeg.wasm AudioCodec plugin for @suaveplan/audio-codecs. Lazy-loaded @ffmpeg/core wasm inside a Web Worker; decode/encode for WAV/MP3/OGG/Opus/FLAC/AAC/WebM; AbortSignal, timeout watchdog, COI-gated multi-thread, OTel spans+counters+histograms, structured AUDIO_CODEC_FFMPEG_WASM.* error codes.
Browser-tier `AudioCodec` plugin for
[`@suaveplan/audio-codecs`](../audio-codecs) backed by
[`@ffmpeg/ffmpeg`](https://github.com/ffmpegwasm/ffmpeg.wasm) running inside
a dedicated Web Worker. Decodes and encodes `wav`, `mp3`, `ogg`, `opus`,
`flac`, `aac`, and `webm`.
<!-- begin:auto-docs -->
## Documentation tree
- [codec](./src/codec/ffmpeg-wasm-codec.md) — This module assembles the public `FfmpegWasmCodec` class — the browser-tier `AudioCodec` plugin for `@suaveplan/audio-c…
- [ffmpeg-wasm-codec](./src/codec/ffmpeg-wasm-codec.md) — This module assembles the public `FfmpegWasmCodec` class — the browser-tier `AudioCodec` plugin for `@suaveplan/audio-c…
- [core-loader](./src/core-loader/core-loader.md) — This module owns the lazy resolution of the ffmpeg.wasm core module. It picks the single-thread `@ffmpeg/core` or the m…
- [core-loader](./src/core-loader/core-loader.md) — This module owns the lazy resolution of the ffmpeg.wasm core module. It picks the single-thread `@ffmpeg/core` or the m…
- [decode](./src/decode/decode-pipeline.md) — This module owns the pure logic of the decode pipeline: format sniffing from magic bytes, the ffmpeg argument list that…
- [decode-pipeline](./src/decode/decode-pipeline.md) — This module owns the pure logic of the decode pipeline: format sniffing from magic bytes, the ffmpeg argument list that…
- [encode](./src/encode/encode-pipeline.md) — This module owns the pure logic of the encode pipeline: validating the requested format against the supported set *befo…
- [encode-pipeline](./src/encode/encode-pipeline.md) — This module owns the pure logic of the encode pipeline: validating the requested format against the supported set *befo…
- [error-codes](./src/error-codes/error-codes.md) — This module declares the canonical error-code registry for `@suaveplan/audio-codec-ffmpeg-wasm`. It exposes the nine `A…
- [error-codes](./src/error-codes/error-codes.md) — This module declares the canonical error-code registry for `@suaveplan/audio-codec-ffmpeg-wasm`. It exposes the nine `A…
- [register](./src/register/register.md) — This module exposes the `register(registry, options?)` install helper — a single function that callers use to plug `Ffm…
- [register](./src/register/register.md) — This module exposes the `register(registry, options?)` install helper — a single function that callers use to plug `Ffm…
- [streaming-decoder](./src/streaming/streaming-decoder.md) — This module implements the `StreamingDecoder` contract from `@suaveplan/audio-codecs` over ffmpeg.wasm. The implementat…
- [streaming-encoder](./src/streaming/streaming-encoder.md) — This module implements the `StreamingEncoder` contract from `@suaveplan/audio-codecs` over ffmpeg.wasm. The implementat…
- [worker](./src/worker/worker-host.md) — This module owns the lifecycle of the dedicated Web Worker that runs the ffmpeg.wasm core. It serialises every codec ca…
- [worker-host](./src/worker/worker-host.md) — This module owns the lifecycle of the dedicated Web Worker that runs the ffmpeg.wasm core. It serialises every codec ca…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/audio-codec-ffmpeg-wasm @suaveplan/audio-codecs
# The wasm core packages are optional dependencies — install whichever you
# plan to serve:
bun add @ffmpeg/ffmpeg @ffmpeg/core
# Optional: multi-thread core (~30 MB) for cross-origin-isolated pages
bun add @ffmpeg/core-mt
```
## Quick start
```ts
import { CodecRegistry } from "@suaveplan/audio-codecs";
import { register } from "@suaveplan/audio-codec-ffmpeg-wasm/register/register";
const registry = new CodecRegistry();
const codec = register(registry, {
corePath: "/static/ffmpeg-core.js",
});
const doc = await registry.decode(mp3Bytes);
const opus = await registry.encode(doc, "opus");
await codec.dispose();
```
## The 30 MB lazy-load disclaimer
The `@ffmpeg/core` wasm payload is roughly 30 MB. It is **not** loaded
when this package is imported, nor when `new FfmpegWasmCodec()` is
constructed — it is loaded on the first call to `decode`, `encode`,
`createDecoder`, or `createEncoder`. Subsequent calls reuse the loaded
core. `codec.dispose()` tears the worker down and releases the core.
## Multi-thread (cross-origin isolation)
Pass `enableMultithread: true` to opt into the `@ffmpeg/core-mt` build.
This requires the page to be cross-origin-isolated:
- Serve the page with
`Cross-Origin-Opener-Policy: same-origin` and
`Cross-Origin-Embedder-Policy: require-corp`.
- `globalThis.crossOriginIsolated` must be `true` at runtime.
If `enableMultithread` is `true` without COI, the codec throws
`AUDIO_CODEC_FFMPEG_WASM.CROSS_ORIGIN_ISOLATION_REQUIRED` on first use
**before** attempting the dynamic `import()`. If COI is true but the
optional `@ffmpeg/core-mt` package is missing, the codec logs a warning
and silently falls back to the single-thread core.
## Streaming honesty note
The `StreamingDecoder` / `StreamingEncoder` factories from this codec
are **buffered emulation** — ffmpeg.wasm is whole-file in / whole-file
out, so `write(chunk)` only appends to an internal buffer and never
invokes the worker; the actual decode/encode happens once when
`flushAsync()` is awaited. Use this for chunked-source UX (progress
bars, abortable downloads), not for true low-latency streaming.
## Public API
- `class FfmpegWasmCodec implements AudioCodec`
- `class FfmpegWasmStreamingDecoder implements StreamingDecoder`
- `class FfmpegWasmStreamingEncoder implements StreamingEncoder`
- `class WorkerHost` — worker lifecycle abstraction
- `loadCore(opts)` — lazy core loader
- `register(registry, options?)` — one-line install helper
- `AUDIO_CODEC_FFMPEG_WASM_CODES` + `AudioCodecFfmpegWasmError`
## Error codes
| Code | Thrown when |
|---|---|
| `AUDIO_CODEC_FFMPEG_WASM.CORE_LOAD_FAILED` | wasm core fetch / import failed |
| `AUDIO_CODEC_FFMPEG_WASM.WORKER_SPAWN_FAILED` | Worker construction or pre-load error |
| `AUDIO_CODEC_FFMPEG_WASM.DECODE_FAILED` | ffmpeg non-zero exit on decode |
| `AUDIO_CODEC_FFMPEG_WASM.ENCODE_FAILED` | ffmpeg non-zero exit on encode |
| `AUDIO_CODEC_FFMPEG_WASM.TIMEOUT` | Per-call watchdog fired |
| `AUDIO_CODEC_FFMPEG_WASM.ABORTED` | `AbortSignal` propagated |
| `AUDIO_CODEC_FFMPEG_WASM.UNSUPPORTED_CODEC` | Format outside the supported set, or Zod boundary failure |
| `AUDIO_CODEC_FFMPEG_WASM.MEMORY_LIMIT_EXCEEDED` | wasm 4 GB linear memory cap hit |
| `AUDIO_CODEC_FFMPEG_WASM.CROSS_ORIGIN_ISOLATION_REQUIRED` | `enableMultithread` without COI |
## Test seams
For testing without a real worker, supply:
- `workerFactory: () => Worker` — returns a controllable shim.
- `coreLoader: () => Promise<unknown>` — overrides the dynamic
`import("@ffmpeg/core")`.
- `coreMtLoader: () => Promise<unknown>` — overrides the dynamic
`import("@ffmpeg/core-mt")`.
These are dependency-injection seams, not mocks; production callers
omit them and the real `new Worker(coreUrl)` / dynamic `import()` path
runs unchanged.

View File

@@ -0,0 +1,88 @@
# `ffmpeg-wasm-codec.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module assembles the public `FfmpegWasmCodec` class — the browser-tier
`AudioCodec` plugin for `@suaveplan/audio-codecs`. The class wires Zod
boundary validation, the lazy core loader, the worker host, the decode /
encode pipelines, the streaming decoder / encoder factories, and the
OpenTelemetry instrumentation into a single object that satisfies the
`AudioCodec` contract.
## Features
- Constructor validates options against `FfmpegWasmCodecOptionsSchema`;
malformed options throw `UNSUPPORTED_CODEC` with the Zod issue list.
- `canDecode` sniffs magic bytes against the supported format set.
- `decode` / `encode` lazy-load the wasm core and the worker, run the
pipeline, emit spans, increment counters, record histograms.
- `createDecoder` / `createEncoder` return the streaming wrappers
defined in `streaming/`.
- `dispose()` terminates the worker and releases the loaded core.
## Usage: Basic
```ts
import { FfmpegWasmCodec } from "@suaveplan/audio-codec-ffmpeg-wasm/codec/ffmpeg-wasm-codec";
const codec = new FfmpegWasmCodec({ corePath: "/static/ffmpeg-core.js" });
const result = await codec.decode(wavBytes);
console.log(result.channelData.length);
await codec.dispose();
```
## Usage: Advanced
```ts
import { FfmpegWasmCodec } from "@suaveplan/audio-codec-ffmpeg-wasm/codec/ffmpeg-wasm-codec";
const codec = new FfmpegWasmCodec({
corePath: "/static/ffmpeg-core-mt.js",
enableMultithread: true,
progressCallback: (p) => console.log("progress", p),
logger: myLogger,
clock: () => performance.now(),
workerFactory: () => new Worker(coreUrl, { type: "module" }),
});
try {
const result = await codec.decode(buffer, {
signal: controller.signal,
format: "mp3",
});
const opus = await codec.encode(result.channelData, {
format: "opus",
sampleRate: result.sampleRate,
bitrate: 96_000,
});
} finally {
await codec.dispose();
}
```
## API Reference
- `class FfmpegWasmCodec implements AudioCodec`.
- `new FfmpegWasmCodec(options?: FfmpegWasmCodecOptions)`.
- `codec.name`, `codec.extensions`, `codec.mimeTypes`.
- `codec.canDecode(bytes)`.
- `codec.decode(bytes, opts?): Promise<DecodeResult>`.
- `codec.encode(channelData, opts?): Promise<Uint8Array>`.
- `codec.createDecoder(opts?): FfmpegWasmStreamingDecoder`.
- `codec.createEncoder(opts?): FfmpegWasmStreamingEncoder`.
- `codec.dispose(): Promise<void>`.
## Implementation Notes
The class holds at most one `WorkerHost` and one `LoadedCore` at a time.
`dispose()` is idempotent — calling it twice is a no-op the second time.
The OTel spans, counters, and histograms use namespaced names matching
the spec (`audio-codec-ffmpeg-wasm.decode`, etc.). Every `decode` and
`encode` increments the counter exactly once with an `outcome` label
(`ok`, `core-load-failed`, or `error`) so callers can compute per-format
failure rates without scraping span events. The progress callback is
wired through `WorkerHost.onProgress`, which catches and logs user
callback exceptions so a buggy callback cannot abort an in-flight call.

View File

@@ -0,0 +1,78 @@
# `core-loader.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the lazy resolution of the ffmpeg.wasm core module. It picks
the single-thread `@ffmpeg/core` or the multi-thread `@ffmpeg/core-mt` based
on the caller's `enableMultithread` flag and the page's
`globalThis.crossOriginIsolated` value, then memoises the result so a second
codec call reuses the already-loaded core. All static analysis of the source
must show that `@ffmpeg/core-mt` is reached only inside a dynamic `import()`
expression — never as a static import — so consumers without the optional
dependency installed pay zero install cost.
## Features
- Lazy: nothing happens until `loadCore` is called.
- URL precedence: `corePath` > `coreUrl` > bundled `DEFAULT_CORE_URL`.
- COI gate: `enableMultithread === true` with COI false throws
`CROSS_ORIGIN_ISOLATION_REQUIRED` before the mt dynamic import is reached.
- Optional-dep fallback: when COI is true but `import("@ffmpeg/core-mt")`
rejects, the loader logs a warning via `opts.logger` and loads the
single-thread core instead.
- Failure surface: any single-thread load failure throws
`CORE_LOAD_FAILED` with the resolved URL in `context.coreUrl`.
## Usage: Basic
```ts
import { loadCore } from "@suaveplan/audio-codec-ffmpeg-wasm/core-loader/core-loader";
const core = await loadCore({ corePath: "/static/ffmpeg-core.js" });
console.log(core.multithread); // false
console.log(core.resolvedUrl); // "/static/ffmpeg-core.js"
```
## Usage: Advanced
```ts
import { loadCore } from "@suaveplan/audio-codec-ffmpeg-wasm/core-loader/core-loader";
// Multi-thread with explicit COI override (testing or controlled embed)
const core = await loadCore({
corePath: "/static/ffmpeg-core-mt.js",
enableMultithread: true,
crossOriginIsolated: true,
logger: myLogger,
coreMtLoader: () => import("@ffmpeg/core-mt"),
});
```
The `coreLoader` / `coreMtLoader` options are dependency-injection seams.
Tests use them to assert lazy load semantics, exercise the COI gate, and
hit the optional-dep-missing fallback without altering the import graph
at static-analysis time. Production callers should leave both unset so
the dynamic `import()` is the only path that ever names the ffmpeg
modules.
## API Reference
- `DEFAULT_CORE_URL: string` — the bundled CDN fallback.
- `loadCore(opts: CoreLoaderOptions): Promise<LoadedCore>`.
- `interface CoreLoaderOptions` — `coreUrl`, `corePath`, `enableMultithread`,
`logger`, `coreLoader`, `coreMtLoader`, `crossOriginIsolated`.
- `interface LoadedCore` — `multithread`, `resolvedUrl`, `module`.
## Implementation Notes
The COI check intentionally happens *before* the dynamic `import()` so
that callers without the `@ffmpeg/core-mt` optional dep installed never
incur a module-not-found warning when the COI gate is the real failure.
The fallback path runs only when COI is true *and* the mt import rejects
— if COI is true and mt loads successfully, no fallback occurs. The
`crossOriginIsolated` override exists so tests can drive both branches
deterministically without manipulating the global.

View File

@@ -0,0 +1,82 @@
# `decode-pipeline.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the pure logic of the decode pipeline: format sniffing
from magic bytes, the ffmpeg argument list that emits `f32le` interleaved
samples to a MEMFS scratch file, and the deinterleave step that produces
per-channel `Float32Array`s for the `DecodeResult` shape. The pipeline
runs entirely through the supplied `WorkerHost` so cancellation,
timeouts, and OOM detection are inherited from a single source of
truth.
## Features
- `sniffFormat(bytes)` recognises WAV, MP3 (ID3v2 + frame-sync), Ogg
(Vorbis vs Opus by inspecting the bos packet), FLAC, AAC ADTS, and
WebM/EBML headers.
- `deinterleave(samples, channels)` returns `channels` equal-length
`Float32Array`s.
- `runDecode(opts)` orchestrates writeFile → run → readFile via
`WorkerHost.submitFfmpeg` and returns a normalised `DecodeResult`.
- Default sample rate (48 000) and channel count (2) for callers that
do not pre-probe their input.
## Usage: Basic
```ts
import { runDecode } from "@suaveplan/audio-codec-ffmpeg-wasm/decode/decode-pipeline";
const result = await runDecode({ host, bytes, format: "wav" });
console.log(result.channelData.length); // channel count
console.log(result.channelData[0].length); // frame count
console.log(result.sampleRate);
```
## Usage: Advanced
```ts
import {
runDecode,
sniffFormat,
deinterleave,
} from "@suaveplan/audio-codec-ffmpeg-wasm/decode/decode-pipeline";
const fmt = sniffFormat(bytes);
if (!fmt) throw new Error("unsupported");
const result = await runDecode({
host,
bytes,
format: fmt as "wav" | "mp3" | "ogg" | "opus" | "flac" | "aac" | "webm",
sampleRate: 44_100,
channels: 1,
timeoutMs: 30_000,
signal: controller.signal,
});
// Drop a deinterleaved view for downstream processing
const ch = deinterleave(new Float32Array([0, 1, 2, 3]), 2);
```
## API Reference
- `DEFAULT_SAMPLE_RATE: 48_000`.
- `DEFAULT_CHANNELS: 2`.
- `sniffFormat(bytes: Uint8Array): string | undefined`.
- `deinterleave(interleaved: Float32Array, channels: number): Float32Array[]`.
- `runDecode(opts: DecodePipelineOptions): Promise<DecodeResult>`.
## Implementation Notes
The deinterleave step copies the raw `Uint8Array` through a `Float32Array`
view to honour the f32le wire layout and then takes a `.slice()` so the
returned arrays own their own backing buffer — callers can transfer or
mutate them without aliasing the worker output. The pipeline never
silently catches MEMFS cleanup failures here; the host's `submitFfmpeg`
handles try/finally cleanup and logs any delete failures via the
injected logger. A non-zero ffmpeg exit is mapped to
`AUDIO_CODEC_FFMPEG_WASM.DECODE_FAILED` inside the worker host so this
module never sees it directly.

View File

@@ -0,0 +1,84 @@
# `encode-pipeline.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the pure logic of the encode pipeline: validating the
requested format against the supported set *before* the worker is touched,
interleaving per-channel PCM into a single `Float32Array`, projecting
that into ffmpeg's `f32le` wire format, building the format-specific
ffmpeg command for each of `wav` / `mp3` / `ogg` / `opus` / `flac` /
`aac` / `webm`, and dispatching the call through the supplied
`WorkerHost`. All MEMFS lifecycle is owned by the host.
## Features
- `interleave` and `float32ToBytes` produce the exact wire layout ffmpeg
expects for `-f f32le`.
- `buildCommand` returns the ffmpeg argument list per supported format,
honouring caller-supplied `bitrate` (`mp3`, `opus`, `aac`, `webm`) and
`quality` (`ogg`).
- `assertSupported` throws `UNSUPPORTED_CODEC` synchronously, before any
worker message is posted — required by the spec.
- `runEncode` drives the full pipeline through `WorkerHost.submitFfmpeg`.
## Usage: Basic
```ts
import { runEncode } from "@suaveplan/audio-codec-ffmpeg-wasm/encode/encode-pipeline";
const bytes = await runEncode({
host,
channelData: [leftFloats, rightFloats],
format: "mp3",
sampleRate: 44_100,
bitrate: 256_000,
});
```
## Usage: Advanced
```ts
import {
runEncode,
interleave,
buildCommand,
} from "@suaveplan/audio-codec-ffmpeg-wasm/encode/encode-pipeline";
// Inspect the command ffmpeg will be asked to run
const cmd = buildCommand("opus", 48_000, 1, 96_000);
console.log(cmd.args);
const interleaved = interleave([mono]);
const result = await runEncode({
host,
channelData: [mono],
format: "opus",
sampleRate: 48_000,
bitrate: 96_000,
signal: controller.signal,
timeoutMs: 60_000,
});
```
## API Reference
- `interleave(channelData): Float32Array`.
- `float32ToBytes(samples): Uint8Array`.
- `buildCommand(format, sampleRate, channels, bitrate?, quality?): FormatCommand`.
- `runEncode(opts: EncodePipelineOptions): Promise<Uint8Array>`.
## Implementation Notes
`assertSupported` is intentionally written as a TypeScript `asserts`
function so its throw branch narrows the format type for the rest of the
function — no extra runtime branch is needed. Format-specific defaults
(192 kbps for MP3/AAC, 96 kbps for Opus/WebM, q=5 for Vorbis) are
chosen to match the ffmpeg.wasm reference build's documented sweet
spots; callers always override via `bitrate`/`quality` for production
use. The interleaved `Float32Array` is wrapped in a `Uint8Array` view
without an extra copy so the worker `postMessage` boundary takes one
transferable buffer instead of two.

View File

@@ -0,0 +1,77 @@
# `error-codes.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module declares the canonical error-code registry for
`@suaveplan/audio-codec-ffmpeg-wasm`. It exposes the nine `AUDIO_CODEC_FFMPEG_WASM.*`
codes the package can throw, an immutable metadata record describing each code,
the `AudioCodecFfmpegWasmError` class that every codec fault inherits from, and
an idempotent registration hook so external systems (OTel attribute binders,
report generators) can record that the registry has been touched.
## Features
- Nine stable error codes covering core-load failures, worker spawn, decode,
encode, timeout, abort, unsupported codec, OOM and COI gating.
- `AudioCodecFfmpegWasmError` class — extends `SuaveplanError`, carries
`category`, `code`, `context`, and the originating `cause`.
- Frozen `AUDIO_CODEC_FFMPEG_WASM_ERROR_METADATA` record with description and
severity per code.
- `registerAudioCodecFfmpegWasmErrorCodes()` no-op hook for downstream wiring.
## Usage: Basic
```ts
import {
AudioCodecFfmpegWasmError,
AUDIO_CODEC_FFMPEG_WASM_CODES,
} from "@suaveplan/audio-codec-ffmpeg-wasm/error-codes";
throw new AudioCodecFfmpegWasmError(
AUDIO_CODEC_FFMPEG_WASM_CODES.UNSUPPORTED_CODEC,
"audio-codec-ffmpeg-wasm.encode.unknown-format",
{ context: { format: "alac" } },
);
```
## Usage: Advanced
```ts
import {
AUDIO_CODEC_FFMPEG_WASM_ERROR_METADATA,
type AudioCodecFfmpegWasmErrorCode,
} from "@suaveplan/audio-codec-ffmpeg-wasm/error-codes";
function severityFor(code: AudioCodecFfmpegWasmErrorCode): string {
return AUDIO_CODEC_FFMPEG_WASM_ERROR_METADATA[code].severity;
}
```
The metadata record is frozen so downstream consumers can safely lift it into
read-only contexts (OTel attribute maps, code-to-i18n-key tables). Throw sites
elsewhere in the package always carry a `context` payload — `coreUrl`,
`format`, `elapsedMs`, `timeoutMs`, `requestedBytes`, or the Zod issues list.
## API Reference
- `AUDIO_CODEC_FFMPEG_WASM_CODES` — `defineCodes("AUDIO_CODEC_FFMPEG_WASM", […])`
result; key → namespaced code string.
- `type AudioCodecFfmpegWasmErrorCode` — union of every code string value.
- `AUDIO_CODEC_FFMPEG_WASM_ERROR_METADATA` — frozen `Record` of metadata.
- `class AudioCodecFfmpegWasmError extends SuaveplanError<…>` — instances expose
`category = "audio-codec-ffmpeg-wasm"`, `code`, `messageKey`, `context`,
`cause`.
- `registerAudioCodecFfmpegWasmErrorCodes()` — idempotent no-op.
## Implementation Notes
The metadata record is `Object.freeze`d so it cannot be mutated at runtime —
this keeps the OTel binder behaviour deterministic. The class has a `category`
field so the constructor body is non-trivial, sidestepping the `useless-constructor`
biome rule without any `biome-ignore` comment (CLAUDE.md rule 9). Every code
emitted by this package is reachable from a real call site exercised in
`error-codes.test.ts`, satisfying the §2c reachability requirement.

View File

@@ -0,0 +1,70 @@
# `register.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module exposes the `register(registry, options?)` install helper —
a single function that callers use to plug `FfmpegWasmCodec` into a
shared `CodecRegistry` from `@suaveplan/audio-codecs`. The helper exists
so consumers can install the codec without importing the class
explicitly; it forwards options through the codec constructor (which
runs Zod validation) and returns the constructed instance so callers
can `dispose()` it later.
## Features
- One-line installation against a `CodecRegistry`.
- Forwards options through `FfmpegWasmCodec`'s Zod boundary.
- Returns the constructed codec so callers retain a `dispose()` handle.
- Re-register semantics: a second call against the same registry with
the same codec name replaces the prior entry.
## Usage: Basic
```ts
import { CodecRegistry } from "@suaveplan/audio-codecs";
import { register } from "@suaveplan/audio-codec-ffmpeg-wasm/register/register";
const registry = new CodecRegistry();
const codec = register(registry);
// later:
await codec.dispose();
```
## Usage: Advanced
```ts
import { CodecRegistry } from "@suaveplan/audio-codecs";
import { register } from "@suaveplan/audio-codec-ffmpeg-wasm/register/register";
const registry = new CodecRegistry();
const codec = register(registry, {
corePath: "/static/ffmpeg-core.js",
enableMultithread: true,
crossOriginIsolated: globalThis.crossOriginIsolated,
progressCallback: (p) => store.update({ encodeProgress: p }),
logger: myLogger,
});
window.addEventListener("beforeunload", () => {
void codec.dispose();
});
```
## API Reference
- `register(registry: CodecRegistry, options?: FfmpegWasmCodecOptions): FfmpegWasmCodec`.
## Implementation Notes
The helper deliberately delegates validation to the codec constructor
rather than duplicating the Zod schema call here — there is exactly one
boundary. A second call against the same registry with the same `name`
("ffmpeg-wasm") replaces the prior entry per
`CodecRegistry.register`'s replace-on-duplicate behaviour. Callers
holding the previous instance from a prior `register()` call must
explicitly `dispose()` it; the registry does not own dispose
responsibility.

View File

@@ -0,0 +1,73 @@
# `streaming-decoder.ts`
## Purpose
This module implements the `StreamingDecoder` contract from
`@suaveplan/audio-codecs` over ffmpeg.wasm. The implementation is
buffered emulation — ffmpeg.wasm is whole-file in / whole-file out and
cannot produce decoded pages incrementally, so this class accumulates
chunks in memory and runs the decode pipeline once during `flush`.
## Features
- `write(chunk)` buffers without touching the worker; returns `[]`.
- `flushAsync()` runs the decode pipeline and caches the result.
- `flush()` (synchronous, per the contract) returns the cached result
populated by a prior `flushAsync()` call.
- Idempotent `flush` / `flushAsync` — subsequent calls return cached.
- `close()` clears buffered chunks; idempotent.
## Usage: Basic
```ts
import { FfmpegWasmStreamingDecoder } from "@suaveplan/audio-codec-ffmpeg-wasm/streaming/streaming-decoder";
const decoder = new FfmpegWasmStreamingDecoder({ host, format: "wav" });
for (const chunk of chunks) decoder.write(chunk);
const result = await decoder.flushAsync();
decoder.close();
```
## Usage: Advanced
```ts
const decoder = new FfmpegWasmStreamingDecoder({
host,
format: "mp3",
sampleRate: 44_100,
channels: 2,
timeoutMs: 30_000,
signal: controller.signal,
});
let consumed = 0;
for await (const chunk of source) {
decoder.write(chunk);
consumed += chunk.byteLength;
}
const decoded = await decoder.flushAsync();
// Subsequent synchronous flush() calls return the cached result so
// CodecRegistry.decodeStream can pull frames in lock-step.
const sync = decoder.flush();
```
## API Reference
- `class FfmpegWasmStreamingDecoder implements StreamingDecoder`.
- `new FfmpegWasmStreamingDecoder(opts: StreamingDecoderOptions)`.
- `decoder.write(chunk): readonly DecodeResult[]`.
- `decoder.flush(): readonly DecodeResult[]`.
- `decoder.flushAsync(): Promise<DecodeResult | null>`.
- `decoder.close(): void`.
- `interface StreamingDecoderOptions`.
## Implementation Notes
The `StreamingDecoder` interface in `@suaveplan/audio-codecs` is fully
synchronous. ffmpeg.wasm cannot satisfy that synchronously, so the
implementation exposes an additional `flushAsync()` method that callers
must await before they invoke `flush()` to retrieve the result. The
`CodecRegistry.decodeStream` consumer in `audio-codecs` calls `flush()`
synchronously, so a wrapping `ReadableStream` `pull` body must await
`flushAsync()` first. Inside an async pull body the wait is natural.
Progress events fire only during the `flushAsync()` call, never during
`write()`.

View File

@@ -0,0 +1,71 @@
# `streaming-encoder.ts`
## Purpose
This module implements the `StreamingEncoder` contract from
`@suaveplan/audio-codecs` over ffmpeg.wasm. The implementation is
buffered emulation — ffmpeg.wasm cannot stream-encode, so this class
accumulates per-channel PCM blocks in memory and runs the encode
pipeline once during `flushAsync`. Synchronous `start` / `write` /
`flush` return empty bytes until `flushAsync` has been awaited.
## Features
- `start(sampleRate, channels)` captures the rate and channel count; emits
no header bytes.
- `write(channelData)` appends a block per channel without touching the
worker.
- `flushAsync()` runs the encode pipeline once and caches the encoded
bytes.
- `flush()` (synchronous, per the contract) returns the cached bytes.
- Idempotent `flush` / `flushAsync` / `close`.
## Usage: Basic
```ts
import { FfmpegWasmStreamingEncoder } from "@suaveplan/audio-codec-ffmpeg-wasm/streaming/streaming-encoder";
const encoder = new FfmpegWasmStreamingEncoder({ host, format: "mp3" });
encoder.start(44_100, 2);
encoder.write([left, right]);
const bytes = await encoder.flushAsync();
encoder.close();
```
## Usage: Advanced
```ts
const encoder = new FfmpegWasmStreamingEncoder({
host,
format: "opus",
bitrate: 128_000,
signal: controller.signal,
timeoutMs: 60_000,
});
encoder.start(48_000, 1);
for (const page of pages) encoder.write(page.channelData);
const encoded = await encoder.flushAsync();
// Subsequent synchronous flush() calls return the cached bytes.
const sync = encoder.flush();
```
## API Reference
- `class FfmpegWasmStreamingEncoder implements StreamingEncoder`.
- `new FfmpegWasmStreamingEncoder(opts: StreamingEncoderOptions)`.
- `encoder.start(sampleRate, channels): Uint8Array`.
- `encoder.write(channelData): Uint8Array`.
- `encoder.flush(): Uint8Array`.
- `encoder.flushAsync(): Promise<Uint8Array>`.
- `encoder.close(): void`.
## Implementation Notes
The encoder mirrors `streaming-decoder` exactly: `start` and `write` are
synchronous shims that buffer; the heavy lifting happens in
`flushAsync()`. Per-channel arrays are concatenated by allocating a
fresh `Float32Array` per `write` call — fine for short streams; large
streams should call the non-streaming `encode` instead. Output bytes
are returned only once `flushAsync` resolves; the synchronous `flush()`
returns the cached value so `CodecRegistry.encodeStream` can pull
bytes in lock-step inside an async pull body.

View File

@@ -0,0 +1,99 @@
# `worker-host.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the lifecycle of the dedicated Web Worker that runs the
ffmpeg.wasm core. It serialises every codec call onto the worker, plumbs
`AbortSignal` and a timeout watchdog through to `worker.terminate()`,
parses worker stderr for memory-limit conditions, and bridges progress
events to the user-supplied callback. The host is the unit of cancellation
for the entire codec.
## Features
- Lazy worker construction through the injected `workerFactory` seam.
- One-time `load` handshake; subsequent calls reuse the loaded core.
- `submitFfmpeg` writes input bytes to MEMFS, runs an ffmpeg command,
reads the output, and cleans both MEMFS entries via try/finally.
- Pre-aborted and mid-call `AbortSignal` handling — terminate the worker
and reject every pending call with `AUDIO_CODEC_FFMPEG_WASM.ABORTED`.
- Per-call timeout watchdog driven by the injected `Clock`. Defaults
60 s decode / 120 s encode; configurable per call.
- Memory-limit detection scans worker stderr (`Cannot enlarge memory`,
`OutOfMemory`, `Aborted()`) and surfaces
`AUDIO_CODEC_FFMPEG_WASM.MEMORY_LIMIT_EXCEEDED`.
- Worker spawn failures (factory throws, `error` before `load` resolves,
`postMessage` throws) surface as `WORKER_SPAWN_FAILED`.
## Usage: Basic
```ts
import { WorkerHost } from "@suaveplan/audio-codec-ffmpeg-wasm/worker/worker-host";
const host = new WorkerHost({
workerFactory: () => new Worker(coreUrl),
clock: () => Date.now(),
});
await host.load();
const { outputBytes, stderr } = await host.submitFfmpeg(
{ path: "input.mp3", bytes: inputBytes },
"output.raw",
["-i", "input.mp3", "-f", "f32le", "output.raw"],
{ category: "decode" },
);
host.terminate();
```
## Usage: Advanced
```ts
import { WorkerHost } from "@suaveplan/audio-codec-ffmpeg-wasm/worker/worker-host";
const ctl = new AbortController();
const host = new WorkerHost({
workerFactory: () => new Worker(coreUrl, { type: "module" }),
clock: () => performance.now(),
progressCallback: (p) => console.log("progress", p),
logger: myLogger,
});
try {
const result = await host.submitFfmpeg(
{ path: "in.wav", bytes: wavBytes },
"out.opus",
["-i", "in.wav", "-c:a", "libopus", "-b:a", "96k", "out.opus"],
{ category: "encode", signal: ctl.signal, timeoutMs: 30_000 },
);
return result.outputBytes;
} finally {
host.terminate();
}
```
## API Reference
- `class WorkerHost` — owns one worker.
- `new WorkerHost(opts: WorkerHostOptions)`.
- `host.isLoaded: boolean`.
- `host.ensureWorker(): WorkerLike`.
- `host.load(): Promise<void>`.
- `host.submitFfmpeg(input, outputPath, args, opts): Promise<FfmpegRunResult>`.
- `host.terminate(reason?: AudioCodecFfmpegWasmError): void`.
- `host.onProgress(progress: number): void`.
- `DEFAULT_TIMEOUTS` — readonly `{ decode: 60_000, encode: 120_000 }`.
## Implementation Notes
The host treats the worker as a stateful resource: every call goes through
`ensureWorker`, every failure path resets the worker reference so the next
call constructs a fresh one. The `WorkerLike` interface only requires
`postMessage` / `addEventListener` / `removeEventListener` / `terminate`,
so the real DOM `Worker` and the test shim are both first-class. Memory
detection runs against both the structured `WorkerResponse.stderr` channel
and any free-floating `stderr` events posted between requests. The
watchdog timer is cleared on every settle path — success, failure,
abort, and the OOM short-circuit — so the host never leaks pending
`setTimeout` handles.

View File

@@ -0,0 +1,144 @@
# @suaveplan/audio-codecs - Universal audio codec registry: decode/encode plugin loader with format detection, progressive streaming decode, native WAV/AIFF/CAF codecs, and lazy-loaded WASM codecs for MP3/FLAC/Ogg/Opus/AAC/WebM/QOA. Universal with runtime-gated node:fs access.
Universal audio codec registry: decode/encode plugin loader with
magic-byte format detection, progressive streaming decode, native
WAV/AIFF/CAF codecs, and lazy-loaded WASM codecs for
MP3/FLAC/Ogg/Opus/AAC/WebM/QOA. Universal — no browser-only or
Node-only APIs at module scope; `node:fs/promises` is reached only via
runtime-gated dynamic `import()`.
<!-- begin:auto-docs -->
## Documentation tree
- [aac](./src/codecs/aac.md) — Lazy AAC codec — decode-only wrapper over `@audio/decode-aac` WASM module loaded on first use.
- [aiff](./src/codecs/aiff.md) — Native pure-JS AIFF (Audio Interchange File Format) decoder, decode-only.
- [caf](./src/codecs/caf.md) — Native pure-JS CAF (Apple Core Audio Format) decoder, decode-only.
- [flac](./src/codecs/flac.md) — Lazy FLAC codec — thin wrapper over `@audio/decode-flac` WASM module loaded on first use.
- [mp3](./src/codecs/mp3.md) — Lazy MP3 codec — thin wrapper that defers decode/encode to the `@audio/decode-mp3` WASM module loaded via dynamic `impo…
- [ogg-vorbis](./src/codecs/ogg-vorbis.md) — Lazy Ogg Vorbis codec — decode-only wrapper over `@audio/decode-ogg-vorbis` WASM module loaded on first use.
- [opus](./src/codecs/opus.md) — Lazy Opus codec — wrapper over `@audio/decode-opus` WASM module loaded on first use.
- [qoa](./src/codecs/qoa.md) — Lazy QOA (Quite OK Audio) codec — wrapper over `@audio/decode-qoa` WASM module loaded on first use.
- [wasm-base](./src/codecs/wasm-base.md) — Shared factory for the seven lazy WASM-backed codec wrappers.
- [wav](./src/codecs/wav.md) — Native pure-JS WAV codec implementing the full `AudioCodec` contract — decode, encode, and both streaming factories — w…
- [webm](./src/codecs/webm.md) — Lazy WebM-audio codec — decode-only wrapper over `@audio/decode-webm` WASM module loaded on first use.
- [decode](./src/decode/decode-source.md) — Universal decode entry point — the single function callers use to turn any `Source` into a decoded `AudioDocument`.
- [decode-source](./src/decode/decode-source.md) — Universal decode entry point — the single function callers use to turn any `Source` into a decoded `AudioDocument`.
- [detect](./src/detect/detect-format.md) — Format detection and source-resolution helpers — the runtime-gated entry point that turns any `Source` (string path, UR…
- [detect-format](./src/detect/detect-format.md) — Format detection and source-resolution helpers — the runtime-gated entry point that turns any `Source` (string path, UR…
- [encode](./src/encode/encode-to.md) — Universal encode entry point — encode an `AudioDocument` to bytes and write them to any `Destination` (file path, Writa…
- [encode-to](./src/encode/encode-to.md) — Universal encode entry point — encode an `AudioDocument` to bytes and write them to any `Destination` (file path, Writa…
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry for `@suaveplan/audio-codecs`. Every fault raised by the package is an `AudioCodecsError` ca…
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry for `@suaveplan/audio-codecs`. Every fault raised by the package is an `AudioCodecsError` ca…
- [registry](./src/registry/registry.md) — In-memory plugin registry that owns audio-codec lookup and dispatch for the whole package.
- [registry](./src/registry/registry.md) — In-memory plugin registry that owns audio-codec lookup and dispatch for the whole package.
- [stream-decoder](./src/stream/stream-decoder.md) — `TransformStream<Uint8Array, AudioPage>` factory — composes a chunked byte stream into an `AudioPage` stream by driving…
- [stream-encoder](./src/stream/stream-encoder.md) — `TransformStream<readonly Float32Array[], Uint8Array>` factory — composes a PCM-block stream into encoded bytes by driv…
<!-- end:auto-docs -->
## Install
```sh
bun add @suaveplan/audio-codecs @suaveplan/audio-core
```
WASM codecs are `optionalDependencies` — install only the ones you
need:
```sh
bun add @audio/decode-mp3 @audio/decode-flac @audio/decode-opus
```
## Quick start
```ts
import { decodeSource, encodeTo } from "@suaveplan/audio-codecs";
// Decode any source
const doc = await decodeSource(wavBytes);
// Encode any document to any registered format
await encodeTo(doc, "/tmp/out.wav", "wav", { bitDepth: 16, format: "pcm" });
```
## Modules
| Subpath | Purpose |
| ------------------------------------------------- | ---------------------------------------------------------------------------------- |
| `./registry/registry` | `CodecRegistry` — plugin lookup + dispatch + OTel instrumentation |
| `./decode/decode-source` | `decodeSource`, `progressiveDecode` — universal decode entry |
| `./encode/encode-to` | `encodeTo` — universal encode entry, writes to path/stream/callback |
| `./detect/detect-format` | `detectSource`, `resolveSource` — runtime-gated source resolution |
| `./stream/stream-decoder` | `createDecodeTransform` — `TransformStream<Uint8Array, AudioPage>` |
| `./stream/stream-encoder` | `createEncodeTransform` — `TransformStream<Float32Array[], Uint8Array>` |
| `./codecs/wav` | Native WAV codec — decode + encode + streaming |
| `./codecs/aiff` | Native AIFF/AIFC decoder (decode-only) |
| `./codecs/caf` | Native CAF (Core Audio Format) decoder (decode-only) |
| `./codecs/mp3`, `flac`, `ogg-vorbis`, `opus`, `aac`, `webm`, `qoa` | Lazy WASM codec wrappers — `optionalDependency` loaded on first use |
| `./codecs/wasm-base` | `createWasmCodec` factory shared by every WASM wrapper |
| `./error-codes` | `AudioCodecsError`, `AUDIO_CODECS_CODES` registry (7 stable codes) |
## Codecs at a glance
| Codec | Native | Decode | Encode | Streaming | Magic |
| ------------ | :----: | :----: | :----: | :-------: | ---------------------- |
| WAV | ✅ | ✅ | ✅ | ✅ | `RIFF` … `WAVE` |
| AIFF/AIFC | ✅ | ✅ | ❌ | ❌ | `FORM` … `AIFF`/`AIFC` |
| CAF | ✅ | ✅ | ❌ | ❌ | `caff` |
| MP3 | WASM | ✅ | ✅ | ❌ | ID3v2 / MPEG sync |
| FLAC | WASM | ✅ | ✅ | ❌ | `fLaC` |
| Ogg Vorbis | WASM | ✅ | ❌ | ❌ | `OggS` |
| Opus | WASM | ✅ | ✅ | ❌ | `OggS` |
| AAC | WASM | ✅ | ❌ | ❌ | ADTS sync |
| WebM (audio) | WASM | ✅ | ❌ | ❌ | EBML header |
| QOA | WASM | ✅ | ✅ | ❌ | `qoaf` |
## Source variants accepted
`decodeSource`, `progressiveDecode`, and `detectSource` all accept any
of:
- `string` — bare path (server: `fs.readFile`; browser: `fetch`),
`file://` URI (server only), or `http(s):` / `data:` / `blob:`
string (any runtime: `fetch`).
- `URL` instance — resolved via `fetch`.
- `ArrayBuffer` / `Uint8Array` — consumed directly.
- `ReadableStream<Uint8Array>` — drained on demand.
## Destination variants accepted
`encodeTo` accepts any of:
- `string` file path — written via runtime-gated
`node:fs/promises.writeFile`.
- `WritableStream<Uint8Array>` — one chunk write then close.
- `(chunk: Uint8Array) => void` callback — single invocation with the
full encoded payload.
## Error model
Every fault is an `AudioCodecsError` carrying one of seven stable codes:
- `AUDIO_CODECS.UNKNOWN_FORMAT` — detection found no codec.
- `AUDIO_CODECS.DECODE_FAILED` — codec rejected input.
- `AUDIO_CODECS.ENCODE_FAILED` — codec rejected document.
- `AUDIO_CODECS.STREAM_ERROR` — abort or stream-only failure.
- `AUDIO_CODECS.NOT_SUPPORTED` — operation unavailable for codec
(e.g. encode for AIFF) or `optionalDependency` not installed.
- `AUDIO_CODECS.INVALID_SOURCE` — unrecognised source / destination or
Zod boundary validation failure.
- `AUDIO_CODECS.BUDGET_EXCEEDED` — projected memory > caller's budget.
Original errors are preserved on `error.cause`; additional triage data
lives on `error.context`.
## Verification
```sh
bunx turbo lint typecheck test --filter=@suaveplan/audio-codecs
bunx turbo test:coverage --filter=@suaveplan/audio-codecs
```
## Spec & change history
- Design change: `enterprise-audio-codecs/`
- Canonical spec (after archive): `audio-codecs/spec.md`

View File

@@ -0,0 +1,72 @@
# `codecs/aac`
Lazy AAC codec — decode-only wrapper over `@audio/decode-aac` WASM
module loaded on first use.
## Purpose
AAC (Advanced Audio Coding) is the modern lossy audio successor to MP3,
used by every major streaming service and as the default audio format
in MP4/M4A containers. The codec is decode-only because (a) the AAC
encoder is complex and patent-encumbered in many jurisdictions, and
(b) the canonical lossy encode target is Opus.
Implementation delegates to [`createWasmCodec`](./wasm-base.md) for the
lazy-load, error-wrapping, and missing-module behaviour shared by every
WASM wrapper.
## Features
- `name === "aac"`, extensions `["aac", "m4a"]`, MIME types
`["audio/aac", "audio/mp4"]`.
- `canDecode(bytes)` — magic-byte sniff for the ADTS sync word
(first byte `0xff`, second byte top 4 bits `0b1111`); rejects
buffers shorter than 2 bytes.
- Decode supported via `@audio/decode-aac`.
- `supportsEncode: false` — encode throws `NOT_SUPPORTED`
short-circuit.
- Streaming factory absent.
## Usage: Basic Example
```ts
import { AacCodec } from "@suaveplan/audio-codecs/codecs/aac";
const decoded = await AacCodec.decode(aacBytes);
```
## Usage: Advanced Example
```ts
import { CodecRegistry, AacCodec, OpusCodec } from "@suaveplan/audio-codecs";
const registry = new CodecRegistry({ codecs: [AacCodec, OpusCodec] });
// transcode AAC → Opus for downstream storage
const aacDoc = await registry.decode(aacBytes);
const opusBytes = await registry.encode(aacDoc, "opus");
```
## API Reference
- `AacCodec: AudioCodec` — frozen constant; decode-only.
- `canDecode(bytes)`, `decode(bytes, opts?)` — standard `AudioCodec`
contract.
- `encode()` — always throws `AUDIO_CODECS.NOT_SUPPORTED`.
## Implementation Notes
- The ADTS sync word check (`0xff 0xf0`) covers raw ADTS-framed AAC.
AAC inside an MP4/M4A container starts with `ftyp` instead; that
case is currently NOT handled by `canDecode` — callers passing
M4A files need to either use a separate MP4 demuxer first or call
`decode` with `format: "aac"` to bypass detection. A future
extension may add an MP4-container sniffer.
- Decode-only by design. suaveplan's preferred lossy encode target is
Opus; AAC encoding requires non-trivial licensing in some
jurisdictions and is intentionally out of scope.
- First decode lazily loads `@audio/decode-aac`; subsequent calls
reuse the memoised module.
- Missing `@audio/decode-aac` → `AUDIO_CODECS.NOT_SUPPORTED`.
- Streaming factory absent — `CodecRegistry.decodeStream("aac", …)`
throws `AUDIO_CODECS.STREAM_ERROR`.

View File

@@ -0,0 +1,117 @@
# `codecs/aiff`
Native pure-JS AIFF (Audio Interchange File Format) decoder, decode-only.
## Purpose
AIFF is the big-endian counterpart to WAV used historically on Apple
hardware. The codec supports both the original AIFF container and the
later AIFC variant (which uses the same chunk structure but allows
non-PCM payloads — though this codec only decodes uncompressed integer
PCM).
Encoding is intentionally not supported: AIFF write is rarely useful
outside Apple's own tooling and the canonical archive format
is WAV/FLAC. Callers attempting to encode AIFF receive a clean
`AUDIO_CODECS.NOT_SUPPORTED` error rather than a silently-degraded
fallback.
## Features
- `canDecode(bytes)` — magic-byte sniff for `FORM` at byte 0 plus
`AIFF` or `AIFC` at byte 8; rejects buffers shorter than 12 bytes
or with non-matching magic.
- `decode(bytes)` — walks the AIFF chunk list for `COMM` and `SSND`,
decodes the 80-bit IEEE 754 extended-precision sample rate, and
returns a `DecodeResult` with normalised `Float32Array[]` PCM.
- 8-bit signed, 16-bit signed, 24-bit signed, and 32-bit signed PCM
in big-endian byte order are all decoded; the 80-bit extended-float
sample rate field is parsed in full so the codec round-trips any
sample rate AIFF can express, including the unusual ones used by old
samplers (37_800, 32_000, 24_000, etc).
- `encode` — throws `AudioCodecsError("AUDIO_CODECS.NOT_SUPPORTED")`.
## Usage: Basic Example
```ts
import { AiffCodec } from "@suaveplan/audio-codecs/codecs/aiff";
const decoded = await AiffCodec.decode(aiffBytes);
console.log(decoded.sampleRate, decoded.channelData.length);
```
## Usage: Advanced Example
Detecting AIFF vs AIFC and falling through to a separate processor:
```ts
import { AiffCodec } from "@suaveplan/audio-codecs/codecs/aiff";
async function processAudio(bytes: Uint8Array): Promise<AudioDocument> {
if (AiffCodec.canDecode(bytes)) {
const result = await AiffCodec.decode(bytes);
return buildDocumentFromResult(result);
}
return processWithFallbackCodec(bytes);
}
// Direct registry registration:
import { CodecRegistry } from "@suaveplan/audio-codecs";
const registry = new CodecRegistry({ codecs: [AiffCodec] });
const doc = await registry.decode(aiffBytes);
```
## API Reference
- `AiffCodec: AudioCodec` — frozen constant; `name === "aiff"`,
`extensions === ["aiff", "aif", "aifc"]`, `mimeTypes === ["audio/aiff",
"audio/x-aiff"]`.
- `canDecode(bytes: Uint8Array): boolean`
- `decode(bytes: Uint8Array): Promise<DecodeResult>` — throws
`AudioCodecsError("AUDIO_CODECS.DECODE_FAILED")` for:
- buffer length < 12 (`too-short`),
- non-FORM magic (`bad-form`),
- non-AIFF/AIFC type (`bad-type`),
- COMM chunk size < 18 (`bad-comm-size`),
- missing COMM chunk (`no-common`),
- missing SSND chunk (`no-ssnd`),
- `sampleSize` = 0 (`unsupported-bits`),
- `sampleSize` > 32 (`unsupported-bytes-per-sample`).
- `encode(): Promise<Uint8Array>` — always throws
`AudioCodecsError("AUDIO_CODECS.NOT_SUPPORTED")`.
- The optional streaming-factory methods are absent; `CodecRegistry`
honours that and throws `STREAM_ERROR` on attempted streaming
operations against this codec.
## Implementation Notes
- The 80-bit extended-precision IEEE 754 sample-rate field is decoded
by `parseIeee754Extended(bytes, off)` which reads 10 bytes (1 sign
bit + 15 exponent bits biased by 16383 + 64-bit explicit mantissa —
the AIFF format predates double-precision IEEE 754 and uses an
explicit-mantissa variant rather than the more common implicit-1
binary64). The function returns `0` when `off + 10 > bytes.byteLength`
so a truncated buffer produces a 0-Hz result rather than a thrown
error — the parent `decode` then fails with a meaningful chunk-level
error.
- AIFF chunk sizes are big-endian uint32; the chunk walker advances
`pos = body + size + (size & 1)` to honour the AIFF requirement that
chunks pad to even boundaries.
- The normalisation factor is `(1 << (sampleSize - 1)) >>> 0` — the
unsigned-shift handles the 32-bit case where `1 << 31` would
otherwise produce a negative number under signed arithmetic.
- The 24-bit decoder reads bytes in MSB-first order (AIFF is
big-endian) using one signed byte + two unsigned bytes to preserve
the sign bit without depending on the JavaScript runtime's
sign-extension semantics.
- `numSampleFrames` from COMM caps decoded output; the codec also
caps via `availableFrames = floor(ssndLength / frameBytes)` so a
truncated SSND chunk simply yields fewer frames rather than reading
past the buffer.
- No streaming factory exists because AIFF's `SSND` chunk is variably
positioned within the FORM block — implementing a single-pass
streaming decoder would require buffering the whole header section
anyway, which negates the streaming benefit. Callers needing
streaming PCM can pipe the AIFF through a full decode then push the
result via the registry's `encodeStream` into WAV (which is
streamable).

View File

@@ -0,0 +1,109 @@
# `codecs/caf`
Native pure-JS CAF (Apple Core Audio Format) decoder, decode-only.
## Purpose
CAF is the modern Apple-specific container that replaced AIFF for use
cases needing 64-bit chunk sizes (files larger than 4 GiB) and embedded
metadata. This codec decodes the uncompressed integer PCM (`lpcm`)
payload that the vast majority of real-world CAF files carry. Compressed
payloads (Apple Lossless, AAC inside CAF) are out of scope and require
the corresponding WASM codec.
Encoding is intentionally not supported. CAF write is essentially never
useful outside Apple's own audio toolchain, and the canonical archive
formats are WAV and FLAC. The codec throws
`AUDIO_CODECS.NOT_SUPPORTED` for any encode attempt.
## Features
- `canDecode(bytes)` — magic-byte sniff for `caff` (`0x63 0x61 0x66 0x66`)
at byte 0; rejects buffers shorter than 4 bytes or with non-matching
magic.
- `decode(bytes)` — walks the CAF chunk list (64-bit big-endian chunk
sizes — `caff` uses uint64 chunk sizes, distinct from RIFF/AIFF
uint32 sizes) for `desc` and `data`, decodes the uncompressed PCM,
and returns a `DecodeResult` with normalised `Float32Array[]` PCM
and the sample rate parsed from the IEEE 754 binary64 `desc.sampleRate`
field.
- Sample byte order is determined by the `formatFlags` bit
`kCAFLinearPCMFormatFlagIsLittleEndian` (`0x0002`); the decoder honours
it per sample so files written by either little-endian or big-endian
producers decode correctly.
- 16-bit, 24-bit, and 32-bit signed integer PCM are supported. Other
bit depths (8, 64, 32 float) are rejected with
`AUDIO_CODECS.DECODE_FAILED("unsupported-bytes-per-sample")`.
- `encode` — throws `AudioCodecsError("AUDIO_CODECS.NOT_SUPPORTED")`.
## Usage: Basic Example
```ts
import { CafCodec } from "@suaveplan/audio-codecs/codecs/caf";
const decoded = await CafCodec.decode(cafBytes);
console.log(decoded.sampleRate, decoded.format);
```
## Usage: Advanced Example
Detect-then-dispatch via the shared registry:
```ts
import { CodecRegistry, CafCodec, WavCodec } from "@suaveplan/audio-codecs";
const registry = new CodecRegistry({ codecs: [WavCodec, CafCodec] });
const fmt = registry.detect(bytes);
if (fmt === "caf") {
// re-encode to WAV for downstream consumers
const doc = await registry.decode(bytes);
const wav = await registry.encode(doc, "wav", { bitDepth: 16, format: "pcm" });
return wav;
}
```
## API Reference
- `CafCodec: AudioCodec` — frozen constant; `name === "caf"`,
`extensions === ["caf"]`, `mimeTypes === ["audio/x-caf"]`.
- `canDecode(bytes: Uint8Array): boolean`
- `decode(bytes: Uint8Array): Promise<DecodeResult>` — throws
`AudioCodecsError("AUDIO_CODECS.DECODE_FAILED")` for:
- buffer length < 8 (`too-short`),
- non-`caff` magic (`bad-magic`),
- `desc` chunk size < 32 (`bad-desc-size`),
- missing `desc` chunk (`no-desc`),
- missing `data` chunk (`no-data`),
- `bitsPerChannel` = 0 (`unsupported-bits`),
- `bitsPerChannel` > 32 (`unsupported-bytes-per-sample`).
- `encode(): Promise<Uint8Array>` — always throws
`AudioCodecsError("AUDIO_CODECS.NOT_SUPPORTED")`.
- The optional streaming-factory methods are absent; the registry
honours that and throws `STREAM_ERROR` on streaming requests.
## Implementation Notes
- CAF chunk sizes are uint64 big-endian. The codec reads them via two
separate uint32 calls (`high * 2^32 + low`) because JavaScript's
`DataView.getBigUint64` returns a `BigInt` which would force the
caller to convert downstream and complicate arithmetic. The
`high * 2^32` form preserves precision for sizes up to
`Number.MAX_SAFE_INTEGER` (~9 PiB) which is far beyond any real CAF
payload.
- The `data` chunk has a 4-byte `editCount` field at the start of its
body; the codec skips it (offset by 4) before treating the remaining
bytes as PCM.
- The sample rate is decoded via `DataView.getFloat64(..., false)`
(big-endian IEEE 754 binary64) — this is the modern CAF format and
contrasts with AIFF's 80-bit extended-precision representation.
- The format ID at offset 8 of `desc` is `lpcm` (`0x6c70636d`) for
uncompressed integer PCM. The decoder does not currently dispatch on
this field — it assumes `lpcm` and decodes accordingly. Other format
IDs (`alac`, `aac `, `lpcm` floats) would produce wrong output;
callers needing compressed CAF support should use the corresponding
WASM codec for the inner format.
- Sample byte order is determined per sample by the
`formatFlags & 0x02` bit; the decoder applies it uniformly to 16/24/32
bit reads. 24-bit reads use a signed high byte + two unsigned bytes,
reordered based on endianness, to preserve sign across either layout.

View File

@@ -0,0 +1,80 @@
# `codecs/flac`
Lazy FLAC codec — thin wrapper over `@audio/decode-flac` WASM module
loaded on first use.
## Purpose
FLAC (Free Lossless Audio Codec) is the canonical lossless format for
high-quality audio archival. It compresses raw PCM by 30–60% with no
quality loss, making it the standard format for music libraries that
need to preserve every bit of the original signal.
The package treats FLAC as a WASM-backed codec to keep the universal
`audio-codecs` package size-constant — pure-JS FLAC implementations
are large and slow, and bundling one would punish every consumer who
never touches FLAC. The actual decode/encode work delegates to
`@audio/decode-flac` loaded via dynamic `import()` on first call.
Implementation delegates to [`createWasmCodec`](./wasm-base.md) which
documents the shared lazy-load, missing-module, and error-wrapping
contract.
## Features
- `name === "flac"`, extensions `["flac"]`, MIME types
`["audio/flac", "audio/x-flac"]`.
- `canDecode(bytes)` — magic-byte sniff for `fLaC`
(`0x66 0x4c 0x61 0x43`) at byte 0; rejects buffers shorter than 4
bytes.
- Decode + encode both supported (`supportsEncode: true`).
- Streaming factory absent — single-pass full-buffer codec.
## Usage: Basic Example
```ts
import { FlacCodec } from "@suaveplan/audio-codecs/codecs/flac";
const decoded = await FlacCodec.decode(flacBytes);
const reEncoded = await FlacCodec.encode(decoded.channelData, {
sampleRate: decoded.sampleRate,
bitDepth: 24,
});
```
## Usage: Advanced Example
Round-trip FLAC → WAV → FLAC via the shared registry:
```ts
import { CodecRegistry, FlacCodec, WavCodec } from "@suaveplan/audio-codecs";
const registry = new CodecRegistry({ codecs: [FlacCodec, WavCodec] });
const flacDoc = await registry.decode(flacBytes);
const intermediate = await registry.encode(flacDoc, "wav", { bitDepth: 24, format: "pcm" });
const wavDoc = await registry.decode(intermediate);
const finalFlac = await registry.encode(wavDoc, "flac");
```
## API Reference
- `FlacCodec: AudioCodec` — `name === "flac"`, frozen.
- `canDecode(bytes)`, `decode(bytes, opts?)`, `encode(channelData,
opts?)` — see [`wasm-base.md`](./wasm-base.md) for the shared
contract.
## Implementation Notes
- Magic-byte sniff is cheap — no WASM load required for `canDecode`.
- The first decode/encode call lazily loads `@audio/decode-flac`;
subsequent calls reuse the same memoised module instance.
- Missing `@audio/decode-flac` → `AUDIO_CODECS.NOT_SUPPORTED` with the
module name in `context.module`.
- FLAC's native streaming-decode form (`Subset` profile blocks) is
not exposed by this wrapper — adding it requires a per-block
callback API which is a future extension.
- FLAC files commonly carry Vorbis comments and embedded artwork in
the same container; the codec emits PCM only — metadata extraction
is `@suaveplan/audio-metadata`'s responsibility via its own FLAC
parser, separate from this codec.

View File

@@ -0,0 +1,100 @@
# `codecs/mp3`
Lazy MP3 codec — thin wrapper that defers decode/encode to the
`@audio/decode-mp3` WASM module loaded via dynamic `import()` on first
use.
## Purpose
MP3 is the canonical lossy audio format. It is treated as a WASM-backed
codec because pure-JS MP3 decoders are slow and large enough that bundling
one with the universal `audio-codecs` package would punish every
consumer who never decodes MP3. Instead the codec ships as a thin lazy
wrapper that costs essentially zero bytes to import and pays for itself
only when MP3 bytes actually arrive.
The implementation delegates to `createWasmCodec` from
[`wasm-base.md`](./wasm-base.md) — see that document for the lazy-load,
error-wrapping, and missing-module-handling contract that every WASM
wrapper inherits.
## Features
- `name === "mp3"`, extensions `["mp3", "mpeg"]`, MIME types
`["audio/mpeg", "audio/mp3"]`.
- `canDecode(bytes)` — magic-byte sniff: returns `true` for buffers
starting with the ID3v2 tag prefix (`"ID3"`) or the MPEG-1 audio sync
word (first byte `0xff`, second byte top 3 bits `0b111`). Returns
`false` for buffers shorter than 2 bytes.
- Decode supported via `@audio/decode-mp3` once loaded.
- Encode supported via the same module (`supportsEncode: true`).
- Streaming factory absent — the underlying module exposes only
full-buffer decode/encode; streaming MP3 needs a per-frame parser
that lives in `@audio/stream-mp3` if/when that ships.
## Usage: Basic Example
```ts
import { Mp3Codec } from "@suaveplan/audio-codecs/codecs/mp3";
// magic sniff works without loading the WASM module
if (Mp3Codec.canDecode(bytes)) {
const decoded = await Mp3Codec.decode(bytes); // first call loads WASM
consume(decoded.channelData);
}
```
## Usage: Advanced Example
Register MP3 alongside other codecs in a shared registry:
```ts
import { CodecRegistry, Mp3Codec, WavCodec, FlacCodec } from "@suaveplan/audio-codecs";
const registry = new CodecRegistry({
codecs: [WavCodec, Mp3Codec, FlacCodec],
});
// magic-byte detection picks the right codec
const fmt = registry.detect(bytes);
if (fmt) {
const doc = await registry.decode(bytes, { format: fmt });
return doc;
}
```
## API Reference
- `Mp3Codec: AudioCodec` — frozen constant produced by
`createWasmCodec({ name: "mp3", extensions, mimeTypes, module:
"@audio/decode-mp3", magic: mp3Magic, supportsEncode: true, load })`.
- `canDecode(bytes: Uint8Array): boolean` — magic-byte sniff
(lightweight, synchronous, no WASM load).
- `decode(bytes: Uint8Array, opts?: DecodeOpts): Promise<DecodeResult>` —
lazily loads `@audio/decode-mp3` on first call. Throws
`AudioCodecsError("AUDIO_CODECS.NOT_SUPPORTED")` if the optional
dependency is not installed, or `DECODE_FAILED` on a codec error.
- `encode(channelData, opts?: EncodeOpts): Promise<Uint8Array>` — same
loading semantics; throws `NOT_SUPPORTED` if the module is missing or
exposes no encode method, `ENCODE_FAILED` on codec error.
## Implementation Notes
- The magic-byte sniff is intentionally cheap so the registry can probe
every codec without paying any WASM-load cost. False positives on
the MPEG sync word are possible because the sync pattern is short
(12 bits); callers that need stricter detection should layer an MP3
frame-header validator on top.
- The ID3v2 prefix means real-world MP3 files carrying ID3 metadata
still sniff as MP3; the metadata extraction is `@suaveplan/audio-metadata`'s
responsibility, not this codec's.
- The codec follows the spec's "WASM-backed codecs loaded on demand"
requirement — no static import of `@audio/decode-mp3`. Consumers
that need full bundling can install the dep explicitly; consumers
that only handle WAV/AIFF pay zero bytes for the MP3 path.
- The first decode/encode call may take 50–200 ms while the WASM module
initialises (instantiate + memory allocation). Subsequent calls reuse
the same instance and run at native-adjacent speed.
- The streaming-decoder / streaming-encoder factories are intentionally
absent on this codec; `CodecRegistry.decodeStream("mp3", …)` therefore
throws `AUDIO_CODECS.STREAM_ERROR`.

View File

@@ -0,0 +1,82 @@
# `codecs/ogg-vorbis`
Lazy Ogg Vorbis codec — decode-only wrapper over
`@audio/decode-ogg-vorbis` WASM module loaded on first use.
## Purpose
Ogg Vorbis is the canonical open-source lossy audio format that pre-dates
Opus, still widely used in game audio and free-software distributions.
The codec is decode-only because (a) the Vorbis encoder is a complex
piece of perceptual-audio engineering best left to the canonical
`libvorbis` implementation packaged separately, and (b) Opus is the
preferred lossy encode target for new content.
Implementation delegates to [`createWasmCodec`](./wasm-base.md) — see
that document for the lazy-load, error-wrapping, and missing-module
behaviour shared by every WASM wrapper.
## Features
- `name === "ogg-vorbis"`, extensions `["ogg", "oga"]`, MIME types
`["audio/ogg", "audio/vorbis"]`.
- `canDecode(bytes)` — magic-byte sniff for `OggS`
(`0x4f 0x67 0x67 0x53`) at byte 0; rejects buffers shorter than 4
bytes.
- Decode supported via `@audio/decode-ogg-vorbis`.
- `supportsEncode: false` — encode throws
`AUDIO_CODECS.NOT_SUPPORTED` short-circuit (no WASM load).
- Streaming factory absent.
## Usage: Basic Example
```ts
import { OggVorbisCodec } from "@suaveplan/audio-codecs/codecs/ogg-vorbis";
const decoded = await OggVorbisCodec.decode(oggBytes);
console.log(decoded.sampleRate, decoded.channelData.length);
```
## Usage: Advanced Example
Detect-then-dispatch from a mixed source via the registry:
```ts
import { CodecRegistry, OggVorbisCodec, OpusCodec, WavCodec } from "@suaveplan/audio-codecs";
const registry = new CodecRegistry({
codecs: [WavCodec, OggVorbisCodec, OpusCodec],
});
const fmt = registry.detect(bytes);
// Ogg containers may carry Vorbis or Opus; the first matching codec by
// magic wins. If both Ogg-based codecs are registered, the codec
// registered first takes precedence — register OpusCodec first if you
// want Opus to win the tie.
if (fmt === "ogg-vorbis") {
return registry.decode(bytes);
}
```
## API Reference
- `OggVorbisCodec: AudioCodec` — frozen constant; decode-only.
- `canDecode(bytes)`, `decode(bytes, opts?)` — standard
`AudioCodec` contract; encode throws `NOT_SUPPORTED`.
## Implementation Notes
- The `OggS` magic is shared with Opus, FLAC-in-Ogg, and Ogg-Speex —
callers need to layer downstream sniffing if they need to
discriminate. The default registry order in `decode-source.ts`
registers Vorbis before Opus, which is the convention
for ambiguous Ogg payloads.
- Encode is intentionally not supported. Preferred lossy
encode target is Opus; callers needing Vorbis output should run
`libvorbis` in a separate worker.
- The first decode call lazily loads `@audio/decode-ogg-vorbis`;
subsequent calls reuse the memoised module.
- Missing `@audio/decode-ogg-vorbis` → `AUDIO_CODECS.NOT_SUPPORTED`
with the module name in `context.module`.
- Streaming factory absent — `CodecRegistry.decodeStream("ogg-vorbis",
…)` throws `AUDIO_CODECS.STREAM_ERROR`.

View File

@@ -0,0 +1,73 @@
# `codecs/opus`
Lazy Opus codec — wrapper over `@audio/decode-opus` WASM module loaded on
first use.
## Purpose
Opus is the modern open-source lossy audio codec that supersedes both
Vorbis (for general-purpose use) and Speex (for voice). It encodes
voice and music at low bitrates with quality that frequently beats
MP3 and AAC, and is the default target for WebRTC voice traffic.
The package treats Opus as the canonical lossy encode target.
The codec is implemented as a thin wrapper over `@audio/decode-opus`
loaded via dynamic `import()`. Implementation delegates to
[`createWasmCodec`](./wasm-base.md) for the lazy-load, error-wrapping,
and missing-module behaviour shared by every WASM wrapper.
## Features
- `name === "opus"`, extensions `["opus"]`, MIME types `["audio/opus"]`.
- `canDecode(bytes)` — magic-byte sniff for `OggS` at byte 0 (Opus
payloads are typically Ogg-encapsulated); rejects buffers shorter
than 4 bytes.
- Decode + encode both supported.
- Streaming factory absent — single-pass full-buffer codec.
## Usage: Basic Example
```ts
import { OpusCodec } from "@suaveplan/audio-codecs/codecs/opus";
const decoded = await OpusCodec.decode(opusBytes);
const reEncoded = await OpusCodec.encode(decoded.channelData);
```
## Usage: Advanced Example
Pipeline: decode WAV → re-encode as Opus for transport:
```ts
import { CodecRegistry, OpusCodec, WavCodec } from "@suaveplan/audio-codecs";
const registry = new CodecRegistry({ codecs: [WavCodec, OpusCodec] });
const wavDoc = await registry.decode(wavBytes);
const opusBytes = await registry.encode(wavDoc, "opus");
// Send opusBytes over a low-bandwidth transport.
```
## API Reference
- `OpusCodec: AudioCodec` — frozen constant.
- `canDecode(bytes)`, `decode(bytes, opts?)`, `encode(channelData,
opts?)` — see [`wasm-base.md`](./wasm-base.md).
## Implementation Notes
- The `OggS` magic shared with Vorbis and FLAC-in-Ogg means
detect-then-dispatch may pick the wrong codec when multiple
Ogg-family codecs are registered. Register Opus before Vorbis if
you expect Opus content to dominate; otherwise pass `format: "opus"`
as a hint to `decode` to bypass detection.
- The first decode/encode call lazily loads `@audio/decode-opus`;
subsequent calls reuse the memoised module.
- Missing `@audio/decode-opus` → `AUDIO_CODECS.NOT_SUPPORTED`.
- Streaming Opus (chunked decode at 20 ms frame boundaries) is not
exposed by this wrapper — adding it requires a per-frame parser
and the `@audio/stream-opus` companion module which is a future
extension.
- The codec accepts the standard `EncodeOpts` shape but Opus' real
encoder tuning (bitrate, complexity, signal type) is module-specific
and lives in the `@audio/decode-opus` options surface.

View File

@@ -0,0 +1,74 @@
# `codecs/qoa`
Lazy QOA (Quite OK Audio) codec — wrapper over `@audio/decode-qoa` WASM
module loaded on first use.
## Purpose
QOA is a recently-published lossy-but-tiny audio codec optimised for
embedded/game-audio scenarios where MP3/Opus add too much complexity.
It encodes 16-bit PCM to ~3.2 bits per sample using a fixed-size frame
layout (~6× compression) with no perceptual model — making it cheap
to decode and stream. It is included for the game-audio /
embedded use case.
Implementation delegates to [`createWasmCodec`](./wasm-base.md) for the
shared lazy-load, error-wrapping, and missing-module behaviour.
## Features
- `name === "qoa"`, extensions `["qoa"]`, MIME types `["audio/qoa"]`.
- `canDecode(bytes)` — magic-byte sniff for `qoaf`
(`0x71 0x6f 0x61 0x66`) at byte 0; rejects buffers shorter than 4
bytes.
- Decode + encode both supported (`supportsEncode: true`).
- Streaming factory absent — single-pass full-buffer codec for now;
QOA's fixed-frame structure means a streaming variant is feasible
as a future extension.
## Usage: Basic Example
```ts
import { QoaCodec } from "@suaveplan/audio-codecs/codecs/qoa";
const decoded = await QoaCodec.decode(qoaBytes);
const reEncoded = await QoaCodec.encode(decoded.channelData, {
sampleRate: decoded.sampleRate,
});
```
## Usage: Advanced Example
Use QOA as a compact game-audio sprite format:
```ts
import { CodecRegistry, QoaCodec, WavCodec } from "@suaveplan/audio-codecs";
const registry = new CodecRegistry({ codecs: [WavCodec, QoaCodec] });
// Compress a sound-effect bank from WAV to QOA for shipping.
const wavDoc = await registry.decode(wavBytes);
const qoaBytes = await registry.encode(wavDoc, "qoa");
// Approximately 6× smaller than the input WAV.
```
## API Reference
- `QoaCodec: AudioCodec` — frozen constant.
- `canDecode(bytes)`, `decode(bytes, opts?)`, `encode(channelData,
opts?)` — see [`wasm-base.md`](./wasm-base.md).
## Implementation Notes
- QOA's `qoaf` magic is unambiguous — no other audio format starts
with these bytes, so detect-then-dispatch is reliable.
- The first decode/encode call lazily loads `@audio/decode-qoa`;
subsequent calls reuse the memoised module.
- Missing `@audio/decode-qoa` → `AUDIO_CODECS.NOT_SUPPORTED`.
- QOA's fixed-frame structure (256 samples per frame, 8-byte header
per channel slice) makes it amenable to a streaming-decoder
factory — adding one requires only a per-frame buffer drain and
is a future extension.
- Quality vs MP3/Opus: QOA is lower fidelity at equal bitrate, but
has zero entropy coding so decode is bounded constant-time per
sample. The trade-off favours embedded / low-resource scenarios.

View File

@@ -0,0 +1,136 @@
# `codecs/wasm-base`
Shared factory for the seven lazy WASM-backed codec wrappers.
## Purpose
The MP3, FLAC, Ogg Vorbis, Opus, AAC, WebM, and QOA codecs are all thin
adapters over external `@audio/*` WASM modules declared as
`optionalDependencies` in `package.json`. They share the same structural
contract — lazy-load the WASM module on first use, throw a stable
`AUDIO_CODECS.NOT_SUPPORTED` if the optional dependency is not
installed, and wrap codec errors into the package's `AudioCodecsError`
hierarchy.
`createWasmCodec(spec)` is the factory that captures that contract once
and produces the seven concrete `AudioCodec` instances. Without it,
every wrapper would re-implement memoised loading, error remapping, and
encode-not-supported branching — and any divergence between wrappers
would silently degrade some formats relative to others.
## Features
- `createWasmCodec(spec: WasmCodecSpec): AudioCodec` — returns an
`AudioCodec` whose `name`, `extensions`, `mimeTypes`, and `canDecode`
are taken directly from `spec`, and whose `decode` / `encode` invoke
the lazily-loaded WASM module's corresponding methods.
- Lazy module load via `spec.load()` — typically a closure capturing
`() => import("@audio/decode-<format>")`. The promise is memoised
after the first call, so repeated decode operations reuse the same
loaded module.
- Missing-module behaviour: if `spec.load()` rejects (the
`optionalDependency` is not installed), the codec throws
`AudioCodecsError("AUDIO_CODECS.NOT_SUPPORTED")` carrying the module
name in `context.module`.
- Encode gating: `spec.supportsEncode === false` short-circuits the
encode method with `NOT_SUPPORTED` before any module load — so a
decode-only codec costs nothing extra on the encode path.
- Decode-only modules: if the loaded module exposes no `decode` method,
the codec throws `NOT_SUPPORTED` with the module name; same shape
applies for missing `encode`.
- Non-API errors from the underlying module are wrapped — `decode`
failures become `AUDIO_CODECS.DECODE_FAILED`, `encode` failures
become `AUDIO_CODECS.ENCODE_FAILED`, both with `cause` set to the
original error and `context.module` + `context.format` for triage.
- `AudioCodecsError` thrown from inside the module passes through
unchanged — already-typed errors don't get re-wrapped.
## Usage: Basic Example
```ts
import { createWasmCodec } from "@suaveplan/audio-codecs/codecs/wasm-base";
export const FooCodec = createWasmCodec({
name: "foo",
extensions: ["foo"],
mimeTypes: ["audio/foo"],
module: "@audio/decode-foo",
magic: (bytes) => bytes.length >= 4 && bytes[0] === 0xfo && bytes[1] === 0x0d,
supportsEncode: false,
load: () => import("@audio/decode-foo"),
});
```
## Usage: Advanced Example
Constructing a codec for an internal WASM module with custom decode
adaptation (e.g. when the underlying module's response shape doesn't
match `DecodeResult` exactly):
```ts
import { createWasmCodec } from "@suaveplan/audio-codecs/codecs/wasm-base";
export const SnakeoilCodec = createWasmCodec({
name: "snakeoil",
extensions: ["snkl"],
mimeTypes: ["audio/x-snakeoil"],
module: "@internal/snakeoil-decoder",
magic: (bytes) =>
bytes.length >= 8 &&
bytes[0] === 0x53 &&
bytes[1] === 0x4e &&
bytes[2] === 0x4b &&
bytes[3] === 0x4c,
supportsEncode: true,
load: async () => {
const mod = await import("@internal/snakeoil-decoder");
// Module exposes `decodeBuffer` / `encodeBuffer` not `decode` / `encode`.
// Adapt at the boundary so wasm-base's contract stays satisfied.
return {
decode: async (bytes) => {
const raw = await mod.decodeBuffer(bytes);
return {
channelData: raw.channels,
sampleRate: raw.sampleRate,
format: "snakeoil",
};
},
encode: async (channelData, opts) =>
mod.encodeBuffer(channelData, opts?.bitDepth ?? 16),
};
},
});
```
## API Reference
- `interface WasmCodecModule` — the shape that loaded modules must
conform to: `decode?`, `encode?`, `canDecode?`. The factory tolerates
modules that expose only a subset (e.g. decode-only) — the
`supportsEncode` flag in `WasmCodecSpec` is the authoritative gate.
- `interface WasmCodecSpec` — `name`, `extensions`, `mimeTypes`,
`module` (the bare specifier string for diagnostics), `magic`
(`(bytes) => boolean`), `supportsEncode` (`boolean`), `load`
(`() => Promise<WasmCodecModule>`).
- `function createWasmCodec(spec: WasmCodecSpec): AudioCodec` —
closure-captures `spec` and returns a frozen `AudioCodec` with
lazy-loaded behaviour and the documented error-wrapping policy.
## Implementation Notes
- The `load` closure stores the promise (not the resolved module) so
parallel `decode` calls during initial load all share the same
promise — no double-load race.
- Errors from `spec.load()` are caught once and re-thrown as
`NOT_SUPPORTED`; subsequent calls reuse the cached rejected promise
so the same error surfaces on every retry without re-importing.
- The `decode` and `encode` methods always re-wrap non-API errors;
`AudioCodecsError` instances pass through. This means downstream
callers can rely on a consistent error type from every WASM codec.
- The factory does not eagerly probe `spec.module` for availability —
importing the package barrel pulls in only the factory, not any WASM
modules. WASM is loaded on first decode/encode call, not at module
import time, matching the spec's `Subpath exports per feature
module` and `WASM-backed codecs loaded on demand` requirements.
- Encode paths short-circuit when `supportsEncode === false` so a
decode-only codec never invokes its loader for an encode call.

View File

@@ -0,0 +1,150 @@
# `codecs/wav`
Native pure-JS WAV codec implementing the full `AudioCodec` contract —
decode, encode, and both streaming factories — without any external
dependency.
## Purpose
WAV is the round-trip canonical format for `audio-codecs`. Every other
codec is allowed to be decode-only or to delegate to a WASM module; the
WAV codec is the one path callers can rely on to be present, complete,
and dependency-free. It also serves as the worked example of the
`AudioCodec` plugin contract for downstream integrators.
The implementation is a minimal RIFF parser plus a sample converter that
handles every common WAV sample format: 8-bit unsigned PCM, 16-bit
signed PCM, 24-bit signed PCM, 32-bit signed PCM, and 32-bit IEEE 754
float. Decoding normalises every format into `Float32Array[]` in `[-1, 1]`;
encoding inverts the conversion respecting the requested bit depth and
storage format (PCM int vs IEEE float).
## Features
- `WavCodec.canDecode(bytes)` — magic-byte sniff: returns `true` iff the
buffer is at least 12 bytes and starts with `RIFF` … `WAVE`.
- `WavCodec.decode(bytes)` — parses the RIFF header, walks the chunk list
for `fmt ` and `data`, decodes the PCM, and returns a `DecodeResult`
with `channelData`, `sampleRate`, and `format: "wav"`.
- `WavCodec.encode(channelData, opts?)` — emits a complete RIFF WAV from
PCM channels at the requested `sampleRate`, `bitDepth`, and
`format: "pcm" | "float"`. Defaults: 44_100 Hz, 16-bit PCM.
- `WavCodec.createDecoder()` — streaming decoder that buffers incoming
chunks until the header is fully parsed, then emits one
`DecodeResult` per `write()` call that brings new full frames.
- `WavCodec.createEncoder(opts?)` — streaming encoder that records sample
rate + channel count at `start()`, accumulates PCM blocks via
`write()`, and writes the complete RIFF header followed by
concatenated samples at `flush()` once the total frame count is known.
## Usage: Basic Example
```ts
import { WavCodec } from "@suaveplan/audio-codecs/codecs/wav";
// decode bytes
const decoded = await WavCodec.decode(wavBytes);
console.log(decoded.sampleRate, decoded.channelData[0].length);
// re-encode at 24-bit PCM
const encoded = await WavCodec.encode(decoded.channelData, {
sampleRate: decoded.sampleRate,
bitDepth: 24,
format: "pcm",
});
```
## Usage: Advanced Example
Streaming decode of a chunked WAV byte source:
```ts
import { WavCodec } from "@suaveplan/audio-codecs/codecs/wav";
const decoder = WavCodec.createDecoder!();
const pcm: Float32Array[] = [];
for await (const chunk of fetchAudio()) {
for (const result of decoder.write(chunk)) {
for (let c = 0; c < result.channelData.length; c++) {
pcm[c] ??= new Float32Array(0);
const merged = new Float32Array(pcm[c]!.length + result.channelData[c]!.length);
merged.set(pcm[c]!, 0);
merged.set(result.channelData[c]!, pcm[c]!.length);
pcm[c] = merged;
}
}
}
for (const tail of decoder.flush()) {
// append remaining
}
decoder.close();
```
Streaming encode against a custom PCM block source:
```ts
const encoder = WavCodec.createEncoder!({
sampleRate: 48_000,
bitDepth: 32,
format: "float",
});
const header = encoder.start(48_000, 2);
yield header; // empty for WAV — header is written at flush
for (const block of pcmBlocks) {
yield encoder.write(block);
}
yield encoder.flush();
encoder.close();
```
## API Reference
- `WavCodec: AudioCodec` — frozen constant; `name === "wav"`, `extensions
=== ["wav", "wave"]`, `mimeTypes === ["audio/wav", "audio/wave",
"audio/x-wav"]`.
- `canDecode(bytes: Uint8Array): boolean`
- `decode(bytes: Uint8Array, opts?: DecodeOpts): Promise<DecodeResult>` —
throws `AudioCodecsError("AUDIO_CODECS.DECODE_FAILED")` on bad magic,
missing `fmt ` chunk, missing `data` chunk, truncated `data`, fmt size
< 16, or unsupported `bitsPerSample` (`bps === 0` or `bps > 4`).
- `encode(channelData, opts?: EncodeOpts): Promise<Uint8Array>` — throws
`AudioCodecsError("AUDIO_CODECS.ENCODE_FAILED")` if `channelData` is
empty. Defaults: `sampleRate: 44_100`, `bitDepth: 16`, integer PCM.
- `createDecoder(opts?: DecodeOpts): StreamingDecoder`
- `createEncoder(opts?: EncodeOpts): StreamingEncoder` — the encoder
buffers all body bytes until `flush()` because the WAV header carries
the total `data` chunk size as a uint32, which is only known at the
end of the stream.
## Implementation Notes
- The RIFF chunk walker tolerates and skips any chunk whose ID is neither
`fmt ` nor `data` — `LIST`, `bext`, `iXML`, and other metadata chunks
are passed over silently. This matches the behaviour every real WAV
consumer expects.
- 24-bit PCM uses three signed-magnitude bytes per sample in
little-endian order; the decoder reads them via two unsigned bytes
and one signed byte to preserve the sign bit cleanly without
bit-twiddling intermediates that could lose precision on edge
values.
- The normalisation factor is `(1 << (bitsPerSample - 1)) >>> 0` — the
unsigned-shift forces a `number` rather than a 32-bit signed integer
for the 32-bit PCM case where `1 << 31` would otherwise be negative.
- Encode clamps every sample to `[-1, 1]` via `Math.max(-1, Math.min(1,
sample))` and rounds via `Math.round` to the nearest integer for the
three integer bit depths. The 32-bit float path writes the sample
directly — clamping is the caller's responsibility for float storage.
- The streaming encoder's `start()` returns an empty `Uint8Array` because
the RIFF header includes a `data` chunk size field whose value is the
total written sample bytes; that value is only known at `flush()`
time. Accordingly the encoder buffers every `write()` output into a
chunk array and emits one concatenated payload (header + body) at
`flush()`. Memory cost is the full PCM size — callers needing constant
memory streaming should use a different format (e.g. raw float headers
or Opus).
- The streaming decoder's `lenient: true` flag on the shared
`parseHeader` helper accepts an under-sized buffer (where the declared
`data` chunk extends past the bytes received so far). The non-streaming
`decode` path passes the default `lenient: false` so it still validates
total payload size.

View File

@@ -0,0 +1,74 @@
# `codecs/webm`
Lazy WebM-audio codec — decode-only wrapper over `@audio/decode-webm`
WASM module loaded on first use.
## Purpose
WebM is an open container format used primarily for video on the web,
but with an audio-only profile that wraps Opus or Vorbis payloads in
the EBML matroska-derived structure. This codec extracts the audio
track from WebM containers and decodes it to PCM.
The codec is decode-only because WebM-audio is almost always a
*receive* format (downloaded from a server), not something callers
need to emit. The canonical audio encode targets are WAV,
FLAC, and Opus. Implementation delegates to
[`createWasmCodec`](./wasm-base.md) for the shared lazy-load and
error-wrapping behaviour.
## Features
- `name === "webm"`, extensions `["webm"]`, MIME types
`["audio/webm"]`.
- `canDecode(bytes)` — magic-byte sniff for the EBML header
`0x1a 0x45 0xdf 0xa3` at byte 0; rejects buffers shorter than 4
bytes.
- Decode supported via `@audio/decode-webm`.
- `supportsEncode: false` — encode throws `NOT_SUPPORTED`.
- Streaming factory absent.
## Usage: Basic Example
```ts
import { WebMCodec } from "@suaveplan/audio-codecs/codecs/webm";
const decoded = await WebMCodec.decode(webmBytes);
```
## Usage: Advanced Example
```ts
import { CodecRegistry, WebMCodec, WavCodec } from "@suaveplan/audio-codecs";
const registry = new CodecRegistry({ codecs: [WebMCodec, WavCodec] });
// Extract audio from a WebM upload and re-encode as WAV for analysis.
const webmDoc = await registry.decode(webmBytes);
const wavBytes = await registry.encode(webmDoc, "wav", { bitDepth: 24 });
```
## API Reference
- `WebMCodec: AudioCodec` — frozen constant; decode-only.
- `canDecode(bytes)`, `decode(bytes, opts?)` — standard `AudioCodec`
contract.
- `encode()` — always throws `AUDIO_CODECS.NOT_SUPPORTED`.
## Implementation Notes
- WebM uses EBML, not RIFF, so the magic-byte signature is
fundamentally different from other audio containers. The four-byte
EBML header is a hard signal — false positives are rare in
practice.
- The codec returns PCM regardless of whether the underlying audio
track is Opus or Vorbis — `@audio/decode-webm` handles the inner
codec dispatch.
- WebM containers commonly carry video alongside audio; this codec
decodes only the audio track. Video handling is out of scope and
belongs to a separate `@suaveplan/video-*` package family.
- Decode-only by design; first decode lazily loads
`@audio/decode-webm`. Subsequent calls reuse the memoised module.
- Missing `@audio/decode-webm` → `AUDIO_CODECS.NOT_SUPPORTED`.
- Streaming factory absent — `decodeStream("webm", …)` throws
`AUDIO_CODECS.STREAM_ERROR`.

View File

@@ -0,0 +1,123 @@
# `decode/decode-source`
Universal decode entry point — the single function callers use to turn
any `Source` into a decoded `AudioDocument`.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`decodeSource` is the top-level public-facing decode API. Callers do
not need to know about codec registries, format detection, or
runtime-gated source resolution — they pass in a `Source` (string,
URL, ArrayBuffer, Uint8Array, or ReadableStream) plus optional
hints/callbacks, and receive a fully-decoded `AudioDocument` from the
default registry (which ships every native + WASM codec).
The companion `progressiveDecode` provides the same surface but is the
hook for future per-chunk-fed decoders that allow editing the
document while decoding is still in progress. The current
implementation falls back to a full-buffer decode for every codec
because none of the native or WASM wrappers expose
`createDecoder` factories yet — the contract is in place so a future
streaming-aware codec can plug in without callers changing their
code.
## Features
- Accepts any `Source` variant — bytes, URL, file path, stream.
- Optional `opts.format` overrides detection (use when callers know
the format and want to skip the magic-byte sniff).
- Optional `opts.budget` projects required memory before allocating
pages — if projection exceeds the budget the call aborts with
`AUDIO_CODECS.BUDGET_EXCEEDED` carrying the projected and budgeted
byte counts in `context`.
- Optional `opts.signal` (AbortSignal) — checked before resolution,
before format detection, and before page allocation. Aborts throw
`AUDIO_CODECS.STREAM_ERROR`.
- Optional `opts.notify()` fires once the first page is materialised;
`opts.ondata(delta)` fires per decoded block delta. Useful for
progress UI on long-running decodes.
- Optional `opts.registry` lets callers inject a pre-configured
`CodecRegistry` (e.g. with extra codecs) instead of using the
default.
- Zod-validated options — invalid `budget` (non-positive number) or
any other schema violation throws `AUDIO_CODECS.INVALID_SOURCE` with
the Zod issue list in `context`.
## Usage: Basic Example
```ts
import { decodeSource } from "@suaveplan/audio-codecs/decode/decode-source";
const doc = await decodeSource(wavBytes);
console.log(doc.sourceLength, doc.sampleRate);
```
## Usage: Advanced Example
Decode a stream with a memory budget, abort signal, and progress
callbacks:
```ts
import { decodeSource } from "@suaveplan/audio-codecs/decode/decode-source";
const ctrl = new AbortController();
const ui = makeProgressUi();
const doc = await decodeSource(
fetch("https://cdn/audio.flac").then((r) => r.body!),
{
budget: 500 * 1024 * 1024, // 500 MB cap
signal: ctrl.signal,
notify: () => ui.startedDecoding(),
ondata: ({ samples }) => ui.updateProgress(samples),
}
);
```
## API Reference
- `interface DecodeSourceOpts { format?, budget?, signal?, notify?,
ondata?, registry? }`.
- `const DecodeSourceOptsSchema` — Zod schema for the options object.
- `async function decodeSource(source: Source, opts?:
DecodeSourceOpts): Promise<AudioDocument>` — throws
`INVALID_SOURCE` for invalid options or unrecognised source values;
`UNKNOWN_FORMAT` if detection finds no codec and `opts.format` is
also missing; `BUDGET_EXCEEDED` if projected memory exceeds
`opts.budget`; `STREAM_ERROR` for AbortSignal cancellation.
- `async function progressiveDecode(source, opts?, registry?):
Promise<AudioDocument>` — same surface as `decodeSource` plus an
optional positional `registry` for callers that prefer not to pass
it via `opts.registry`.
- `re-export CodecRegistry` — convenience so consumers needing custom
codec registration can import it from the same subpath.
## Implementation Notes
- The default registry is constructed lazily via a top-level promise
(`DEFAULT_REGISTRY_PROMISE`) so the side-effects of registering ten
codecs only happen the first time the registry is needed. Imports
of the package barrel itself don't trigger codec registration.
- `opts.signal` is checked at every coarse-grained boundary —
before resolution, after registry availability, before detection.
Codec-internal aborts are codec-specific and not covered by this
function.
- The budget projection uses `sampleRate * channels * sampleCount * 4`
(4 bytes per float32 sample) as an upper bound. For compressed
sources the actual decoded size depends on the codec's expansion
ratio; the projection assumes a conservative 5× expansion of input
bytes which catches the common cases (MP3, FLAC, Opus all
decompress within 5×) without false positives for raw WAV.
- `progressiveDecode` is currently equivalent to `decodeSource` —
the streaming-page-fill behaviour described in the spec
("editable before decode completes") requires codec `createDecoder`
factories which only WAV currently exposes. The function name and
signature reserve the contract so future streaming-aware codecs
drop in without API changes.
- Both functions are async — even when the input is fully in-memory,
format detection and codec dispatch involve at least one microtask
boundary because the codec methods are async.

View File

@@ -0,0 +1,131 @@
# `detect/detect-format`
Format detection and source-resolution helpers — the runtime-gated entry
point that turns any `Source` (string path, URL, ArrayBuffer, Uint8Array,
ReadableStream) into a header slice plus an optional detected format
name.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The package handles five concrete `Source` shapes; each needs a
different platform-specific resolution strategy. Without a single
helper, every public entry point (`decodeSource`, `encodeTo`,
`progressiveDecode`) would re-implement the same `string | URL |
ArrayBuffer | Uint8Array | ReadableStream` switch — duplicating wiring
and risking divergent behaviour for the edge cases. This module
centralises it.
The detection delegates magic-byte sniffing to
`@suaveplan/audio-metadata`'s `detectAudioFormat` — the same function
used by every other package that needs to identify audio bytes
— so the registry's `detect` and the entry-point `detectSource` always
agree.
## Features
- `Source` type union — `string | URL | ArrayBuffer | Uint8Array |
ReadableStream<Uint8Array>`.
- `DetectResult` shape — `{ format: string | undefined; bytes?:
Uint8Array; path?: string; reader?: ReadableStreamDefaultReader }`.
The optional fields tell the caller what kind of resolution happened
so they can continue draining the source efficiently.
- `detectSource(source)` — reads at most `HEADER_SIZE` (256 KiB) bytes
from the source head, sniffs the format, and returns either the
detected format name or `undefined` for unknown content. Never
throws for unknown content — only throws `INVALID_SOURCE` for a
source value that is not a recognised variant or a fetch that
returned no body.
- `resolveSource(source)` — drains the source completely into a single
`Uint8Array`. Used by `decodeSource` to materialise the full payload
before dispatch.
- `HEADER_SIZE` — exported constant set to `256 * 1024`. Reading
exactly this many bytes keeps detection cheap even for multi-GB
files while being large enough to cover every realistic header
layout (ID3v2 with embedded artwork, AIFF with large COMM-adjacent
comment chunks, etc).
## Resolution rules
- `string` starting with `http://` / `https://` / `data:` / `blob:`,
or any `URL` instance → resolved via `fetch()`. Header read via the
response stream reader, which is returned to the caller for
continued draining.
- `string` starting with `file://` → resolved via dynamic
`import('node:fs/promises')`. Returns the resolved file path so the
caller can perform random-access reads.
- Other bare `string` → on a server runtime (process.versions.node is
a string) resolves via `fs/promises.readFile`; in a browser runtime
resolves via `fetch()`.
- `ArrayBuffer` / `Uint8Array` → header is the first 256 KiB slice;
no platform call.
- `ReadableStream<Uint8Array>` → reader drains until 256 KiB observed;
the original reader is returned for continued draining.
## Usage: Basic Example
```ts
import { detectSource } from "@suaveplan/audio-codecs/detect/detect-format";
const result = await detectSource(bytes);
if (result.format === "wav") {
decodeWav(result.bytes!);
}
```
## Usage: Advanced Example
Resolve a remote URL once and reuse the detection result + stream
reader without re-fetching:
```ts
import { detectSource, resolveSource } from "@suaveplan/audio-codecs/detect/detect-format";
const result = await detectSource("https://cdn.example/audio.flac");
if (result.format === "flac") {
// We have the first 256 KiB cached on result.bytes, and the rest
// is available via result.reader.
const rest = await drainReader(result.reader!);
const full = concat(result.bytes!, rest);
return decodeFlac(full);
}
// resolveSource fully drains regardless of source shape.
const buf = await resolveSource("file:///srv/audio/in.wav");
```
## API Reference
- `type Source = string | URL | ArrayBuffer | Uint8Array | ReadableStream<Uint8Array>`
- `interface DetectResult { format: string | undefined; bytes?: Uint8Array; path?: string; reader?: ReadableStreamDefaultReader<Uint8Array> }`
- `const HEADER_SIZE: number` — `256 * 1024`
- `async function detectSource(source: Source): Promise<DetectResult>` —
throws `AUDIO_CODECS.INVALID_SOURCE` for an unrecognised source
value or when `fetch()` returns a body-less response.
- `async function resolveSource(source: Source): Promise<Uint8Array>` —
throws `AUDIO_CODECS.INVALID_SOURCE` for an unrecognised source.
## Implementation Notes
- `isServerRuntime()` is a closure over `typeof process !== "undefined"
&& typeof process.versions?.node === "string"`. The function exists
so the runtime branch is not evaluated at module-load time — the
package stays universal with no static `node:fs` import.
- The `node:fs/promises` import is **always** inside a runtime-gated
dynamic `import()` — never a top-level import. This keeps the package
importable from browser bundles without bundler config tweaks.
- The browser-only `fetch()` fallback paths (lines 154-156 in
`detectSource`, 214-216 in `resolveSource`) are unreachable under
Node tests; structural coverage of that branch requires a happy-dom
or browser-target test harness (a future integration suite).
- The `URL` instance branch and the `http://` / `https://` /
`data:` / `blob:` string branch both route through
`fetchHeaderViaFetch` — no logic is duplicated.
- `readHeaderFromStream` keeps reading until at least `HEADER_SIZE`
bytes are observed OR the stream ends. The resulting buffer may
be larger than `HEADER_SIZE` because chunks arrive whole; that's
intentional — over-read is cheap and avoids fragmenting the
caller's understanding of what bytes were consumed.

View File

@@ -0,0 +1,128 @@
# `encode/encode-to`
Universal encode entry point — encode an `AudioDocument` to bytes and
write them to any `Destination` (file path, WritableStream, or
callback).
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`encodeTo` is the top-level public-facing encode API. Callers do not
need to wire codec dispatch + destination plumbing manually — they
pass in a document, a format name, and a destination handle, and the
function takes care of routing through `CodecRegistry.encode` and
then writing the resulting bytes to the destination via the
appropriate platform call.
## Features
- Accepts three `Destination` variants:
- `string` file path → resolved via dynamic
`import('node:fs/promises')` for `writeFile`. Runtime-gated so
the import never appears statically.
- `WritableStream<Uint8Array>` → write via the stream's writer,
closes the stream after writing.
- `(chunk: Uint8Array) => void` callback → single invocation with
the full payload.
- Optional `opts.format: "pcm" | "float"` and `opts.bitDepth: 8 | 16
| 24 | 32` for codecs that support multiple sample formats (WAV).
- Optional `opts.sampleRate` override — used when the encode pipeline
needs to retarget a different rate than the document's natural
rate.
- Optional `opts.signal` (AbortSignal) — checked before encode
dispatch. Pre-aborted signals throw `AUDIO_CODECS.STREAM_ERROR`.
- Optional `opts.registry` to inject a pre-configured registry.
Defaults to a fresh `CodecRegistry()` — callers that need WAV/AIFF/
CAF or WASM codecs available should pass in the registry from
`decode-source.ts` or construct one with the codecs they need.
- Zod-validated options — invalid `sampleRate`/`bitDepth`/`format`
throws `AUDIO_CODECS.INVALID_SOURCE`.
- Encoder errors are wrapped — codec-specific failures surface as
`AUDIO_CODECS.ENCODE_FAILED` with the original error on `cause`.
- Invalid destination (not a path/stream/callback) throws
`AUDIO_CODECS.INVALID_SOURCE`.
## Usage: Basic Example
```ts
import { CodecRegistry, encodeTo, WavCodec } from "@suaveplan/audio-codecs";
const registry = new CodecRegistry({ codecs: [WavCodec] });
await encodeTo(doc, "/tmp/out.wav", "wav", {
registry,
bitDepth: 16,
format: "pcm",
});
```
## Usage: Advanced Example
Encode to a streaming destination (e.g. an HTTP response body) with
abort cancellation:
```ts
import { CodecRegistry, encodeTo, WavCodec } from "@suaveplan/audio-codecs";
const registry = new CodecRegistry({ codecs: [WavCodec] });
const ctrl = new AbortController();
const responseStream = new WritableStream<Uint8Array>({
write(chunk) {
sendOverWebSocket(chunk);
},
});
await encodeTo(doc, responseStream, "wav", {
registry,
bitDepth: 24,
format: "pcm",
signal: ctrl.signal,
});
// Or write via callback for fine-grained control:
let captured: Uint8Array | undefined;
await encodeTo(doc, (chunk) => { captured = chunk; }, "wav", { registry });
```
## API Reference
- `type Destination = string | WritableStream<Uint8Array> | ((chunk: Uint8Array) => void)`
- `interface EncodeToOpts { sampleRate?, bitDepth?, format?, signal?, registry? }`
- `const EncodeToOptsSchema` — Zod schema validating
`sampleRate`, `bitDepth`, and `format`.
- `async function encodeTo(doc: AudioDocument, destination: Destination,
format: string, opts?: EncodeToOpts): Promise<void>` — throws
`INVALID_SOURCE` for invalid options or unrecognised destination,
`STREAM_ERROR` for pre-aborted signals, `NOT_SUPPORTED` for unknown
formats, `ENCODE_FAILED` for codec errors.
## Implementation Notes
- The file-path destination uses dynamic `import('node:fs/promises')`
to keep the package universal. Browser callers must use the
`WritableStream` or callback destination — passing a string path
in a browser would fail with `INVALID_SOURCE` via the dynamic
import failure, which then surfaces as `INVALID_SOURCE` at the
outer boundary.
- The `WritableStream` path acquires a writer, writes one chunk, then
closes via `writer.close()`. Callers that need to keep the stream
open for additional writes should use a callback destination
instead — the closed-after-one-write semantics matches the
single-payload encode model.
- The callback destination receives the full encoded buffer in one
invocation. For per-chunk encode (where bytes arrive as encoding
progresses), use `CodecRegistry.encodeStream` directly instead of
`encodeTo`.
- Codec error wrapping preserves the original via `cause`; callers
can recover the underlying error via
`(err as AudioCodecsError).cause`.
- `opts.signal` is checked only before dispatch — codec internal
abort during encode requires the codec to honour the signal,
which most current codecs (WAV native, WASM wrappers) do not.
Mid-encode cancellation will not interrupt the codec call but
will surface as a thrown `STREAM_ERROR` from any subsequent
`encodeTo` call against the same controller.

View File

@@ -0,0 +1,129 @@
# `error-codes`
Stable error-code registry for `@suaveplan/audio-codecs`. Every fault raised
by the package is an `AudioCodecsError` carrying one of seven codes from this
registry plus an optional `cause` and `context`.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`audio-codecs` is a public, plugin-driven boundary: callers hand it arbitrary
bytes, files, URLs, or streams and expect a single stable contract on
failure. Without a frozen code namespace, every codec implementation would be
free to throw its own bespoke error shape and consumers would have to
discriminate by message string — which rots the moment a codec rephrases
itself.
This module is the contract. Every code is declared via `defineCodes`, every
code has a description and severity in `AUDIO_CODECS_ERROR_METADATA`, and
every code is asserted reachable from a call site by
`src/error-codes.test.ts`. The full namespaced values
(`AUDIO_CODECS.UNKNOWN_FORMAT`, etc.) are stable forever: renaming one is a
breaking change that warrants its own OpenSpec proposal.
## Features
- Seven stable codes covering detection, decode, encode, streaming, support,
source-validation, and memory-budget faults.
- Frozen `AUDIO_CODECS_ERROR_METADATA` describing every code's purpose and
severity (`"error"` for hard faults, `"warning"` for AbortSignal / stream-
termination scenarios).
- `AudioCodecsError extends SuaveplanError<AudioCodecsErrorCode>` with a
public constructor, a stable `category = "audio-codecs"` discriminator,
and `cause` / `context` propagation from the base class.
- Idempotent `registerAudioCodecsErrorCodes()` hook for downstream OTel
attribute binders that want a guaranteed touch point.
## Usage: Basic Example
```ts
import {
AUDIO_CODECS_CODES,
AudioCodecsError,
} from "@suaveplan/audio-codecs/error-codes";
if (!codec) {
throw new AudioCodecsError(
AUDIO_CODECS_CODES.UNKNOWN_FORMAT,
"audio-codecs.detection.unknown",
{ context: { sourceKind: "buffer" } }
);
}
```
Consumers catch by `instanceof AudioCodecsError` and discriminate on the
stable `code` field — never on the message text, which is localisation-bound
through the `SuaveplanError` `MessageResolver` hook.
## Usage: Advanced Example
Wiring codes into a structured reporter:
```ts
import {
AUDIO_CODECS_CODES,
AUDIO_CODECS_ERROR_METADATA,
AudioCodecsError,
type AudioCodecsErrorCode,
} from "@suaveplan/audio-codecs/error-codes";
function reportCodecFailure(err: unknown, span: import("@opentelemetry/api").Span): void {
if (!(err instanceof AudioCodecsError)) {
span.recordException(err as Error);
return;
}
const meta = AUDIO_CODECS_ERROR_METADATA[err.code as AudioCodecsErrorCode];
span.setStatus({ code: 2 /* ERROR */ });
span.setAttribute("audio_codec.error_code", err.code);
span.setAttribute("audio_codec.error_severity", meta.severity);
if (err.cause instanceof Error) span.recordException(err.cause);
}
// `BUDGET_EXCEEDED` and `STREAM_ERROR` are first-class outcomes a planning
// stage can check before invoking decode — they aren't only thrown.
if (projection.bytes > budget) {
throw new AudioCodecsError(
AUDIO_CODECS_CODES.BUDGET_EXCEEDED,
"audio-codecs.decode.budget-exceeded",
{ context: { projectedBytes: projection.bytes, budgetBytes: budget } }
);
}
```
## API Reference
- `AUDIO_CODECS_CODES` — frozen record of namespaced codes; access as
`AUDIO_CODECS_CODES.DECODE_FAILED` and the value is
`"AUDIO_CODECS.DECODE_FAILED"`.
- `AudioCodecsErrorCode` — union type of every registry value.
- `AudioCodecsErrorMetadata` — `{ description: string; severity: "warning" |
"error" }`.
- `AUDIO_CODECS_ERROR_METADATA: Readonly<Record<AudioCodecsErrorCode,
AudioCodecsErrorMetadata>>` — frozen lookup keyed by code.
- `AudioCodecsError` — constructor signature
`new AudioCodecsError(code, messageKey, options?)` where `options` is the
shared `SuaveplanErrorOptions` (`cause`, `context`, `traceId`).
- `registerAudioCodecsErrorCodes()` — idempotent no-op hook.
## Implementation Notes
- The seven codes are deliberately narrow: every codec failure maps to one of
them. Adapters that need finer granularity preserve the original error via
`cause` and add provider-specific keys to `context` — they do not add new
codes here.
- `AUDIO_CODECS.STREAM_ERROR` carries `severity: "warning"` because the most
common cause is caller-initiated `AbortSignal` cancellation, which is a
predictable cooperative termination rather than a fault. Genuine streaming
faults (codec stream stalls, unexpected EOF mid-chunk) still use this code
but elevate severity at the reporting layer via `cause`.
- The class carries a `readonly category = "audio-codecs"` literal so the
base `SuaveplanError` constructor widening is non-trivial (avoids
`noUselessConstructor` lint without a suppression comment).
- The registration hook is intentionally a no-op: the metadata record is
declared frozen at module evaluation time, so there is no side-effect to
defer. Keeping the hook exported preserves the same shape as sibling
audio packages, so a bulk registrar (`audio-core`, `audio-controllers`,
`audio-codecs`, etc.) can iterate them uniformly at startup.

View File

@@ -0,0 +1,141 @@
# `registry/registry`
In-memory plugin registry that owns audio-codec lookup and dispatch for the
whole package.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`CodecRegistry` is the single entry point through which `decodeSource`,
`encodeTo`, `progressiveDecode`, and the streaming transform factories
resolve a codec from raw bytes, a format hint, an extension, or a MIME type
and then dispatch decode/encode operations to it. Without a single registry,
every public entry point would re-implement the lookup + dispatch + error
mapping triad — duplicating wiring and risking divergence.
The registry also owns the package's OpenTelemetry instrumentation surface
for decode/encode calls (`codec.decode` / `codec.encode` spans, the
`audio_codec_*` counter and histogram pair). Codecs themselves stay free of
OTel concerns; the registry observes the boundary.
## Features
- Three lookup indexes — by canonical `name`, by file extension, by MIME
type. `get(key)` checks all three so callers do not need to know which
shape they hold.
- Eager initial registration via `new CodecRegistry({ codecs: [...] })` plus
imperative `register(codec)` / `unregister(name)`.
- `detect(bytes)` walks every registered codec's `canDecode` in
registration order and returns the first match's `name` (or `undefined`).
- `decode(bytes, opts?)` resolves the codec, awaits its `decode`, and builds
an `AudioDocument` from the resulting `Float32Array[]` PCM. Non-API codec
errors are wrapped in `AudioCodecsError("AUDIO_CODECS.DECODE_FAILED")`
with the original on `cause`; `AudioCodecsError` instances pass through.
- `encode(doc, format, opts?)` materialises the document's PCM, dispatches
to the codec registered for `format`, and returns the encoded bytes.
Unknown format → `AUDIO_CODECS.NOT_SUPPORTED`. Non-API encode error →
`AUDIO_CODECS.ENCODE_FAILED` with `cause`.
- `decodeStream(source, format)` returns a `ReadableStream<AudioPage>` whose
pull loop drives the codec's `createDecoder()` streaming factory.
Codecs without that factory throw `AUDIO_CODECS.STREAM_ERROR`.
- `encodeStream(doc, format)` returns a `ReadableStream<Uint8Array>` whose
pull loop feeds the document's pages (sorted by `startSample`) into the
codec's `createEncoder()`.
- `list()` returns every registered codec for introspection.
- Zod-validated codec registration via `AudioCodecSchema` — a malformed
codec object is rejected at `register()` with `INVALID_SOURCE`.
- OTel spans, counter, and duration histogram on every successful decode
and encode; spans carry the resolved `format` as an attribute.
## Usage: Basic Example
```ts
import { CodecRegistry } from "@suaveplan/audio-codecs/registry/registry";
import { WavCodec } from "@suaveplan/audio-codecs/codecs/wav";
const registry = new CodecRegistry({ codecs: [WavCodec] });
// decode WAV bytes into an AudioDocument
const doc = await registry.decode(wavBytes);
// re-encode back to WAV at 24-bit PCM
const wav = await registry.encode(doc, "wav", { bitDepth: 24, format: "pcm" });
```
## Usage: Advanced Example
Hot-swap a codec and pipeline a streaming decode into a custom transform:
```ts
import { CodecRegistry } from "@suaveplan/audio-codecs/registry/registry";
import { Mp3Codec, WavCodec, FlacCodec } from "@suaveplan/audio-codecs";
const registry = new CodecRegistry({ codecs: [WavCodec, Mp3Codec] });
registry.register(FlacCodec); // additive — now decodes FLAC too
const sourceStream: ReadableStream<Uint8Array> = await fetchAudio();
const pageStream = registry.decodeStream(sourceStream, "mp3");
// pipe through a downstream normaliser (defined elsewhere)
const normalised = pageStream.pipeThrough(makeNormaliser());
for await (const page of normalised) {
consumePage(page);
}
// detect-then-dispatch for unknown bytes
const fmt = registry.detect(unknownBytes);
if (fmt) {
const doc = await registry.decode(unknownBytes, { format: fmt });
useDoc(doc);
}
```
## API Reference
- `class CodecRegistry`
- `constructor(options?: CodecRegistryOptions)` — `options.codecs` are
each `register`-ed eagerly.
- `register(codec: AudioCodec): void` — throws
`AudioCodecsError("AUDIO_CODECS.INVALID_SOURCE")` if `codec` fails
`AudioCodecSchema` validation. Replaces any prior codec sharing the
same `name`; extension and MIME indexes additively expand.
- `unregister(name: string): void` — no-op for unknown names; clears
matching extension and MIME entries.
- `get(nameOrExtOrMime: string): AudioCodec | undefined`
- `list(): readonly AudioCodec[]`
- `detect(bytes: Uint8Array): string | undefined`
- `decode(bytes, opts?): Promise<AudioDocument>` — `opts.format` is a hint
that wins over `canDecode` detection when supplied; `opts.metadata` is
forwarded onto the resulting `AudioDocument`.
- `encode(doc, format, opts?): Promise<Uint8Array>`
- `decodeStream(source, format): ReadableStream<AudioPage>`
- `encodeStream(doc, format): ReadableStream<Uint8Array>`
## Implementation Notes
- The three lookup indexes are kept independently — `unregister` walks both
extension and MIME maps and deletes only entries that still point at the
removed codec, so registering two codecs that share an extension and
unregistering one leaves the other intact.
- `detect` iteration order is the order codecs were registered. Callers
that need a different priority (e.g. AIFF before WAV when both
canDecode an ambiguous prefix) should re-`register` the higher-priority
codec last so its `canDecode` runs first.
- The `#instrumented` helper centralises span lifetime, counter, and
histogram observations. It catches any thrown error, sets the span
status to ERROR, records the exception, then re-throws — so the
outer-most caller still observes the original (or wrapped) error.
- `pcmFromDocument` walks the document's pages in their stored order, not
by `startSample`. For documents produced by `buildDocument` this is the
same; for hand-constructed documents the caller is responsible for
ordered insertion. `encodeStream` does sort by `startSample` explicitly
to handle reordering at the streaming boundary.
- The constants `TRACER`, `METER`, `DECODE_COUNTER`, `ENCODE_COUNTER`,
`DECODE_HIST`, `ENCODE_HIST` are acquired at module evaluation time but
the underlying OTel SDK is a no-op when no exporter is registered, so
importing the registry has no observable runtime cost in unconfigured
environments.

View File

@@ -0,0 +1,100 @@
# `stream/stream-decoder`
`TransformStream<Uint8Array, AudioPage>` factory — composes a chunked
byte stream into an `AudioPage` stream by driving a codec's
`StreamingDecoder`.
## Purpose
The `CodecRegistry.decodeStream` method returns a
`ReadableStream<AudioPage>`. That works when callers want to read
pages directly, but does not compose into `@suaveplan/streams`
pipelines because pipelines are built from `TransformStream` nodes,
not `ReadableStream` sinks. This module's `createDecodeTransform`
fills the gap — it returns a `TransformStream<Uint8Array, AudioPage>`
suitable for `pipeThrough()` between a raw byte source and a
downstream consumer.
## Features
- `createDecodeTransform(format, registry?)` — returns a
`TransformStream<Uint8Array, AudioPage>` where each input chunk
is fed to the codec's `StreamingDecoder` and each decoded
`DecodeResult` is emitted as one `AudioPage` on the output stream.
- Lazy codec resolution — the transform's `ensure()` helper picks
the codec and constructs its decoder on first chunk, not at
transform construction. This lets callers tee the stream or
build pipeline branches without paying for codec setup on
branches that never see data.
- Default registry — when `registry` is omitted, the transform
lazily loads `WavCodec`, `AiffCodec`, and `CafCodec` into a
shared cached registry. Callers needing WASM codecs must pass
their own registry.
- Error propagation — codec failures (unknown format,
no-streaming-factory, or any thrown error from `write` / `flush`)
surface as `controller.error(...)` with an `AudioCodecsError`
carrying the original on `cause`.
## Usage: Basic Example
```ts
import { createDecodeTransform } from "@suaveplan/audio-codecs/stream/stream-decoder";
import { CodecRegistry, WavCodec } from "@suaveplan/audio-codecs";
const registry = new CodecRegistry({ codecs: [WavCodec] });
const transform = createDecodeTransform("wav", registry);
const upstream: ReadableStream<Uint8Array> = await fetchAudio();
const pages = upstream.pipeThrough(transform);
for await (const page of pages) {
consumePage(page);
}
```
## Usage: Advanced Example
Compose with a downstream `@suaveplan/streams` transform:
```ts
import { createDecodeTransform } from "@suaveplan/audio-codecs/stream/stream-decoder";
import { makeNormaliser, makeRingBuffer } from "./my-pipeline";
const decodeTx = createDecodeTransform("flac", myRegistry);
await fetchAudioStream()
.pipeThrough(decodeTx)
.pipeThrough(makeNormaliser({ targetLufs: -14 }))
.pipeThrough(makeRingBuffer({ capacity: 16 }))
.pipeTo(myDestinationSink);
```
## API Reference
- `function createDecodeTransform(format: string, registry?: CodecRegistry):
TransformStream<Uint8Array, AudioPage>` — produces a transform that
emits `AudioPage` per decoded block; throws / errors with
`AUDIO_CODECS.NOT_SUPPORTED` for unknown format or
`AUDIO_CODECS.STREAM_ERROR` for codecs without `createDecoder()`.
## Implementation Notes
- The `transform()` callback awaits `ensure()` then calls
`decoder.write(chunk)`, fanning out the resulting `DecodeResult[]`
into the output controller. Each `DecodeResult.channelData` array
is wrapped in fresh `Float32Array` copies so downstream consumers
cannot accidentally mutate the codec's internal buffers.
- `startSample` accumulates across pages so the emitted
`AudioPage` sequence forms a contiguous timeline.
- The `flush()` callback drains the codec's buffered tail via
`decoder.flush()`, emits any remaining pages, then closes the
decoder. Errors during flush are translated into
`AUDIO_CODECS.STREAM_ERROR` via `controller.error()`.
- Codec-thrown `AudioCodecsError` instances pass through unchanged;
non-API errors are wrapped with `STREAM_ERROR`.
- The lazy registry initialisation (`getDefaultRegistry`) currently
registers only the three native codecs (WAV/AIFF/CAF). Callers
needing MP3/FLAC/Opus/etc must build their own registry via
`new CodecRegistry({ codecs: [...] })` and pass it explicitly.
This choice keeps the default-registry path tree-shakeable when
callers don't use streaming.

View File

@@ -0,0 +1,110 @@
# `stream/stream-encoder`
`TransformStream<readonly Float32Array[], Uint8Array>` factory —
composes a PCM-block stream into encoded bytes by driving a codec's
`StreamingEncoder`.
## Purpose
The mirror of `stream-decoder` for the encode direction. Callers
building a `@suaveplan/streams` pipeline (decode → DSP → encode)
need a `TransformStream` node for the encode stage; this module
provides it.
## Features
- `createEncodeTransform(format, sampleRate, channels, registry?)` —
returns a `TransformStream<readonly Float32Array[], Uint8Array>`
where each input `Float32Array[]` chunk represents one block of
PCM (one `Float32Array` per channel, same length per channel) and
each output `Uint8Array` carries encoded bytes from the codec's
`StreamingEncoder`.
- Lazy codec resolution — the transform constructs the codec's
encoder on first chunk (or on flush if no chunks arrived). The
encoder's `start()` is called once to emit header bytes, then
`write()` for every chunk, then `flush()` to emit any trailer.
- Sample rate + channel count are captured at construction so the
encoder's `start()` can pre-compute its header without waiting
for the first chunk.
- Empty-source handling — even when no PCM chunks arrive, `flush()`
still calls `start()` once and emits the codec's trailer (the
44-byte WAV header for the WAV codec). Downstream consumers
always receive a structurally-valid file.
- Default registry — when `registry` is omitted, the transform
lazily loads `WavCodec` into a shared cached registry. Codecs
beyond WAV that support encode (FLAC, MP3, Opus, QOA) must be
passed explicitly via the `registry` parameter.
## Usage: Basic Example
```ts
import { createEncodeTransform } from "@suaveplan/audio-codecs/stream/stream-encoder";
import { CodecRegistry, WavCodec } from "@suaveplan/audio-codecs";
const registry = new CodecRegistry({ codecs: [WavCodec] });
const transform = createEncodeTransform("wav", 44_100, 2, registry);
const pcmStream = makePcmBlockStream();
const wavBytes = pcmStream.pipeThrough(transform);
await wavBytes.pipeTo(myFileSink);
```
## Usage: Advanced Example
Decode-then-encode pipeline (transcode FLAC → WAV via streaming):
```ts
import {
createDecodeTransform,
createEncodeTransform,
} from "@suaveplan/audio-codecs/stream/stream-encoder";
const decodeTx = createDecodeTransform("flac", registry);
const encodeTx = createEncodeTransform("wav", 44_100, 2, registry);
// The mid pipeline converts AudioPage into Float32Array[] form.
const pageToBlock = new TransformStream<AudioPage, readonly Float32Array[]>({
transform(page, controller) {
controller.enqueue(page.channelData);
},
});
await fetchAudio()
.pipeThrough(decodeTx)
.pipeThrough(pageToBlock)
.pipeThrough(encodeTx)
.pipeTo(myDestination);
```
## API Reference
- `function createEncodeTransform(format: string, sampleRate: number,
channels: number, registry?: CodecRegistry):
TransformStream<readonly Float32Array[], Uint8Array>` — produces
a transform that emits encoded byte chunks; errors with
`AUDIO_CODECS.NOT_SUPPORTED` for unknown formats or
`AUDIO_CODECS.STREAM_ERROR` for codecs without `createEncoder()`.
## Implementation Notes
- The `started` flag ensures the encoder's `start(sampleRate,
channels)` is invoked exactly once even when no chunks arrive
(the `flush()` path also checks the flag and calls `start()`
if needed before invoking `flush()`).
- WAV's `start()` returns an empty `Uint8Array` because the WAV
header includes the total data-chunk size which is only known at
flush time; the transform handles that by emitting zero-byte
chunks (skipped via the `byteLength > 0` check) so downstream
consumers don't see empty data.
- Codec-thrown `AudioCodecsError` instances pass through unchanged;
non-API errors from `write` or `flush` are wrapped with
`STREAM_ERROR`.
- The default registry registers only `WavCodec`. Encoding via
FLAC, MP3, Opus, or QOA requires the caller to construct a
registry with those codecs and pass it as the fourth argument.
This choice keeps the default-registry path tree-shakeable when
callers don't use streaming.
- `cancel()` on the underlying transform stream calls
`encoder.close()` to release any internal buffers — even
half-streamed encodes leak no resources on cancellation.

View File

@@ -0,0 +1,75 @@
# @suaveplan/audio-controllers - Universal MIDI hardware controller plugins (Kontrol S88, Maschine, Traktor S4): MidiControllerPlugin base with injectable MidiAccessAdapter, hot-plug FSM via @suaveplan/fsm, transport-synced Sequencer + Arpeggiator, CC-mapping DSL, learn mode, LED feedback, bindParameter integration with @suaveplan/audio-fx-plugin-base, AUDIO_CONTROLLERS.* error registry, full OTel surface.
Universal MIDI hardware controller plugins. Built on
`@suaveplan/audio-core`, `@suaveplan/audio-transport`, `@suaveplan/events`,
`@suaveplan/fsm`, and `@suaveplan/audio-fx-plugin-base`. Ships three
concrete controllers (Kontrol S88, Maschine, Traktor S4) plus a shared
`MidiControllerPlugin` base, `Sequencer`, `Arpeggiator`, learn-mode helper,
and `bindParameter` integration with the FX plugin family.
<!-- begin:auto-docs -->
## Documentation tree
- [arpeggiator](./src/arpeggiator/arpeggiator.md) — `Arpeggiator` is the transport-synced note-walker shared between the Kontrol S88's hardware arpeggio mode and any consu…
- [arpeggiator](./src/arpeggiator/arpeggiator.md) — `Arpeggiator` is the transport-synced note-walker shared between the Kontrol S88's hardware arpeggio mode and any consu…
- [connection-fsm](./src/base/connection-fsm.md) — Builds the controller connection-state machine consumed by every `MidiControllerPlugin`. The FSM models the lifecycle o…
- [midi-controller-plugin](./src/base/midi-controller-plugin.md) — The abstract `MidiControllerPlugin` is the universal base class shared by every concrete MIDI controller integrati…
- [otel](./src/base/otel.md) — Centralises the OpenTelemetry surface consumed by every module in the package. The `getControllerOtel()` accessor lazil…
- [binding](./src/binding/bind-parameter.md) — `bindParameter` wires a typed `MidiSource` (a controller CC, note-velocity, or future hardware source) to a parameter o…
- [bind-parameter](./src/binding/bind-parameter.md) — `bindParameter` wires a typed `MidiSource` (a controller CC, note-velocity, or future hardware source) to a parameter o…
- [error-codes](./src/error-codes/error-codes.md) — Defines the canonical `AUDIO_CONTROLLERS.*` error-code registry for the `@suaveplan/audio-controllers` package. Every e…
- [error-codes](./src/error-codes/error-codes.md) — Defines the canonical `AUDIO_CONTROLLERS.*` error-code registry for the `@suaveplan/audio-controllers` package. Every e…
- [learn](./src/learn/learn-mode.md) — Functional helper around the `MidiControllerPlugin.enterLearnMode` / `exitLearnMode` toggle. The base class natively sw…
- [learn-mode](./src/learn/learn-mode.md) — Functional helper around the `MidiControllerPlugin.enterLearnMode` / `exitLearnMode` toggle. The base class natively sw…
- [kontrol-s88](./src/plugins/kontrol-s88/kontrol-s88.md) — `KontrolS88Plugin` is the concrete `MidiControllerPlugin` subclass for Native Instruments Kontrol S88 MK2/MK3 — an 88-k…
- [kontrol-s88](./src/plugins/kontrol-s88/kontrol-s88.md) — `KontrolS88Plugin` is the concrete `MidiControllerPlugin` subclass for Native Instruments Kontrol S88 MK2/MK3 — an 88-k…
- [maschine](./src/plugins/maschine/maschine.md) — `MaschinePlugin` is the concrete `MidiControllerPlugin` subclass for Native Instruments Maschine: a 16-pad grid with pe…
- [maschine](./src/plugins/maschine/maschine.md) — `MaschinePlugin` is the concrete `MidiControllerPlugin` subclass for Native Instruments Maschine: a 16-pad grid with pe…
- [traktor-s4](./src/plugins/traktor-s4/traktor-s4.md) — `TraktorS4Plugin` is the concrete `MidiControllerPlugin` subclass for the Native Instruments Traktor Kontrol S4 MK3 — a…
- [traktor-s4](./src/plugins/traktor-s4/traktor-s4.md) — `TraktorS4Plugin` is the concrete `MidiControllerPlugin` subclass for the Native Instruments Traktor Kontrol S4 MK3 — a…
- [sequencer](./src/sequencer/sequencer.md) — `Sequencer` is the transport-synced step engine consumed by every `MidiControllerPlugin`. It schedules every active ste…
- [sequencer](./src/sequencer/sequencer.md) — `Sequencer` is the transport-synced step engine consumed by every `MidiControllerPlugin`. It schedules every active ste…
<!-- end:auto-docs -->
## Shipped controllers
- `KontrolS88Plugin` — 88-key weighted keyboard with scale modes, arp,
pitch-bend, mod-wheel, and per-key RGB LED feedback.
- `MaschinePlugin` — 16-pad grid with per-pad velocity, step sequencer
rows, and slot-based sample loading.
- `TraktorS4Plugin` — 4-channel DJ controller with mixer, crossfader, and
four decks.
## Features
- Injectable `MidiAccessAdapter` seam — the package never reaches for
`navigator.requestMIDIAccess` directly, so tests run under happy-dom
with a fake-device fixture and zero mocks.
- Connection-state FSM via `@suaveplan/fsm` with hot-plug recovery.
- Declarative CC mapping DSL with Zod-validated bytes.
- Learn mode for runtime CC capture.
- Transport-synced sequencer and arpeggiator — sample-accurate, offline
renderable, no `setInterval`.
- OpenTelemetry counters, histograms, and spans (no-op without an exporter).
- Zod schemas at every public boundary.
- Six `AUDIO_CONTROLLERS.*` error codes with severity metadata.
## Subpath exports
| Path | Module |
|---|---|
| `.` | barrel |
| `./base/midi-controller-plugin` | `MidiControllerPlugin` + types |
| `./sequencer/sequencer` | `Sequencer` |
| `./arpeggiator/arpeggiator` | `Arpeggiator` |
| `./learn/learn-mode` | `captureNextLearn` |
| `./binding` | `bindParameter`, `bindPluginPreset` |
| `./plugins/kontrol-s88` | `KontrolS88Plugin` |
| `./plugins/maschine` | `MaschinePlugin` |
| `./plugins/traktor-s4` | `TraktorS4Plugin` |
| `./error-codes` | `AUDIO_CONTROLLERS_*` registry |
## Status
`0.1.0` — Wave 1 migration. 100/100/100/100 coverage.

View File

@@ -0,0 +1,60 @@
# arpeggiator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`Arpeggiator` is the transport-synced note-walker shared between the
Kontrol S88's hardware arpeggio mode and any consumer that wants
pattern-driven note generation tied to the project transport. It
subscribes to a `Transport` via `scheduleRepeat`, so the same pattern
emits identical sequences in real time and under offline transport
advancement — the spec's "renderable offline" guarantee. Supported modes
are `up`, `down`, `updown`, and `random`; the rate is any
`TransportTime` token (`16n`, `8n`, beats, ticks).
## Features
- Four ordering modes: `up`, `down`, `updown`, `random`.
- Injectable RNG for deterministic tests of `random` mode.
- `setRate` accepts any `TransportTime` token.
- `onNote` listener registry with cleanup handles.
## Usage: Basic
```ts
const arp = new Arpeggiator({ transport });
arp.setPattern([60, 64, 67], "up");
arp.onNote(({ note, time }) => synth.trigger(note, time));
arp.start();
```
## Usage: Advanced
```ts
const arp = new Arpeggiator({ transport, rate: "32n", rng: () => 0.5 });
arp.setPattern([48, 55, 60, 64], "random");
arp.start();
transport.start();
// Offline render
transport.scheduleOnce(() => arp.stop(), "4m");
```
## API Reference
- `new Arpeggiator({ transport, rate?, rng? })`.
- `setPattern(notes, mode)` — replaces the pattern and resets the cursor.
- `setRate(interval)`.
- `start()` / `stop()`.
- `onNote(listener)` → unsubscribe function.
## Implementation Notes
The `updown` mode keeps its own direction flag and reverses at the
endpoints so the sequence is `0 1 2 1 0 1 2 1 …` for a three-note
pattern. `random` mode draws the index from the injected RNG; tests
pass a deterministic generator so the property "every output index is
inside `[0, pattern.length)`" is asserted without flakiness. `setPattern`
resets the cursor so a mode change always begins at the first element.

View File

@@ -0,0 +1,63 @@
# connection-fsm
## Purpose
Builds the controller connection-state machine consumed by every
`MidiControllerPlugin`. The FSM models the lifecycle of a hardware
controller's MIDI access: a fresh controller starts `disconnected`, moves
through `connecting`, settles in `connected`, and falls into `error` on
any access failure or rejected transition. Because the FSM is driven from
both the adapter (real connect/disconnect) and from `handleStateChange`
(hot-plug events from `navigator.requestMIDIAccess().onstatechange`-shaped
sources), centralising the rules here keeps every device subclass
ignorant of state-machine details.
## Features
- `createConnectionMachine()` returns an `@suaveplan/fsm` interpreter with
the four canonical states pre-wired.
- `sendOrThrow(machine, event)` converts the FSM's underlying
`INVALID_TRANSITION` into a domain-stable
`AudioControllersError("AUDIO_CONTROLLERS.INVALID_STATE")` with the
offending `from` and `event` carried in `context`.
- Transition table mirrors the spec exactly: `DISCONNECT`, `CONNECT`,
`CONNECTED`, `ERROR`, `RESET`.
## Usage: Basic
```ts
import { createConnectionMachine, sendOrThrow } from "@suaveplan/audio-controllers";
const m = createConnectionMachine();
sendOrThrow(m, "CONNECT");
sendOrThrow(m, "CONNECTED");
```
## Usage: Advanced
```ts
const m = createConnectionMachine();
m.onTransition((state) => log.debug({ value: state.value }, "controller-fsm"));
try {
sendOrThrow(m, "CONNECTED"); // illegal from `disconnected`
} catch (err) {
if (err instanceof AudioControllersError) {
expect(err.code).toBe(AUDIO_CONTROLLERS_CODES.INVALID_STATE);
}
}
```
## API Reference
- `createConnectionMachine()` → `FSMInterpreter<{ lastError?: string }>`.
- `sendOrThrow(machine, event)` → `void`.
- `ConnectionMachine` — re-exported interpreter type alias.
## Implementation Notes
A local `TRANSITIONS` table is consulted before delegating to the FSM's
own dispatcher so the package surface throws an `AUDIO_CONTROLLERS.*`
error rather than the raw `FSM.INVALID_TRANSITION` code. The state
graph deliberately omits transitions out of `error` other than `RESET`
to force callers to explicitly acknowledge recovery — accidental
re-`CONNECT` from `error` would mask the original failure.

View File

@@ -0,0 +1,79 @@
# midi-controller-plugin
## Purpose
The abstract `MidiControllerPlugin` is the universal base class shared
by every concrete MIDI controller integration in `@suaveplan/audio-controllers`.
It owns the lifecycle that every device needs — `requestAccess` through an
injected `MidiAccessAdapter`, port binding, `MIDIMessageEvent` parsing into
typed `control`, `raw`, `note`, `learn`, and `state` events, hot-plug
recovery via the `@suaveplan/fsm` connection machine, declarative CC
mapping, LED batching, and learn-mode capture. Concrete subclasses only
need to declare their `ControllerMetadata` and any device-specific output
helpers.
## Features
- Injectable `MidiAccessAdapter` seam — zero `navigator` references.
- Connection-state FSM with explicit `disconnected/connecting/connected/error`.
- Hot-plug recovery: re-applies cached LED state on reconnect.
- CC mapping DSL with Zod-validated bytes and optional `transform`.
- Learn mode that auto-exits on first capture.
- Incoming notes are timestamped against the transport tick timeline.
- Real OpenTelemetry counters, histograms and spans (no-op without exporter).
- Idempotent `destroy()` that releases ports and clears listeners.
## Usage: Basic
```ts
class MyController extends MidiControllerPlugin {
constructor(opts: MidiControllerPluginOptions) {
super(
{
id: "demo",
name: "Demo",
manufacturer: "Acme",
portNamePatterns: ["Demo"],
},
opts
);
}
}
const c = new MyController({ adapter, transport });
await c.initialize();
```
## Usage: Advanced
```ts
const off = c.on("control", ({ target, value }) => mixer.set(target, value));
c.defineMappings([
{ channel: 0, controller: 7, target: "volume", min: 0, max: 1 },
]);
c.flushLEDs([{ status: 0x90, data1: 60, data2: 100 }]);
await c.destroy();
off();
```
## API Reference
- `requestAccess({ sysex?, signal? })` → `Promise<MidiAccessLike>`.
- `initialize({ signal?, sysex? })` → `Promise<void>`.
- `destroy()` → `Promise<void>` (idempotent).
- `defineMappings(mappings)` / `getMappings()`.
- `flushLEDs(updates)` — batches sends through the bound output.
- `enterLearnMode()` / `exitLearnMode()`.
- `on(event, listener)` / `off(event, listener)`.
- `handleStateChange(event)` — invoked by the adapter on hot-plug events.
- `sequencer` — embedded `Sequencer` bound to the same transport.
## Implementation Notes
The base never imports `navigator` or any Node API; the package is
universal and only ever speaks to the injected adapter and transport.
Notes are scheduled with `transport.scheduleOnce` at a transport-relative
time derived from `transport.toSeconds(transport.ticks)` — the spec's
"timestamped against the tick timeline" guarantee. The FSM is wrapped in a
`sendOrThrow` helper that converts the underlying `INVALID_TRANSITION`
error from `@suaveplan/fsm` into `AUDIO_CONTROLLERS.INVALID_STATE` so
callers see a stable error code surface.

View File

@@ -0,0 +1,65 @@
# otel
## Purpose
Centralises the OpenTelemetry surface consumed by every module in the
package. The `getControllerOtel()` accessor lazily resolves the tracer,
counters, and histograms required by the spec — `controller.midi_message`,
`controller.sequencer_step`, and `controller.connect` spans plus the
`audio_controller_midi_messages_total`,
`audio_controller_sequencer_steps_total`,
`audio_controller_midi_latency_ms`, and
`audio_controller_sequencer_jitter_ms` metrics — without any import-time
side effects. Resolution is deferred until first call so importing the
package never mutates any global state.
## Features
- Lazy, cached resolution of `Tracer`, `Counter` and `Histogram`.
- No-op when no exporter is registered (per the OpenTelemetry contract).
- Single instrument names across the package — keeps dashboards stable.
- Cached across calls to amortise the `getMeter`/`getTracer` lookups.
## Usage: Basic
```ts
import { getControllerOtel } from "@suaveplan/audio-controllers/base/midi-controller-plugin";
const otel = getControllerOtel();
otel.midiMessages.add(1, { controller: "kontrol-s88" });
```
## Usage: Advanced
```ts
import { metrics } from "@opentelemetry/api";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
metrics.setGlobalMeterProvider(new MeterProvider({ readers: [reader] }));
// Subsequent first call from inside the package now records to `reader`.
const otel = getControllerOtel();
const span = otel.tracer.startSpan("controller.connect");
try {
// ...
} finally {
span.end();
}
```
## API Reference
- `getControllerOtel(): ControllerOtel` — lazy singleton accessor.
- `ControllerOtel` — `{ tracer, midiMessages, sequencerSteps,
midiLatencyMs, sequencerJitterMs }`.
## Implementation Notes
The cached singleton is intentionally module-scoped — once an exporter is
registered subsequent calls keep using the same instrument handles, so
dashboards see one continuous time series. Because OTel's `getMeter` and
`getTracer` are themselves idempotent when called with the same name we
could omit the cache, but caching also makes the resolved instruments
observable in test fixtures. Instrument names mirror the broader
`audio_controller_*` naming convention used by every audio package in the
project so cross-cutting dashboards align across packages and
adopters of multiple controllers see consistent metric semantics.

View File

@@ -0,0 +1,73 @@
# bind-parameter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`bindParameter` wires a typed `MidiSource` (a controller CC, note-velocity,
or future hardware source) to a parameter on any `AudioFxPlugin` from
`@suaveplan/audio-fx-plugin-base`. It looks the parameter up via the
plugin's O(1) `parameterIndex`, maps the inbound 0–127 byte into the
declared `[min, max]` range honouring the parameter's `scale`
(`linear` / `log` / `exponential`), and dispatches either `setParameter`
or `setParameterCurve` depending on whether a `smoothingMs` was supplied.
The complement `bindPluginPreset` listens for program-change-style CCs and
applies `PresetSnapshot`s by index.
## Features
- Source-aware mapping: linear, logarithmic, exponential parameter scales.
- Optional `smoothingMs` engages `setParameterCurve` so jittery knobs ramp.
- Unknown `paramId` raises `AUDIO_CONTROLLERS.UNKNOWN_PARAMETER`.
- Unbind returns a `UnsubscribeFn` that removes only this binding —
other bindings on the same source keep firing.
- Preset binding for MIDI program-change-style flows.
## Usage: Basic
```ts
const off = bindParameter(eq, "lowShelfGain", { type: "cc", channel: 0, number: 7 }, {
controller,
});
// ...
off();
```
## Usage: Advanced
```ts
const off = bindParameter(
eq,
"lowShelfGain",
{ type: "cc", channel: 0, number: 7 },
{ controller, smoothingMs: 20 }
);
const offPreset = bindPluginPreset(
eq,
{ type: "cc", channel: 0, number: 32 },
presetSnapshots,
{ controller }
);
```
## API Reference
- `bindParameter(plugin, paramId, source, { controller, smoothingMs? })`
→ `UnsubscribeFn`.
- `bindPluginPreset(plugin, source, presets, { controller })` →
`UnsubscribeFn`.
- `MidiSource` — union of `cc` and `note-velocity` shapes.
- `UnsubscribeFn` — `() => void`.
## Implementation Notes
The binding subscribes to the controller's `raw` event so it can observe
even messages that have no `defineMappings` entry. A `(channel, controller)`
filter inside the handler keeps the dispatch O(1). When the parameter
declares `scale: "log"` the mapping does `min * (max/min)^t`, falling back
to `1e-6` if `min === 0` to avoid a division by zero. Preset application
delegates to `plugin.applyPreset`, which performs Zod validation itself —
any failure propagates unchanged.

View File

@@ -0,0 +1,72 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the canonical `AUDIO_CONTROLLERS.*` error-code registry for the
`@suaveplan/audio-controllers` package. Every error thrown anywhere inside
the package — from the abstract `MidiControllerPlugin`, the connection-state
FSM, the sequencer, the arpeggiator, the learn-mode helper, the binding
helpers, or any concrete device subclass — funnels through the
`AudioControllersError` class declared here with one of the registered
codes. Centralising the registry guarantees a single, audit-able vocabulary
and a stable wire shape for downstream telemetry.
## Features
- `defineCodes("AUDIO_CONTROLLERS", […])` produces dotted codes such as
`AUDIO_CONTROLLERS.ACCESS_DENIED` and `AUDIO_CONTROLLERS.DEVICE_ERROR`.
- `AUDIO_CONTROLLERS_ERROR_METADATA` is a frozen description + severity
table consulted by both error formatting and the conformance probe.
- `AudioControllersError` extends `@suaveplan/error`'s `SuaveplanError` and
carries a `category = "audio-controllers"` discriminator.
- Idempotent `registerAudioControllersErrorCodes()` for explicit bootstrap.
## Usage: Basic
```ts
import {
AUDIO_CONTROLLERS_CODES,
AudioControllersError,
} from "@suaveplan/audio-controllers";
throw new AudioControllersError(
AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
"kontrol.key-index",
{ context: { keyIndex: 999 } }
);
```
## Usage: Advanced
```ts
import {
AUDIO_CONTROLLERS_ERROR_METADATA,
AudioControllersError,
} from "@suaveplan/audio-controllers";
function severity(err: unknown): "warning" | "error" | undefined {
if (!(err instanceof AudioControllersError)) return undefined;
return AUDIO_CONTROLLERS_ERROR_METADATA[err.code]?.severity;
}
```
## API Reference
- `AUDIO_CONTROLLERS_CODES` — frozen record of code constants.
- `AudioControllersErrorCode` — TypeScript union of every code value.
- `AUDIO_CONTROLLERS_ERROR_METADATA[code]` — `{ description, severity }`.
- `AudioControllersError` — extends `SuaveplanError<AudioControllersErrorCode>`.
- `registerAudioControllersErrorCodes()` — idempotent bootstrap hook.
## Implementation Notes
Codes are defined as a tuple in a single `as const` array so the public
type union stays exhaustive — adding a code requires updating the metadata
table or TypeScript fails. The metadata object is `Object.freeze`d to
prevent mutation in production code. The `name` property of every error
instance includes the metadata severity so log aggregators can fan out
warnings vs. errors without parsing the code string.

View File

@@ -0,0 +1,61 @@
# learn-mode
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Functional helper around the `MidiControllerPlugin.enterLearnMode` /
`exitLearnMode` toggle. The base class natively swallows the next
unmapped CC message and emits a `learn` event when in learn mode; this
module wraps that flow into a promise so application code can simply
`await captureNextLearn(controller)` to get the captured
`(channel, controller)` pair. It also exposes a cancellation handle for
UI flows that need to abort the capture (user closes the dialog,
controller disconnects, etc.).
## Features
- Promise-based capture of the next CC message.
- `cancel()` releases the subscription without exiting learn mode, so
callers can compose multiple captures.
- Zero new state on the plugin — uses the existing `on('learn', ...)`
surface and `enterLearnMode()` toggle.
## Usage: Basic
```ts
const { channel, controller: cc } = await captureNextLearn(controller);
console.log("Mapped to channel", channel, "CC", cc);
```
## Usage: Advanced
```ts
const capture = captureNextLearn(controller);
const timeout = setTimeout(() => capture.cancel(), 5000);
try {
const { channel, controller: cc } = await capture;
controller.defineMappings([
{ channel, controller: cc, target: "volume", min: 0, max: 1 },
]);
} finally {
clearTimeout(timeout);
}
```
## API Reference
- `captureNextLearn(plugin)` → `Promise<LearnResult> & { cancel(): void }`.
- `LearnResult` — `{ channel: number, controller: number }`.
## Implementation Notes
The promise resolves at most once. After the first `learn` emission the
internal subscription is released so the resolver cannot fire twice; the
base class also auto-exits learn mode on first capture, layering defence
in depth. `cancel()` simply unsubscribes the listener, but intentionally
does not call `exitLearnMode()` — that's the caller's policy choice if
the UI hand-off requires keeping learn-mode armed across multiple
controls in the same session for batch learn-mode capture workflows.

View File

@@ -0,0 +1,68 @@
# kontrol-s88
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`KontrolS88Plugin` is the concrete `MidiControllerPlugin` subclass for
Native Instruments Kontrol S88 MK2/MK3 — an 88-key weighted MIDI
keyboard with pitch-bend, mod-wheel, RGB key LEDs, scale modes, and
arpeggio routing. It inherits the entire MIDI lifecycle (adapter-driven
`requestAccess`, port binding, CC mapping, learn mode, FSM, OTel) from
the base and only adds device-specific surface: `setScale`, `setArpMode`,
`setPitchBend`, `setModWheel`, and `sendLED`.
## Features
- Scale modes: chromatic, major, minor, dorian, mixolydian, pentatonic, blues.
- Arpeggiator modes: up, down, updown, random.
- Pitch-bend `[-1, 1]` mapped to 14-bit MIDI pitch-bend message.
- Mod-wheel `[0, 1]` mapped to CC 1.
- Per-key RGB LED feedback via the inherited output port.
- Range-checked device arguments → `AUDIO_CONTROLLERS.DEVICE_ERROR`.
## Usage: Basic
```ts
const kontrol = new KontrolS88Plugin({ adapter, transport });
await kontrol.initialize();
kontrol.setScale("major");
kontrol.sendLED(0, { red: 1, green: 0, blue: 0 });
```
## Usage: Advanced
```ts
const kontrol = new KontrolS88Plugin({ adapter, transport, logger });
await kontrol.initialize({ sysex: true });
kontrol.defineMappings([
{ channel: 0, controller: 0x10, target: "filter.cutoff", min: 20, max: 20000 },
]);
kontrol.setArpMode("updown");
kontrol.setPitchBend(0.5);
kontrol.setModWheel(0.25);
for (let key = 0; key < 88; key++) {
kontrol.sendLED(key, { red: key / 88, green: 0, blue: 1 - key / 88 });
}
```
## API Reference
- `new KontrolS88Plugin(options)`.
- `setScale(mode)` / `getScale()`.
- `setArpMode(mode)` / `getArpMode()`.
- `setPitchBend(value)` / `getPitchBend()`.
- `setModWheel(value)` / `getModWheel()`.
- `sendLED(keyIndex, color)` — key index `[0, 87]`.
## Implementation Notes
Key index 0 corresponds to MIDI note 21 (A0); the helper translates the
input into a note-on message addressed to that key. Pitch-bend is encoded
as the standard 14-bit MIDI value (`status 0xE0`, two 7-bit data bytes).
All output paths funnel through the inherited `sendOutput` helper, which
raises `AUDIO_CONTROLLERS.NOT_CONNECTED` if no `MIDIOutput` is bound. The
declared `portNamePatterns` (`KOMPLETE KONTROL`, `S88`) cover both MK2 and
MK3 hardware revisions reported by the OS-level MIDI driver.

View File

@@ -0,0 +1,70 @@
# maschine
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`MaschinePlugin` is the concrete `MidiControllerPlugin` subclass for
Native Instruments Maschine: a 16-pad grid with per-pad velocity, a
16-step sequencer row per pad, and slot-based sample loading. It inherits
the universal MIDI lifecycle from the base and adds the device-specific
surface declared by the spec: `setPadVelocity`, `setStep`, and
`loadSample`. Sample loading is delegated to an injectable
`SampleLoader` so the package stays universal and side-effect-free.
## Features
- 16 pads × 16 steps internal grid.
- Per-pad velocity table (0–127) with `MIDIOutput` echo.
- `loadSample(slot, id, { signal })` honours `AbortSignal` both before
and after the loader resolves.
- Range-checked pad index, step index, velocity, and slot index.
- Sample registry exposed via `getSample(slotIndex)`.
## Usage: Basic
```ts
const maschine = new MaschinePlugin({ adapter, transport });
await maschine.initialize();
maschine.setPadVelocity(3, 96);
maschine.setStep(3, 0, true);
await maschine.loadSample(3, "kick-808");
```
## Usage: Advanced
```ts
const ctl = new AbortController();
const maschine = new MaschinePlugin({
adapter,
transport,
sampleLoader: async (id, signal) => loadFromDrive(id, signal),
});
await maschine.initialize({ signal: ctl.signal });
maschine.setPadVelocity(0, 127);
maschine.setStep(0, 0, true);
maschine.setStep(0, 4, true);
maschine.setStep(0, 8, true);
maschine.setStep(0, 12, true);
await maschine.loadSample(0, "kick-808", { signal: ctl.signal });
```
## API Reference
- `new MaschinePlugin({ adapter, transport, sampleLoader? })`.
- `setPadVelocity(padIndex, velocity)` / `getPadVelocity(padIndex)`.
- `setStep(padIndex, stepIndex, active)` / `getStep(padIndex, stepIndex)`.
- `loadSample(slotIndex, sampleId, { signal? })` → `Promise<void>`.
- `getSample(slotIndex)` → `string | undefined`.
## Implementation Notes
`loadSample` performs a pre-await abort check, awaits the loader, then
re-checks the signal at the next await boundary — the spec's
"mid-operation abort" guarantee. Any failure inside the loader is
re-thrown as `AUDIO_CONTROLLERS.DEVICE_ERROR` carrying the original error
as `cause`, so consumers can introspect transport-layer failures without
re-implementing error mapping. The default loader is a no-op suitable for
unit-test scenarios; production callers wire in their own.

View File

@@ -0,0 +1,70 @@
# traktor-s4
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`TraktorS4Plugin` is the concrete `MidiControllerPlugin` subclass for the
Native Instruments Traktor Kontrol S4 MK3 — a 4-channel DJ controller with
four decks, four channel volume faders, and a crossfader. It inherits the
universal MIDI lifecycle (adapter-driven `requestAccess`, CC mapping,
hot-plug FSM, learn mode, OTel) from the base and only declares the
device-specific surface required by the spec: `loadDeck`, `setCrossfader`,
and `setChannelVolume`. Deck-sync against transport tempo happens by way
of the inherited `transport.bpm` signal.
## Features
- Four decks with per-deck `DeckTrack` records (id, title, bpm, duration).
- Per-channel volume in `[0, 1]` with output echo to the device fader LED.
- Crossfader in `[0, 1]` mapped to CC `0x08`.
- Range-checked deck index and channel index.
## Usage: Basic
```ts
const traktor = new TraktorS4Plugin({ adapter, transport });
await traktor.initialize();
traktor.loadDeck(0, { id: "track-a" });
traktor.setCrossfader(0.5);
traktor.setChannelVolume(0, 0.8);
```
## Usage: Advanced
```ts
const traktor = new TraktorS4Plugin({ adapter, transport });
await traktor.initialize();
traktor.loadDeck(0, { id: "a", title: "Side A", bpm: 124 });
traktor.loadDeck(1, { id: "b", title: "Side B", bpm: 124 });
traktor.defineMappings([
{ channel: 0, controller: 0x08, target: "crossfader", min: 0, max: 1 },
]);
traktor.on("control", ({ target, value }) =>
log.debug({ target, value }, "traktor.cc")
);
traktor.setChannelVolume(0, 1);
traktor.setChannelVolume(1, 0.5);
traktor.setCrossfader(0.5);
```
## API Reference
- `new TraktorS4Plugin(options)`.
- `loadDeck(deckIndex, track)` / `getDeck(deckIndex)`.
- `setCrossfader(value)` / `getCrossfader()`.
- `setChannelVolume(channel, value)` / `getChannelVolume(channel)`.
## Implementation Notes
`loadDeck` writes a confirmation note-on to the device so the on-deck LED
lights up when a track is staged. Crossfader and per-channel volume are
clamped at the boundary — values outside `[0, 1]` raise
`AUDIO_CONTROLLERS.DEVICE_ERROR` with the offending value in `context`.
The internal `volumes` buffer is a `Float32Array` to keep the read path
allocation-free in hot loops, even though the public API is plain numbers.

View File

@@ -0,0 +1,65 @@
# sequencer
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`Sequencer` is the transport-synced step engine consumed by every
`MidiControllerPlugin`. It schedules every active step through
`Transport.scheduleRepeat`, never `setInterval` or `setTimeout`, so step
playback is sample-accurate relative to the project transport and renders
identically offline. The class exposes the spec-required surface —
`setTempo`, `setSteps`, `setStep`, `start`, `stop`, `reset`, `onStep` —
and serves as the shared sequencing primitive behind Maschine's step
sequencer and Kontrol S88's arpeggiator routing.
## Features
- Sample-accurate scheduling via `@suaveplan/audio-transport`.
- Per-index `onStep` listener registry with unsubscribe handles.
- `setTempo` mirrors the bpm onto the transport's `bpm` signal so other
consumers of the transport see the change.
- Resilient cursor logic: `reset()` returns the cursor to 0 without
stopping the transport.
## Usage: Basic
```ts
const seq = new Sequencer({ transport });
seq.setSteps([true, false, true, false]);
seq.onStep(0, () => synth.trigger("kick"));
seq.start();
```
## Usage: Advanced
```ts
const seq = new Sequencer({ transport, stepDuration: "8n" });
seq.setSteps(Array.from({ length: 16 }, (_, i) => i % 4 === 0));
const off = seq.onStep(4, (time) => log.debug({ time }, "snare"));
seq.setTempo(140);
seq.start();
// ...
seq.stop();
off();
seq.reset();
```
## API Reference
- `new Sequencer({ transport, stepDuration? })`.
- `setTempo(bpm)` / `getTempo()`.
- `setSteps(steps)` / `setStep(index, active)` / `getSteps()`.
- `start()` / `stop()` / `reset()`.
- `onStep(index, callback)` → unsubscribe function.
## Implementation Notes
The repeat callback is registered once and reused — `start()` is a no-op
when called twice. `setTempo` writes directly into the
`transport.bpm` signal from `@suaveplan/audio-transport`; that's
deliberate, so an automation lane sweeping bpm continues to drive the
sequencer without any extra wiring. The cursor wraps modulo the step
count so an empty step list still ticks without firing any callback.

View File

@@ -0,0 +1,101 @@
# @suaveplan/audio-core - Universal audio data model and processing primitives: paged AudioDocument, non-destructive PlanEngine, block-level ProcessPipeline, and typed OpRegistry. Zero browser or Node dependencies.
Universal audio data model and processing primitives. Provides a paged `AudioDocument`, a non-destructive `PlanEngine`, a block-level `ProcessPipeline`, and a typed `OpRegistry` — all with zero browser or Node.js dependencies.
<!-- begin:auto-docs -->
## Documentation tree
- [analysis](./src/analysis/analysis-op.md) — `AnalysisOp` is the operator contract every `@suaveplan/audio-analysis-*` package implements: spectrum, beat-tracking,…
- [analysis-op](./src/analysis/analysis-op.md) — `AnalysisOp` is the operator contract every `@suaveplan/audio-analysis-*` package implements: spectrum, beat-tracking,…
- [document](./src/document/document.md) — `AudioDocument` is the primary paged audio document model in `@suaveplan/audio-core`. It represents a multi-channel aud…
- [document](./src/document/document.md) — `AudioDocument` is the primary paged audio document model in `@suaveplan/audio-core`. It represents a multi-channel aud…
- [error-codes](./src/error-codes/error-codes.md) — This module centralises every error code, metadata description, and severity level that the `@suaveplan/audio-core` pac…
- [error-codes](./src/error-codes/error-codes.md) — This module centralises every error code, metadata description, and severity level that the `@suaveplan/audio-core` pac…
- [page](./src/page/page.md) — This module provides the low-level building blocks for memory-efficient audio storage and retrieval inside an `AudioDoc…
- [page](./src/page/page.md) — This module provides the low-level building blocks for memory-efficient audio storage and retrieval inside an `AudioDoc…
- [plan-engine](./src/plan/plan-engine.md) — The `PlanEngine` class is the central coordinator for turning an `AudioDocument` edit list into an executable audio pla…
- [segment](./src/plan/segment.md) — The segment helpers provide low-level geometric operations on `Segment` objects. A segment represents a contiguous mapp…
- [block-processor](./src/process/block-processor.md) — `BlockProcessor` defines the standard lifecycle contract for audio processors that operate on fixed-size blocks of mult…
- [process-pipeline](./src/process/process-pipeline.md) — `ProcessPipeline` executes a chain of audio editing operations on blocks of multi-channel PCM data. It bridges the decl…
- [registry](./src/registry/op-registry.md) — `OpRegistry` is the central, in-memory catalog for all audio operations in `@suaveplan/audio-core`. It maps unique stri…
- [crop](./src/registry/built-in/crop.md) — `registerCrop` adds the `crop` operation to an `OpRegistry`, making it available to the planning engine. The crop opera…
- [dither](./src/registry/built-in/dither.md) — `registerDither` installs the `dither` processing operation into an `OpRegistry`. Dithering adds a small amount of pseu…
- [fade](./src/registry/built-in/fade.md) — `registerFade` adds the `fade` operation to an `OpRegistry`. Fade applies a linear amplitude ramp across an entire proc…
- [gain](./src/registry/built-in/gain.md) — `registerGain` installs the `gain` operation into an `OpRegistry`. It is the simplest block-level processor: every samp…
- [insert](./src/registry/built-in/insert.md) — `registerInsert` adds the `insert` operation to an `OpRegistry`. Insert is a planning-stage edit that introduces silenc…
- [mix](./src/registry/built-in/mix.md) — `registerMix` installs the `mix` operation into an `OpRegistry`. The mix processor adds a scaled copy of the input sign…
- [normalize](./src/registry/built-in/normalize.md) — `registerNormalize` adds the `normalize` operation to an `OpRegistry`. Normalization scans an entire processing block t…
- [pad](./src/registry/built-in/pad.md) — `registerPad` installs the `pad` operation into an `OpRegistry`. Pad is a planning function that extends the total time…
- [pan](./src/registry/built-in/pan.md) — `registerPan` adds the `pan` operation to an `OpRegistry`. Pan is a block-level processor that adjusts the relative lou…
- [remove](./src/registry/built-in/remove.md) — `registerRemove` installs the `remove` operation into an `OpRegistry`. Remove is a planning function that deletes sampl…
- [repeat](./src/registry/built-in/repeat.md) — `registerRepeat` adds the `repeat` operation to an `OpRegistry`. Repeat is a planning function that duplicates the enti…
- [resample](./src/registry/built-in/resample.md) — `registerResample` installs the `resample` operation into an `OpRegistry`. Resample is a planning function that changes…
- [reverse](./src/registry/built-in/reverse.md) — `registerReverse` adds the `reverse` operation to an `OpRegistry`. Reverse is a planning function that flips the order…
- [speed](./src/registry/built-in/speed.md) — `registerSpeed` installs the `speed` operation into an `OpRegistry`. Speed is a planning function that changes playback…
- [stretch](./src/registry/built-in/stretch.md) — `registerStretch` adds the `stretch` operation to an `OpRegistry`. Stretch is a planning function that changes the dura…
- [transform](./src/registry/built-in/transform.md) — `registerTransform` installs the `transform` operation into an `OpRegistry`. Transform is a block-level processor that…
- [trim](./src/registry/built-in/trim.md) — `registerTrim` installs the `trim` operation into an `OpRegistry`. Trim is a planning function that shortens the audio…
- [op-registry](./src/registry/op-registry.md) — `OpRegistry` is the central, in-memory catalog for all audio operations in `@suaveplan/audio-core`. It maps unique stri…
- [render](./src/render/render.md) — The render module bridges the compiled plan produced by `PlanEngine` and the paged sample storage of an `AudioDocument`…
- [render](./src/render/render.md) — The render module bridges the compiled plan produced by `PlanEngine` and the paged sample storage of an `AudioDocument`…
- [stats](./src/stats/stats.md) — This module computes standard audio statistics across paged channel data. By scanning every sample in every page, it pr…
- [stats](./src/stats/stats.md) — This module computes standard audio statistics across paged channel data. By scanning every sample in every page, it pr…
- [stream](./src/stream/stream.md) — This module adapts the `@suaveplan/audio-core` processing pipeline to the Web Streams standard. It exports a factory fu…
- [stream](./src/stream/stream.md) — This module adapts the `@suaveplan/audio-core` processing pipeline to the Web Streams standard. It exports a factory fu…
- [telemetry](./src/telemetry/telemetry.md) — The telemetry module is the single OpenTelemetry integration point for `@suaveplan/audio-core`. It exposes thin, lazy h…
- [telemetry](./src/telemetry/telemetry.md) — The telemetry module is the single OpenTelemetry integration point for `@suaveplan/audio-core`. It exposes thin, lazy h…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/audio-core
```
## Quick start
```ts
import { AudioDocument, OpRegistry, PlanEngine } from "@suaveplan/audio-core";
const doc = new AudioDocument({
sampleRate: 48000,
channels: 2,
sourceLength: 48000,
pages: [
{
startSample: 0,
channelData: [new Float32Array(48000), new Float32Array(48000)],
},
],
edits: [],
});
// Apply a non-destructive trim edit
doc.run({ type: "trim", opts: { start: 1000, end: 47000 } });
// Compile the plan
const registry = OpRegistry.createWithDefaults();
const engine = new PlanEngine(registry);
const plan = engine.compile(doc, true);
// Stream the result
for await (const block of doc.stream()) {
// block is Float32Array[] — one per channel
}
```
## Modules
- [`src/document/document.md`](src/document/document.md) — `AudioDocument` class and JSON round-trip
- [`src/page/page.md`](src/page/page.md) — Page primitives: `walkPages`, `copyPages`, `readPages`, allocation pool
- [`src/plan/plan-engine.md`](src/plan/plan-engine.md) — `PlanEngine` for compiling and resolving edits
- [`src/plan/segment.md`](src/plan/segment.md) — Segment overlap, merge, and split helpers
- [`src/process/process-pipeline.md`](src/process/process-pipeline.md) — `ProcessPipeline` for block-level DSP
- [`src/process/block-processor.md`](src/process/block-processor.md) — `BlockProcessor` interface and `NoopBlockProcessor`
- [`src/registry/op-registry.md`](src/registry/op-registry.md) — `OpRegistry` for registering plan and process ops
- [`src/registry/built-in/`](src/registry/built-in/) — Built-in operations: trim, crop, speed, stretch, reverse, insert, remove, repeat, pad, resample, gain, normalize, fade, pan, mix, dither, transform
- [`src/render/render.md`](src/render/render.md) — `renderBlock` for copying source segments into output buffers
- [`src/stats/stats.md`](src/stats/stats.md) — `computeStats` for peak, RMS, and DC offset
- [`src/stream/stream.md`](src/stream/stream.md) — `createPlanTransformStream` for downstream transforms
- [`src/error-codes.md`](src/error-codes.md) — Error codes and `AudioError` class

Some files were not shown because too many files have changed in this diff Show More