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