Files
suaveplan/openspec/archive/2026-05-23-enterprise-audio-analysis-loudness/design.md
2026-05-27 23:29:53 +01:00

11 KiB
Raw Blame History

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:

  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.

// 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 — 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.