Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
36
plugins/document-navigator/docs/media/audio/README.md
Normal file
36
plugins/document-navigator/docs/media/audio/README.md
Normal 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 -->
|
||||
1
plugins/document-navigator/docs/media/audio/_intro.md
Normal file
1
plugins/document-navigator/docs/media/audio/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
Audio processing, playback, analysis, and effect plugins.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
104
plugins/document-navigator/docs/media/audio/audio-cli/README.md
Normal file
104
plugins/document-navigator/docs/media/audio/audio-cli/README.md
Normal 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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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()`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
@@ -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`.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
101
plugins/document-navigator/docs/media/audio/audio-core/README.md
Normal file
101
plugins/document-navigator/docs/media/audio/audio-core/README.md
Normal 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
Reference in New Issue
Block a user