Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,233 @@
# 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.