Files
2026-05-27 23:29:53 +01:00

234 lines
11 KiB
Markdown
Raw Permalink 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.
# 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<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
```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.