11 KiB
Design: enterprise-audio-analysis-loudness
Change ID: enterprise-audio-analysis-loudness
Package: @suaveplan/audio-analysis-loudness
@suaveplan/audio-analysis-loudness is a leaf analysis plugin in the @suaveplan/audio-* family.
It measures loudness — integrated/short-term/momentary LUFS, loudness range, true peak, RMS, sample
peak, and per-channel DC offset — for broadcast-compliance, mastering, and auto-normalization
pipelines. This document carries the package-specific design; the cross-cutting audio
ecosystem architecture is not duplicated here.
1. Relationship to the audio ecosystem
The cross-cutting architecture for the whole @suaveplan/audio-* family lives in
enterprise-audio-core/design.md §1–§4 — the three-layer separation, the six plugin slots, the
zero-duplication rule for genesis primitives, and the AudioError hierarchy. This package does not
restate that model; it references it.
LoudnessMeter occupies plugin slot 3 — Analysis Op (enterprise-audio-core/design.md §1.3).
Analysis ops are sidecar processors: they read an AudioDocument and emit features, and they are
not part of the render pipeline. The meter never registers a plan op or a process op; it
consumes audio through @suaveplan/audio-core's read/stream APIs and returns a LoudnessResult.
The AnalysisOp and AnalysisResult interfaces are defined by @suaveplan/audio-core. This
package implements AnalysisOp and extends AnalysisResult into LoudnessResult.
2. Algorithm rationale
2.1. K-weighting filter (ITU-R BS.1770)
BS.1770 loudness is not raw RMS — it is the energy of a K-weighted signal. The K-weighting curve is a two-stage IIR cascade applied per channel before any energy is accumulated:
- Stage 1 — high-shelf "pre-filter". A second-order shelving biquad that models the acoustic effect of the listener's head, boosting high frequencies by roughly +4 dB.
- Stage 2 — RLB high-pass. A second-order Butterworth-style high-pass (the "revised low-frequency B-curve") that rolls off content below ~100 Hz so sub-bass rumble does not dominate the measurement.
Coefficients are sample-rate dependent. The canonical BS.1770 coefficients are specified at
48 kHz; for other rates (notably 44.1 kHz) the analog prototype is re-discretised via the bilinear
transform so the magnitude response matches. The filter keeps per-channel biquad state
(z1, z2 per stage) so it can be driven incrementally over streamed blocks and still produce
output sample-identical to a single whole-buffer pass.
interface Biquad {
b0: number; b1: number; b2: number; a1: number; a2: number
}
interface KWeightingState {
pre: Float64Array // [z1, z2] per channel for stage 1
rlb: Float64Array // [z1, z2] per channel for stage 2
}
function buildKWeighting(sampleRate: number): { pre: Biquad; rlb: Biquad }
function filterBlock(block: Float32Array[], coeffs, state: KWeightingState): Float32Array[]
2.2. Gating (BS.1770-4)
Integrated loudness is a gated mean of K-weighted energy, not a simple average:
- Energy is accumulated over 400 ms gating blocks that overlap by 75% (a new block every 100 ms).
- Each block's per-channel mean square is summed with the BS.1770 channel weights (1.0 for L, R, C; 1.41 for the surround channels) to a single loudness value.
- The absolute gate discards every block below −70 LUFS — this removes silence and near-silence.
- The ungated mean of the survivors sets a relative gate at −10 LU below it; blocks below the relative gate are discarded.
- Integrated loudness is the loudness of the mean energy of the doubly-gated survivor set.
Momentary loudness uses the same K-weighting and channel-summing over a single ungated 400 ms
window; short-term loudness over a 3 s window. These feed analyzeBlock (momentary) and the
loudness-range computation (short-term).
2.3. Loudness range — EBU Tech 3342
loudnessRange (LRA, in LU) is a statistical measure of dynamic spread:
- Collect short-term loudness over 3 s windows stepped by 1 s.
- Apply an absolute gate at −70 LUFS, then a relative gate 20 LU below the gated mean (note: wider than the integrated-loudness relative gate of 10 LU — this is intentional in Tech 3342).
- LRA is the 95th percentile minus the 10th percentile of the surviving distribution.
A document shorter than one 3 s window cannot produce a distribution, so LRA is reported as 0.
2.4. True-peak — 4x oversampling (BS.1770-4 Annex 2)
Sample peak under-reports the real analog peak because the true waveform crests between samples.
BS.1770-4 specifies 4x oversampling through a polyphase FIR interpolation filter; truePeak
(dBTP) is the maximum absolute interpolated sample across all channels. By construction
truePeak >= peak always — the original samples are a subset of the interpolated set.
// 4-phase polyphase FIR; each phase is a short symmetric low-pass kernel.
function truePeakDbtp(block: Float32Array[]): number
2.5. RMS, peak, DC offset
rms is 20 * log10(sqrt(meanSquare)) across all channels; peak is
20 * log10(maxAbsSample); dcOffset[c] is the arithmetic mean of channel c. For digital
silence, rms and lufs are clamped to finite BS.1770 floor values rather than -Infinity, so
downstream consumers (normalizers, UI meters) never have to special-case -Infinity.
3. Public surface and result shape
interface LoudnessMeterOptions {
blockSize?: number // gating block length in ms; default 400 (BS.1770)
overlap?: number // gating overlap fraction 0..1; default 0.75
targetLufs?: number // reference target; default -14.0 (streaming), -23.0 (broadcast)
signal?: AbortSignal // cooperative cancellation for analyze / analyzeStream
}
interface LoudnessResult extends AnalysisResult {
lufs: number // integrated loudness (LUFS)
loudnessRange: number // LRA (LU)
truePeak: number // dBTP
rms: number // dB
peak: number // dB
dcOffset: number[] // per-channel mean sample value
}
class LoudnessMeter implements AnalysisOp {
readonly name = 'loudness-meter'
readonly description = 'ITU-R BS.1770-4 loudness measurement'
constructor(options?: LoudnessMeterOptions)
analyze(doc: AudioDocument, opts?: LoudnessMeterOptions): Promise<LoudnessResult>
analyzeStream(doc: AudioDocument, opts?: LoudnessMeterOptions): AsyncGenerator<LoudnessResult>
analyzeBlock(block: Float32Array[], ctx: BlockContext): Partial<LoudnessResult>
}
analyze and analyzeStream share one gating engine: analyze drains the document and runs the
engine to completion; analyzeStream yields the engine's running integrated loudness after every
gating block. The invariant is that the final analyzeStream yield equals the analyze result
for the same document — the streaming path is the batch path observed incrementally, not a
separate approximation.
analyzeBlock is the real-time path: synchronous, allocation-light (it reuses a per-instance
scratch filter state), and safe to call on every audio callback for live meters. It returns only
the per-block fields it can compute without document context (lufs momentary, peak, rms).
4. File layout
packages/media/audio/audio-analysis-loudness/
package.json, tsconfig.json, vitest.config.ts, README.md
src/
index.ts
error-codes.ts
meter/
loudness-meter.ts (+ .md .types.ts .schemas.ts .test.ts) · index.ts
algorithms/
lufs.ts (+ .md .test.ts)
true-peak.ts (+ .md .test.ts)
loudness-range.ts (+ .md .test.ts)
index.ts
filters/
k-weighting.ts (+ .md .test.ts)
index.ts
__benchmarks__/
loudness.bench.ts
tests/
integration.test.ts property.test.ts purity.test.ts exports.test.ts
algorithms/ holds the pure DSP kernels (no AudioDocument knowledge — they take Float32Array[]
and numbers); filters/ holds the K-weighting cascade; meter/ is the AnalysisOp orchestration
layer that wires document streaming, the gating engine, OTel, Zod validation, and abort handling
together. Each algorithms/* and filters/* module is independently subpath-exported so a
consumer that only needs true-peak detection does not pull the gating engine.
5. Integration notes
@suaveplan/audio-core— suppliesAudioDocument, theAnalysisOp/AnalysisResultinterfaces,BlockContext, and the read/stream APIs the meter consumes. The meter reads PCM throughdoc.stream()so it inherits core's paged-memory and AbortSignal behaviour for free.@suaveplan/error—AudioErrorbase class and theAUDIO_ANALYSIScode namespace (AUDIO_ANALYSIS_LOUDNESS.INVALID_OPTIONS,AUDIO_ANALYSIS_LOUDNESS.EMPTY_DOCUMENT,AUDIO_ANALYSIS_LOUDNESS.ABORTED).@suaveplan/opentelemetry— spanaudio.analysis.loudness, counteraudio_analysis_runs_total, histogramsaudio_analysis_duration_msandaudio_analysis_block_duration_ms, all tagged{op="loudness-meter"}. Instrumentation is lazy — inert with no exporter, never an import-time side effect.@suaveplan/types—LoggerLikefor the optional injected logger.zod—LoudnessMeterOptionsSchemavalidates the constructor and entry-point options; failures throwAUDIO_ANALYSIS_LOUDNESS.INVALID_OPTIONSwith the Zod issue list incontext.
6. Benchmarks
loudness.bench.ts—analyzea 1-minute 48 kHz stereo track.loudness.bench.ts—analyzea 10-minute 48 kHz stereo track (verifies the gating engine stays linear and the streaming path does not accumulate per-block allocation).
7. Decisions and trade-offs
- One gating engine, two entry points. Rather than a separate fast-path approximation for
streaming,
analyzeStreamyields the same engine's running state. This guarantees the streamed result converges exactly to the batch result and removes a whole class of "streaming disagrees with batch" bugs at the cost ofanalyzeStreamdoing no less work thananalyze. - Silence floors instead of
-Infinity. BS.1770 loudness of true silence is-Infinity; returning that value forces every consumer (normalizers, React meters) to special-case it. The meter clampslufsandrmsto finite BS.1770 floor values so the result shape is always numeric. This is a deliberate, documented deviation from a naive reading of the standard. algorithms/kernels are document-agnostic. The LUFS, true-peak, and loudness-range kernels takeFloat32Array[]and plain numbers, notAudioDocument. This keeps them unit-testable against synthetic signals with no core dependency in the test, and lets the FX package reuse the true-peak kernel directly for a true-peak limiter without instantiating a meter.- True-peak via fixed 4x oversampling. BS.1770-4 permits higher oversampling ratios, but 4x is the specified minimum and is sufficient up to 48 kHz material; a configurable ratio would add surface area for negligible accuracy gain in the target sample-rate range. If a future consumer needs 8x for very-high-rate material it can be added as an option without breaking the result shape.
- No browser
AnalyserNodepath. Unlikeaudio-analysis-spectrum, loudness metering needs exact gated energy, not a smoothed FFT — there is no correctness-preservingAnalyserNodeshortcut, so the package stays purely universal with a single hand-rolled DSP path.