# 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. ```ts 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: 1. Energy is accumulated over **400 ms gating blocks** that **overlap by 75%** (a new block every 100 ms). 2. 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. 3. The **absolute gate** discards every block below **−70 LUFS** — this removes silence and near-silence. 4. The ungated mean of the survivors sets a **relative gate** at **−10 LU** below it; blocks below the relative gate are discarded. 5. 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: 1. Collect **short-term** loudness over 3 s windows stepped by 1 s. 2. 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). 3. 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. ```ts // 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 ```ts 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 analyzeStream(doc: AudioDocument, opts?: LoudnessMeterOptions): AsyncGenerator analyzeBlock(block: Float32Array[], ctx: BlockContext): Partial } ``` `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 ```text 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`** — supplies `AudioDocument`, the `AnalysisOp` / `AnalysisResult` interfaces, `BlockContext`, and the read/stream APIs the meter consumes. The meter reads PCM through `doc.stream()` so it inherits core's paged-memory and AbortSignal behaviour for free. - **`@suaveplan/error`** — `AudioError` base class and the `AUDIO_ANALYSIS` code namespace (`AUDIO_ANALYSIS_LOUDNESS.INVALID_OPTIONS`, `AUDIO_ANALYSIS_LOUDNESS.EMPTY_DOCUMENT`, `AUDIO_ANALYSIS_LOUDNESS.ABORTED`). - **`@suaveplan/opentelemetry`** — span `audio.analysis.loudness`, counter `audio_analysis_runs_total`, histograms `audio_analysis_duration_ms` and `audio_analysis_block_duration_ms`, all tagged `{op="loudness-meter"}`. Instrumentation is lazy — inert with no exporter, never an import-time side effect. - **`@suaveplan/types`** — `LoggerLike` for the optional injected logger. - **`zod`** — `LoudnessMeterOptionsSchema` validates the constructor and entry-point options; failures throw `AUDIO_ANALYSIS_LOUDNESS.INVALID_OPTIONS` with the Zod issue list in `context`. --- ## 6. Benchmarks - `loudness.bench.ts` — `analyze` a 1-minute 48 kHz stereo track. - `loudness.bench.ts` — `analyze` a 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, `analyzeStream` yields 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 of `analyzeStream` doing no less work than `analyze`. - **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 clamps `lufs` and `rms` to 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 take `Float32Array[]` and plain numbers, not `AudioDocument`. 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 `AnalyserNode` path.** Unlike `audio-analysis-spectrum`, loudness metering needs exact gated energy, not a smoothed FFT — there is no correctness-preserving `AnalyserNode` shortcut, so the package stays purely universal with a single hand-rolled DSP path.