16 KiB
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, orBuffer
Scenario: Tests pass under both runtimes
- WHEN the suite runs under
bun test(server) and under vitest with ahappy-domshim (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.descriptionis the BS.1770-4 string, andanalyze,analyzeStream, andanalyzeBlockare callable functions
Scenario: analyze resolves a LoudnessResult for a full document
- WHEN a caller
await meter.analyze(doc)on a populatedAudioDocument - THEN the resolved value carries
op === "loudness-meter", a numericlufs,loudnessRange,truePeak,rms,peak, and adcOffsetarray 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
analyzeruns on a −20 dBFS 1 kHz sine (mono, ≥ 3 s) - THEN
result.lufsis within ±0.1 LU of the BS.1770 reference value for that signal
Scenario: Quiet blocks are excluded by the gates
- WHEN
analyzeruns on a signal that is loud for 3 s then silent for 30 s - THEN
result.lufsreflects 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
analyzeruns on a signal alternating between loud and quiet passages - THEN
result.loudnessRangeis greater than0and equals the 95th-minus-10th percentile spread of the gated short-term distribution
Scenario: Too-short document reports zero range
- WHEN
analyzeruns 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
analyzeruns on any non-silent signal - THEN
result.truePeak >= result.peak
Scenario: Inter-sample peak is detected above the sample maximum
- WHEN
analyzeruns on a signal crafted so the true waveform peak falls between samples (e.g. a 0 dBFS sine near Nyquist/4) - THEN
result.truePeakexceedsresult.peakby 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
analyzeruns on a unity-amplitude sine with no DC component - THEN
result.peakis0dB,result.rmsis approximately-3.01dB, and every entry ofresult.dcOffsetis approximately0
Scenario: Silence is handled gracefully
- WHEN
analyzeruns on a fully silent document - THEN the call resolves without throwing,
result.lufsis the BS.1770 silence floor, andresult.rmsis 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 callsanalyze(doc)on the same document - THEN the last yielded result's
lufsequals the batchanalyzeresult'slufswithin floating-point tolerance
Scenario: Running loudness refines as blocks arrive
- WHEN a caller reads the first two yields of
analyzeStreamon a multi-block document - THEN each yield carries an integrated
lufscomputed 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
analyzeBlockwith a 400 ms block of a known-level signal - THEN the returned partial carries a
lufsvalue 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-rangeoverlap - THEN it throws
AudioAnalysisLoudnessErrorwhosecode === "AUDIO_ANALYSIS_LOUDNESS.INVALID_OPTIONS"with the Zod issues incontext
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
analyzeis called on anAudioDocumentwithsourceLength === 0 - THEN it throws
AudioAnalysisLoudnessErrorwhosecode === "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_METADATAis 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
analyzeis called with an already-aborted signal - THEN it throws
AudioAnalysisLoudnessErrorwhosecode === "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
analyzeStreamyield - THEN the generator throws
AUDIO_ANALYSIS_LOUDNESS.ABORTEDat 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
analyzeis 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
analyzeruns against a real in-memoryMeterProvider - THEN
audio_analysis_runs_total{op="loudness-meter"}increments by 1 ANDaudio_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"