Files
suaveplan/openspec/specs/audio-analysis-loudness/spec.md
2026-05-27 23:29:53 +01:00

248 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- markdownlint-disable MD024 -->
# Capability Spec: audio-analysis-loudness
**Package**: `@suaveplan/audio-analysis-loudness`
**Status**: SHIPPED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## ADDED Requirements
### Requirement: Universal-tier purity
The `@suaveplan/audio-analysis-loudness` package SHALL import zero browser-only APIs (`window`, `document`, `navigator`, `AudioContext`, `AudioBuffer`, `AnalyserNode`) and zero Node-only APIs (`node:fs`, `node:path`, `process`, `Buffer`). All loudness measurement SHALL operate on `Float32Array[]` PCM obtained from an `AudioDocument` through `@suaveplan/audio-core` read/stream APIs, with no platform-specific code paths. The package's `dependencies` SHALL be limited to `@suaveplan/audio-core`, `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/opentelemetry`, `tslib`, and `zod`.
#### Scenario: Built dist references no platform globals
- **WHEN** the built `dist/` is grep'd
- **THEN** it MUST NOT reference `window`, `document`, `navigator`, `AudioContext`, `AnalyserNode`, `node:fs`, `node:path`, `process`, or `Buffer`
#### Scenario: Tests pass under both runtimes
- **WHEN** the suite runs under `bun test` (server) and under vitest with a `happy-dom` shim (browser)
- **THEN** both runs pass with identical results
**Verified by:** `tests/purity.test.ts::"dist references no browser or Node globals"` AND `tests/purity.test.ts::"dependency set is limited to the declared workspace and zod"`
---
### Requirement: LoudnessMeter implements the AnalysisOp contract
The package SHALL export a `LoudnessMeter` class implementing the `AnalysisOp` interface defined by `@suaveplan/audio-core` (plugin slot 3 — Analysis Op). It SHALL expose a readonly `name` of `"loudness-meter"` and a readonly `description` of `"ITU-R BS.1770-4 loudness measurement"`, and SHALL implement `analyze(doc, opts?)`, `analyzeStream(doc, opts?)`, and `analyzeBlock(block, ctx)`. The meter SHALL be a sidecar processor — it SHALL NOT register a plan or process op and SHALL NOT participate in the render pipeline.
#### Scenario: Meter is shaped as an AnalysisOp
- **WHEN** a caller constructs `new LoudnessMeter()`
- **THEN** `meter.name === "loudness-meter"`, `meter.description` is the BS.1770-4 string, and `analyze`, `analyzeStream`, and `analyzeBlock` are callable functions
#### Scenario: analyze resolves a LoudnessResult for a full document
- **WHEN** a caller `await meter.analyze(doc)` on a populated `AudioDocument`
- **THEN** the resolved value carries `op === "loudness-meter"`, a numeric `lufs`, `loudnessRange`, `truePeak`, `rms`, `peak`, and a `dcOffset` array of one entry per channel
**Verified by:** `src/meter/loudness-meter.test.ts::"exposes the AnalysisOp name and description"` AND `src/meter/loudness-meter.test.ts::"analyze resolves a fully populated LoudnessResult"`
---
### Requirement: ITU-R BS.1770-4 integrated loudness
`LoudnessMeter.analyze` SHALL compute integrated loudness (LUFS) per ITU-R BS.1770-4: each channel SHALL be passed through the K-weighting filter (stage 1 high-shelf pre-filter, stage 2 high-pass RLB filter), mean-square energy SHALL be accumulated over 400 ms gating blocks overlapped by 75%, channel energies SHALL be summed with the BS.1770 channel weights, the absolute gate SHALL discard blocks below −70 LUFS, and the relative gate SHALL discard blocks more than 10 LU below the ungated mean before the final integration. Momentary loudness SHALL use a 400 ms window and short-term loudness a 3 s window.
#### Scenario: Reference signal yields the BS.1770 expected loudness
- **WHEN** `analyze` runs on a −20 dBFS 1 kHz sine (mono, ≥ 3 s)
- **THEN** `result.lufs` is within ±0.1 LU of the BS.1770 reference value for that signal
#### Scenario: Quiet blocks are excluded by the gates
- **WHEN** `analyze` runs on a signal that is loud for 3 s then silent for 30 s
- **THEN** `result.lufs` reflects only the loud segment because the silent gating blocks fall below the absolute and relative gates
**Verified by:** `src/algorithms/lufs.test.ts::"integrated loudness matches the BS.1770 reference within tolerance"` AND `src/algorithms/lufs.test.ts::"absolute and relative gates discard quiet blocks"`
---
### Requirement: Loudness range per EBU Tech 3342
`LoudnessResult.loudnessRange` SHALL be the loudness range (LRA) in LU computed per EBU Tech 3342: short-term loudness values SHALL be collected over 3 s windows with 1 s steps, an absolute gate at −70 LUFS and a relative gate 20 LU below the gated mean SHALL be applied, and the LRA SHALL be the difference between the 95th and 10th percentiles of the surviving distribution. A document with fewer than one full short-term window SHALL report `loudnessRange` as `0`.
#### Scenario: Dynamic signal reports a positive loudness range
- **WHEN** `analyze` runs on a signal alternating between loud and quiet passages
- **THEN** `result.loudnessRange` is greater than `0` and equals the 95th-minus-10th percentile spread of the gated short-term distribution
#### Scenario: Too-short document reports zero range
- **WHEN** `analyze` runs on a document shorter than one 3 s short-term window
- **THEN** `result.loudnessRange === 0`
**Verified by:** `src/algorithms/loudness-range.test.ts::"reports the percentile spread for a dynamic signal"` AND `src/algorithms/loudness-range.test.ts::"reports zero range for a sub-window document"`
---
### Requirement: True-peak detection via 4x oversampling
`LoudnessResult.truePeak` SHALL be the true-peak level in dBTP measured by 4x oversampling each channel through a polyphase FIR interpolation filter and taking the maximum absolute interpolated sample across all channels, per ITU-R BS.1770-4 Annex 2. `truePeak` SHALL always be greater than or equal to the sample `peak` for the same signal, because inter-sample peaks can exceed sample maxima.
#### Scenario: True peak is at least the sample peak
- **WHEN** `analyze` runs on any non-silent signal
- **THEN** `result.truePeak >= result.peak`
#### Scenario: Inter-sample peak is detected above the sample maximum
- **WHEN** `analyze` runs on a signal crafted so the true waveform peak falls between samples (e.g. a 0 dBFS sine near Nyquist/4)
- **THEN** `result.truePeak` exceeds `result.peak` by a measurable margin
**Verified by:** `src/algorithms/true-peak.test.ts::"true peak is never below the sample peak"` AND `src/algorithms/true-peak.test.ts::"detects an inter-sample peak above the sample maximum"`
---
### Requirement: RMS, peak, and per-channel DC offset
`LoudnessResult` SHALL carry `rms` (root-mean-square level in dB across all channels), `peak` (maximum absolute sample expressed in dB), and `dcOffset` (an array holding the mean sample value of each channel). For a full-scale signal `peak` SHALL be `0` dB; for digital silence `rms` SHALL be reported as a finite floor value rather than `-Infinity` and `lufs` SHALL be reported as the BS.1770 silence floor without throwing.
#### Scenario: Known signal reports expected RMS, peak, and DC offset
- **WHEN** `analyze` runs on a unity-amplitude sine with no DC component
- **THEN** `result.peak` is `0` dB, `result.rms` is approximately `-3.01` dB, and every entry of `result.dcOffset` is approximately `0`
#### Scenario: Silence is handled gracefully
- **WHEN** `analyze` runs on a fully silent document
- **THEN** the call resolves without throwing, `result.lufs` is the BS.1770 silence floor, and `result.rms` is a finite floor value
**Verified by:** `src/meter/loudness-meter.test.ts::"reports expected RMS, peak, and DC offset for a known signal"` AND `src/meter/loudness-meter.test.ts::"handles silence without throwing or returning -Infinity"`
---
### Requirement: Streaming integrated loudness
`LoudnessMeter.analyzeStream` SHALL be an async generator that consumes `AudioDocument` blocks via `@suaveplan/audio-core` streaming and yields a `LoudnessResult` after each gating block, where each yielded result's `lufs` is the integrated loudness of all audio seen so far. The running gate state SHALL be carried across yields so the final yielded result equals what `analyze` would return for the whole document. The generator SHALL accept an `AbortSignal` via its options object.
#### Scenario: Final streamed result equals the batch result
- **WHEN** a caller fully drains `analyzeStream(doc)` and also calls `analyze(doc)` on the same document
- **THEN** the last yielded result's `lufs` equals the batch `analyze` result's `lufs` within floating-point tolerance
#### Scenario: Running loudness refines as blocks arrive
- **WHEN** a caller reads the first two yields of `analyzeStream` on a multi-block document
- **THEN** each yield carries an integrated `lufs` computed over strictly more audio than the previous yield
**Verified by:** `src/meter/loudness-meter.test.ts::"final streamed result equals the batch analyze result"` AND `src/meter/loudness-meter.test.ts::"streamed integrated loudness refines as blocks arrive"`
---
### Requirement: Block-level momentary metering
`LoudnessMeter.analyzeBlock(block, ctx)` SHALL compute momentary loudness for a single `Float32Array[]` block using the `BlockContext` sample rate and channel count, returning a `Partial<LoudnessResult>` carrying at least `lufs` (momentary), `peak`, and `rms` for that block. `analyzeBlock` SHALL be synchronous, allocation-light, and safe to call on every audio callback for real-time meters; it SHALL NOT mutate the input block.
#### Scenario: analyzeBlock returns momentary loudness for one block
- **WHEN** a caller calls `analyzeBlock` with a 400 ms block of a known-level signal
- **THEN** the returned partial carries a `lufs` value within tolerance of the BS.1770 momentary loudness for that block and leaves the input arrays unmodified
**Verified by:** `src/meter/loudness-meter.test.ts::"analyzeBlock returns momentary loudness without mutating the input"`
---
### Requirement: K-weighting filter
The package SHALL export a K-weighting filter implementation comprising the ITU-R BS.1770 two-stage biquad cascade (stage 1: high-shelf "pre-filter" modelling the acoustic effect of the head; stage 2: RLB high-pass filter). The filter coefficients SHALL be derived for the document's sample rate so measurement is correct at 44.1 kHz, 48 kHz, and other rates, and the filter SHALL maintain per-channel state so it can be applied incrementally to streamed blocks.
#### Scenario: Filter coefficients adapt to the sample rate
- **WHEN** the K-weighting filter is built for 44100 Hz and again for 48000 Hz
- **THEN** the two coefficient sets differ and each reproduces the BS.1770 reference magnitude response for its rate within tolerance
#### Scenario: Incremental filtering matches whole-buffer filtering
- **WHEN** a signal is filtered in one pass and again block-by-block through the stateful filter
- **THEN** the two filtered outputs are sample-equal within floating-point tolerance
**Verified by:** `src/filters/k-weighting.test.ts::"derives rate-specific coefficients matching the BS.1770 response"` AND `src/filters/k-weighting.test.ts::"incremental block filtering equals whole-buffer filtering"`
---
### Requirement: Zod boundary validation
Every public options object — `LoudnessMeterOptions` — SHALL have a Zod schema exported from a sibling `*.schemas.ts`, and every public entry point (`analyze`, `analyzeStream`, the `LoudnessMeter` constructor) SHALL validate its options argument against the schema before use. A validation failure SHALL throw `AudioAnalysisLoudnessError` with code `AUDIO_ANALYSIS_LOUDNESS.INVALID_OPTIONS` carrying the Zod issue list in `context`.
#### Scenario: Invalid options are rejected at the boundary
- **WHEN** `new LoudnessMeter({ overlap: 2 })` is constructed with an out-of-range `overlap`
- **THEN** it throws `AudioAnalysisLoudnessError` whose `code === "AUDIO_ANALYSIS_LOUDNESS.INVALID_OPTIONS"` with the Zod issues in `context`
**Verified by:** `src/meter/loudness-meter.test.ts::"constructor rejects invalid options with AUDIO_ANALYSIS_LOUDNESS.INVALID_OPTIONS"`
---
### Requirement: Structured error codes
The package SHALL export `AudioAnalysisLoudnessErrorCode`, `AUDIO_ANALYSIS_LOUDNESS_ERROR_METADATA`, and `registerAudioAnalysisLoudnessErrorCodes()`, registering the codes `AUDIO_ANALYSIS_LOUDNESS.INVALID_OPTIONS`, `AUDIO_ANALYSIS_LOUDNESS.EMPTY_DOCUMENT`, and `AUDIO_ANALYSIS_LOUDNESS.ABORTED` with `@suaveplan/error`. Every error thrown by the package SHALL be an `AudioAnalysisLoudnessError` extending `SuaveplanError` and carry one of these codes, `cause`, and `context`. Every registered code SHALL be reachable from a real call site exercised by tests: `AUDIO_ANALYSIS_LOUDNESS.INVALID_OPTIONS` from Zod validation, `AUDIO_ANALYSIS_LOUDNESS.EMPTY_DOCUMENT` from `analyze` on a zero-length document, and `AUDIO_ANALYSIS_LOUDNESS.ABORTED` from a pre-aborted or mid-stream-aborted signal.
#### Scenario: Empty document is rejected
- **WHEN** `analyze` is called on an `AudioDocument` with `sourceLength === 0`
- **THEN** it throws `AudioAnalysisLoudnessError` whose `code === "AUDIO_ANALYSIS_LOUDNESS.EMPTY_DOCUMENT"`
#### Scenario: Every registered code is thrown by a call site
- **WHEN** the test suite runs
- **THEN** each code in `AUDIO_ANALYSIS_LOUDNESS_ERROR_METADATA` is asserted thrown by at least one test
**Verified by:** `src/meter/loudness-meter.test.ts::"analyze throws AUDIO_ANALYSIS_LOUDNESS.EMPTY_DOCUMENT for a zero-length document"` AND `src/error-codes.test.ts::"every AUDIO_ANALYSIS code is reachable from a call site"`
---
### Requirement: AbortSignal propagation
`LoudnessMeter.analyze` and `LoudnessMeter.analyzeStream` SHALL accept an `AbortSignal` via their options object. An already-aborted signal SHALL cause the method to throw `AudioAnalysisLoudnessError` with code `AUDIO_ANALYSIS_LOUDNESS.ABORTED` before any block is read; a signal aborted mid-stream SHALL terminate `analyzeStream` at the next gating-block boundary with the same error.
#### Scenario: Pre-aborted signal short-circuits analysis
- **WHEN** `analyze` is called with an already-aborted signal
- **THEN** it throws `AudioAnalysisLoudnessError` whose `code === "AUDIO_ANALYSIS_LOUDNESS.ABORTED"` before reading any audio block
#### Scenario: Mid-stream abort terminates at the next block boundary
- **WHEN** a signal is aborted after the first `analyzeStream` yield
- **THEN** the generator throws `AUDIO_ANALYSIS_LOUDNESS.ABORTED` at the next gating-block boundary and yields nothing further
**Verified by:** `src/meter/loudness-meter.test.ts::"analyze throws AUDIO_ANALYSIS_LOUDNESS.ABORTED for a pre-aborted signal"` AND `src/meter/loudness-meter.test.ts::"analyzeStream honours mid-stream abort"`
---
### Requirement: OpenTelemetry instrumentation
The package SHALL instrument its public entry points via `@suaveplan/opentelemetry`: a span `audio.analysis.loudness` around `analyze` and `analyzeStream`, a counter `audio_analysis_runs_total` tagged `{op="loudness-meter"}`, and histograms `audio_analysis_duration_ms` and `audio_analysis_block_duration_ms` tagged `{op="loudness-meter"}`. Instrumentation SHALL be a no-op when no exporter is registered and SHALL NOT run as an import-time side effect.
#### Scenario: Instrumentation is inert without an exporter
- **WHEN** the package is imported and `analyze` is run with no OTel exporter registered
- **THEN** no error is thrown and no global state is mutated at import time
#### Scenario: A successful analyze records the expected signals
- **WHEN** `analyze` runs against a real in-memory `MeterProvider`
- **THEN** `audio_analysis_runs_total{op="loudness-meter"}` increments by 1 AND `audio_analysis_duration_ms{op="loudness-meter"}` records a single observation
**Verified by:** `tests/purity.test.ts::"importing the package mutates no global state"` AND `src/meter/loudness-meter.test.ts::"analyze records a span, counter, and histogram when an exporter is registered"`
---
### Requirement: Subpath exports per feature module
`package.json` SHALL declare one `exports` entry per feature module (`./meter/loudness-meter`, `./algorithms/lufs`, `./algorithms/true-peak`, `./algorithms/loudness-range`, `./filters/k-weighting`) in addition to the root `.` barrel, each resolving to a typed `import` condition. `sideEffects` SHALL be `false`.
#### Scenario: Each feature module is independently importable
- **WHEN** a consumer imports `@suaveplan/audio-analysis-loudness/algorithms/true-peak`
- **THEN** the import resolves to the true-peak module without pulling the whole package barrel
**Verified by:** `tests/exports.test.ts::"every feature module has a subpath export entry"`
<!-- markdownlint-enable MD024 -->