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.

View File

@@ -0,0 +1,138 @@
# OpenSpec Change: enterprise-audio-analysis-loudness
- **Change ID**: enterprise-audio-analysis-loudness
- **Package**: `@suaveplan/audio-analysis-loudness`
- **Category**: universal
- **Target path**: `packages/media/audio/audio-analysis-loudness`
- **Status**: PROPOSED
- **Date**: 2026-05-14
- **Design**: `./design.md`
- **Spec delta**: `./specs/audio-analysis-loudness/spec.md`
---
## Summary
Deliver `@suaveplan/audio-analysis-loudness` on the genesis branch from scratch at enterprise quality. Loudness metering plugin for the universal audio analysis framework. Measures integrated loudness (LUFS), loudness range, true peak, RMS, and DC offset per ITU-R BS.1770-4. Essential for broadcast compliance, mastering, and auto-normalization pipelines.
Runs identically in browser and server via `BlockProcessor` or direct stream iteration.
---
## Current-state audit (genesis)
The package does not exist on genesis yet — this is a fresh build. No prior source, no test results, no coverage data.
---
## Available workspace deps on genesis
| Package | Role |
|---|---|
| `@suaveplan/audio-core` (`workspace:^`) | `AudioDocument`, `BlockProcessor`, `BlockContext` |
| `@suaveplan/error` (`workspace:^`) | `AudioError` base class; `AUDIO_ANALYSIS.*` codes |
| `@suaveplan/types` (`workspace:^`) | `LoggerLike` |
| `@suaveplan/opentelemetry` (`workspace:^`) | OTel spans, counters, histograms |
| `zod` (`catalog:forms`) | Zod schemas for option types |
Dev deps: `vitest`, `@vitest/coverage-istanbul`, `fast-check`, `mitata` (all `catalog:testing`).
---
## Public API
### Core types
```ts
interface LoudnessMeterOptions {
blockSize?: number // default 400ms (BS.1770)
overlap?: number // default 75%
targetLufs?: number // default -14.0 (streaming) or -23.0 (broadcast)
}
interface LoudnessResult extends AnalysisResult {
lufs: number // integrated loudness
loudnessRange: number // LRA in LU
truePeak: number // dBTP
rms: number // dB
peak: number // dB
dcOffset: number[] // per-channel
}
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>
}
```
---
## New additions for genesis
1. **BS.1770-4 compliance** — K-weighting filter, relative gate at -70 LUFS, integrated gate.
2. **True peak** — 4x oversampled peak detection using polyphase interpolation.
3. **Loudness range** — statistical loudness range (LU) per EBU Tech 3342.
4. **Streaming meters** — `analyzeStream()` yields running integrated loudness as blocks arrive.
5. **Block-level metering** — `analyzeBlock()` returns momentary loudness for real-time meters.
6. **OTel instrumentation** — span `audio.analysis.loudness`, counter, histogram.
7. **Property-based tests** — sine wave at 0 dBFS → known LUFS, silence → -∞ LUFS handled gracefully.
8. **Benchmark suite** — meter 1-minute track, meter 10-minute track.
---
## File layout
```text
packages/media/audio/audio-analysis-loudness/
package.json
tsconfig.json
vitest.config.ts
src/
index.ts
error-codes.ts
meter/
loudness-meter.ts
loudness-meter.md
loudness-meter.types.ts
loudness-meter.test.ts
index.ts
algorithms/
lufs.ts
lufs.md
lufs.test.ts
true-peak.ts
true-peak.md
true-peak.test.ts
loudness-range.ts
loudness-range.md
loudness-range.test.ts
index.ts
filters/
k-weighting.ts
k-weighting.md
k-weighting.test.ts
index.ts
__benchmarks__/
loudness.bench.ts
tests/
integration.test.ts
property.test.ts
```
---
## Impact
**Breaking changes**: none — fresh build, package does not yet exist on genesis.
**Prerequisites**: `@suaveplan/audio-core`, `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/opentelemetry` published to Verdaccio (all already on genesis).
### Affected dependents
No dependents in this batch (verified by `bunx turbo ls --filter=...@suaveplan/audio-analysis-loudness`). `@suaveplan/audio-analysis-loudness` is a leaf analysis plugin — no batch package depends on it. It is consumed at runtime by application/timeline code and by `normalize`'s LUFS mode, both of which are created against this package's spec delta, not retrofitted.

View File

@@ -0,0 +1,247 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: audio-analysis-loudness (audio-analysis-loudness)
**Package**: `@suaveplan/audio-analysis-loudness`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## ADDED Requirements
### Requirement: Universal-tier purity
The `@suaveplan/audio-analysis-loudness` package SHALL import zero browser-only APIs (`window`, `document`, `navigator`, `AudioContext`, `AudioBuffer`, `AnalyserNode`) and zero Node-only APIs (`node:fs`, `node:path`, `process`, `Buffer`). All loudness measurement SHALL operate on `Float32Array[]` PCM obtained from an `AudioDocument` through `@suaveplan/audio-core` read/stream APIs, with no platform-specific code paths. The package's `dependencies` SHALL be limited to `@suaveplan/audio-core`, `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/opentelemetry`, `tslib`, and `zod`.
#### Scenario: Built dist references no platform globals
- **WHEN** the built `dist/` is grep'd
- **THEN** it MUST NOT reference `window`, `document`, `navigator`, `AudioContext`, `AnalyserNode`, `node:fs`, `node:path`, `process`, or `Buffer`
#### Scenario: Tests pass under both runtimes
- **WHEN** the suite runs under `bun test` (server) and under vitest with a `happy-dom` shim (browser)
- **THEN** both runs pass with identical results
**Verified by:** `tests/purity.test.ts::"dist references no browser or Node globals"` AND `tests/purity.test.ts::"dependency set is limited to the declared workspace and zod"`
---
### Requirement: LoudnessMeter implements the AnalysisOp contract
The package SHALL export a `LoudnessMeter` class implementing the `AnalysisOp` interface defined by `@suaveplan/audio-core` (plugin slot 3 — Analysis Op). It SHALL expose a readonly `name` of `"loudness-meter"` and a readonly `description` of `"ITU-R BS.1770-4 loudness measurement"`, and SHALL implement `analyze(doc, opts?)`, `analyzeStream(doc, opts?)`, and `analyzeBlock(block, ctx)`. The meter SHALL be a sidecar processor — it SHALL NOT register a plan or process op and SHALL NOT participate in the render pipeline.
#### Scenario: Meter is shaped as an AnalysisOp
- **WHEN** a caller constructs `new LoudnessMeter()`
- **THEN** `meter.name === "loudness-meter"`, `meter.description` is the BS.1770-4 string, and `analyze`, `analyzeStream`, and `analyzeBlock` are callable functions
#### Scenario: analyze resolves a LoudnessResult for a full document
- **WHEN** a caller `await meter.analyze(doc)` on a populated `AudioDocument`
- **THEN** the resolved value carries `op === "loudness-meter"`, a numeric `lufs`, `loudnessRange`, `truePeak`, `rms`, `peak`, and a `dcOffset` array of one entry per channel
**Verified by:** `src/meter/loudness-meter.test.ts::"exposes the AnalysisOp name and description"` AND `src/meter/loudness-meter.test.ts::"analyze resolves a fully populated LoudnessResult"`
---
### Requirement: ITU-R BS.1770-4 integrated loudness
`LoudnessMeter.analyze` SHALL compute integrated loudness (LUFS) per ITU-R BS.1770-4: each channel SHALL be passed through the K-weighting filter (stage 1 high-shelf pre-filter, stage 2 high-pass RLB filter), mean-square energy SHALL be accumulated over 400 ms gating blocks overlapped by 75%, channel energies SHALL be summed with the BS.1770 channel weights, the absolute gate SHALL discard blocks below −70 LUFS, and the relative gate SHALL discard blocks more than 10 LU below the ungated mean before the final integration. Momentary loudness SHALL use a 400 ms window and short-term loudness a 3 s window.
#### Scenario: Reference signal yields the BS.1770 expected loudness
- **WHEN** `analyze` runs on a −20 dBFS 1 kHz sine (mono, ≥ 3 s)
- **THEN** `result.lufs` is within ±0.1 LU of the BS.1770 reference value for that signal
#### Scenario: Quiet blocks are excluded by the gates
- **WHEN** `analyze` runs on a signal that is loud for 3 s then silent for 30 s
- **THEN** `result.lufs` reflects only the loud segment because the silent gating blocks fall below the absolute and relative gates
**Verified by:** `src/algorithms/lufs.test.ts::"integrated loudness matches the BS.1770 reference within tolerance"` AND `src/algorithms/lufs.test.ts::"absolute and relative gates discard quiet blocks"`
---
### Requirement: Loudness range per EBU Tech 3342
`LoudnessResult.loudnessRange` SHALL be the loudness range (LRA) in LU computed per EBU Tech 3342: short-term loudness values SHALL be collected over 3 s windows with 1 s steps, an absolute gate at −70 LUFS and a relative gate 20 LU below the gated mean SHALL be applied, and the LRA SHALL be the difference between the 95th and 10th percentiles of the surviving distribution. A document with fewer than one full short-term window SHALL report `loudnessRange` as `0`.
#### Scenario: Dynamic signal reports a positive loudness range
- **WHEN** `analyze` runs on a signal alternating between loud and quiet passages
- **THEN** `result.loudnessRange` is greater than `0` and equals the 95th-minus-10th percentile spread of the gated short-term distribution
#### Scenario: Too-short document reports zero range
- **WHEN** `analyze` runs on a document shorter than one 3 s short-term window
- **THEN** `result.loudnessRange === 0`
**Verified by:** `src/algorithms/loudness-range.test.ts::"reports the percentile spread for a dynamic signal"` AND `src/algorithms/loudness-range.test.ts::"reports zero range for a sub-window document"`
---
### Requirement: True-peak detection via 4x oversampling
`LoudnessResult.truePeak` SHALL be the true-peak level in dBTP measured by 4x oversampling each channel through a polyphase FIR interpolation filter and taking the maximum absolute interpolated sample across all channels, per ITU-R BS.1770-4 Annex 2. `truePeak` SHALL always be greater than or equal to the sample `peak` for the same signal, because inter-sample peaks can exceed sample maxima.
#### Scenario: True peak is at least the sample peak
- **WHEN** `analyze` runs on any non-silent signal
- **THEN** `result.truePeak >= result.peak`
#### Scenario: Inter-sample peak is detected above the sample maximum
- **WHEN** `analyze` runs on a signal crafted so the true waveform peak falls between samples (e.g. a 0 dBFS sine near Nyquist/4)
- **THEN** `result.truePeak` exceeds `result.peak` by a measurable margin
**Verified by:** `src/algorithms/true-peak.test.ts::"true peak is never below the sample peak"` AND `src/algorithms/true-peak.test.ts::"detects an inter-sample peak above the sample maximum"`
---
### Requirement: RMS, peak, and per-channel DC offset
`LoudnessResult` SHALL carry `rms` (root-mean-square level in dB across all channels), `peak` (maximum absolute sample expressed in dB), and `dcOffset` (an array holding the mean sample value of each channel). For a full-scale signal `peak` SHALL be `0` dB; for digital silence `rms` SHALL be reported as a finite floor value rather than `-Infinity` and `lufs` SHALL be reported as the BS.1770 silence floor without throwing.
#### Scenario: Known signal reports expected RMS, peak, and DC offset
- **WHEN** `analyze` runs on a unity-amplitude sine with no DC component
- **THEN** `result.peak` is `0` dB, `result.rms` is approximately `-3.01` dB, and every entry of `result.dcOffset` is approximately `0`
#### Scenario: Silence is handled gracefully
- **WHEN** `analyze` runs on a fully silent document
- **THEN** the call resolves without throwing, `result.lufs` is the BS.1770 silence floor, and `result.rms` is a finite floor value
**Verified by:** `src/meter/loudness-meter.test.ts::"reports expected RMS, peak, and DC offset for a known signal"` AND `src/meter/loudness-meter.test.ts::"handles silence without throwing or returning -Infinity"`
---
### Requirement: Streaming integrated loudness
`LoudnessMeter.analyzeStream` SHALL be an async generator that consumes `AudioDocument` blocks via `@suaveplan/audio-core` streaming and yields a `LoudnessResult` after each gating block, where each yielded result's `lufs` is the integrated loudness of all audio seen so far. The running gate state SHALL be carried across yields so the final yielded result equals what `analyze` would return for the whole document. The generator SHALL accept an `AbortSignal` via its options object.
#### Scenario: Final streamed result equals the batch result
- **WHEN** a caller fully drains `analyzeStream(doc)` and also calls `analyze(doc)` on the same document
- **THEN** the last yielded result's `lufs` equals the batch `analyze` result's `lufs` within floating-point tolerance
#### Scenario: Running loudness refines as blocks arrive
- **WHEN** a caller reads the first two yields of `analyzeStream` on a multi-block document
- **THEN** each yield carries an integrated `lufs` computed over strictly more audio than the previous yield
**Verified by:** `src/meter/loudness-meter.test.ts::"final streamed result equals the batch analyze result"` AND `src/meter/loudness-meter.test.ts::"streamed integrated loudness refines as blocks arrive"`
---
### Requirement: Block-level momentary metering
`LoudnessMeter.analyzeBlock(block, ctx)` SHALL compute momentary loudness for a single `Float32Array[]` block using the `BlockContext` sample rate and channel count, returning a `Partial<LoudnessResult>` carrying at least `lufs` (momentary), `peak`, and `rms` for that block. `analyzeBlock` SHALL be synchronous, allocation-light, and safe to call on every audio callback for real-time meters; it SHALL NOT mutate the input block.
#### Scenario: analyzeBlock returns momentary loudness for one block
- **WHEN** a caller calls `analyzeBlock` with a 400 ms block of a known-level signal
- **THEN** the returned partial carries a `lufs` value within tolerance of the BS.1770 momentary loudness for that block and leaves the input arrays unmodified
**Verified by:** `src/meter/loudness-meter.test.ts::"analyzeBlock returns momentary loudness without mutating the input"`
---
### Requirement: K-weighting filter
The package SHALL export a K-weighting filter implementation comprising the ITU-R BS.1770 two-stage biquad cascade (stage 1: high-shelf "pre-filter" modelling the acoustic effect of the head; stage 2: RLB high-pass filter). The filter coefficients SHALL be derived for the document's sample rate so measurement is correct at 44.1 kHz, 48 kHz, and other rates, and the filter SHALL maintain per-channel state so it can be applied incrementally to streamed blocks.
#### Scenario: Filter coefficients adapt to the sample rate
- **WHEN** the K-weighting filter is built for 44100 Hz and again for 48000 Hz
- **THEN** the two coefficient sets differ and each reproduces the BS.1770 reference magnitude response for its rate within tolerance
#### Scenario: Incremental filtering matches whole-buffer filtering
- **WHEN** a signal is filtered in one pass and again block-by-block through the stateful filter
- **THEN** the two filtered outputs are sample-equal within floating-point tolerance
**Verified by:** `src/filters/k-weighting.test.ts::"derives rate-specific coefficients matching the BS.1770 response"` AND `src/filters/k-weighting.test.ts::"incremental block filtering equals whole-buffer filtering"`
---
### Requirement: Zod boundary validation
Every public options object — `LoudnessMeterOptions` — SHALL have a Zod schema exported from a sibling `*.schemas.ts`, and every public entry point (`analyze`, `analyzeStream`, the `LoudnessMeter` constructor) SHALL validate its options argument against the schema before use. A validation failure SHALL throw `AudioAnalysisLoudnessError` with code `AUDIO_ANALYSIS_LOUDNESS.INVALID_OPTIONS` carrying the Zod issue list in `context`.
#### Scenario: Invalid options are rejected at the boundary
- **WHEN** `new LoudnessMeter({ overlap: 2 })` is constructed with an out-of-range `overlap`
- **THEN** it throws `AudioAnalysisLoudnessError` whose `code === "AUDIO_ANALYSIS_LOUDNESS.INVALID_OPTIONS"` with the Zod issues in `context`
**Verified by:** `src/meter/loudness-meter.test.ts::"constructor rejects invalid options with AUDIO_ANALYSIS_LOUDNESS.INVALID_OPTIONS"`
---
### Requirement: Structured error codes
The package SHALL export `AudioAnalysisLoudnessErrorCode`, `AUDIO_ANALYSIS_LOUDNESS_ERROR_METADATA`, and `registerAudioAnalysisLoudnessErrorCodes()`, registering the codes `AUDIO_ANALYSIS_LOUDNESS.INVALID_OPTIONS`, `AUDIO_ANALYSIS_LOUDNESS.EMPTY_DOCUMENT`, and `AUDIO_ANALYSIS_LOUDNESS.ABORTED` with `@suaveplan/error`. Every error thrown by the package SHALL be an `AudioAnalysisLoudnessError` extending `SuaveplanError` and carry one of these codes, `cause`, and `context`. Every registered code SHALL be reachable from a real call site exercised by tests: `AUDIO_ANALYSIS_LOUDNESS.INVALID_OPTIONS` from Zod validation, `AUDIO_ANALYSIS_LOUDNESS.EMPTY_DOCUMENT` from `analyze` on a zero-length document, and `AUDIO_ANALYSIS_LOUDNESS.ABORTED` from a pre-aborted or mid-stream-aborted signal.
#### Scenario: Empty document is rejected
- **WHEN** `analyze` is called on an `AudioDocument` with `sourceLength === 0`
- **THEN** it throws `AudioAnalysisLoudnessError` whose `code === "AUDIO_ANALYSIS_LOUDNESS.EMPTY_DOCUMENT"`
#### Scenario: Every registered code is thrown by a call site
- **WHEN** the test suite runs
- **THEN** each code in `AUDIO_ANALYSIS_LOUDNESS_ERROR_METADATA` is asserted thrown by at least one test
**Verified by:** `src/meter/loudness-meter.test.ts::"analyze throws AUDIO_ANALYSIS_LOUDNESS.EMPTY_DOCUMENT for a zero-length document"` AND `src/error-codes.test.ts::"every AUDIO_ANALYSIS code is reachable from a call site"`
---
### Requirement: AbortSignal propagation
`LoudnessMeter.analyze` and `LoudnessMeter.analyzeStream` SHALL accept an `AbortSignal` via their options object. An already-aborted signal SHALL cause the method to throw `AudioAnalysisLoudnessError` with code `AUDIO_ANALYSIS_LOUDNESS.ABORTED` before any block is read; a signal aborted mid-stream SHALL terminate `analyzeStream` at the next gating-block boundary with the same error.
#### Scenario: Pre-aborted signal short-circuits analysis
- **WHEN** `analyze` is called with an already-aborted signal
- **THEN** it throws `AudioAnalysisLoudnessError` whose `code === "AUDIO_ANALYSIS_LOUDNESS.ABORTED"` before reading any audio block
#### Scenario: Mid-stream abort terminates at the next block boundary
- **WHEN** a signal is aborted after the first `analyzeStream` yield
- **THEN** the generator throws `AUDIO_ANALYSIS_LOUDNESS.ABORTED` at the next gating-block boundary and yields nothing further
**Verified by:** `src/meter/loudness-meter.test.ts::"analyze throws AUDIO_ANALYSIS_LOUDNESS.ABORTED for a pre-aborted signal"` AND `src/meter/loudness-meter.test.ts::"analyzeStream honours mid-stream abort"`
---
### Requirement: OpenTelemetry instrumentation
The package SHALL instrument its public entry points via `@suaveplan/opentelemetry`: a span `audio.analysis.loudness` around `analyze` and `analyzeStream`, a counter `audio_analysis_runs_total` tagged `{op="loudness-meter"}`, and histograms `audio_analysis_duration_ms` and `audio_analysis_block_duration_ms` tagged `{op="loudness-meter"}`. Instrumentation SHALL be a no-op when no exporter is registered and SHALL NOT run as an import-time side effect.
#### Scenario: Instrumentation is inert without an exporter
- **WHEN** the package is imported and `analyze` is run with no OTel exporter registered
- **THEN** no error is thrown and no global state is mutated at import time
#### Scenario: A successful analyze records the expected signals
- **WHEN** `analyze` runs against a real in-memory `MeterProvider`
- **THEN** `audio_analysis_runs_total{op="loudness-meter"}` increments by 1 AND `audio_analysis_duration_ms{op="loudness-meter"}` records a single observation
**Verified by:** `tests/purity.test.ts::"importing the package mutates no global state"` AND `src/meter/loudness-meter.test.ts::"analyze records a span, counter, and histogram when an exporter is registered"`
---
### Requirement: Subpath exports per feature module
`package.json` SHALL declare one `exports` entry per feature module (`./meter/loudness-meter`, `./algorithms/lufs`, `./algorithms/true-peak`, `./algorithms/loudness-range`, `./filters/k-weighting`) in addition to the root `.` barrel, each resolving to a typed `import` condition. `sideEffects` SHALL be `false`.
#### Scenario: Each feature module is independently importable
- **WHEN** a consumer imports `@suaveplan/audio-analysis-loudness/algorithms/true-peak`
- **THEN** the import resolves to the true-peak module without pulling the whole package barrel
**Verified by:** `tests/exports.test.ts::"every feature module has a subpath export entry"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,79 @@
# Tasks: enterprise-audio-analysis-loudness
**Change ID**: enterprise-audio-analysis-loudness
**Package**: `@suaveplan/audio-analysis-loudness`
**Target path**: `packages/media/audio/audio-analysis-loudness`
**Prerequisite packages on Verdaccio**: `@suaveplan/audio-core`, `@suaveplan/error`, `@suaveplan/types`
---
## 0. Pre-flight (non-negotiable)
- [ ] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
- [ ] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
- [ ] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
## 1. Scaffold
- [ ] Create directory `packages/media/audio/audio-analysis-loudness/` with `src/`, `tests/`, `src/__benchmarks__/`, and all feature subdirectories per the layout in `proposal.md`.
- [ ] Write `package.json` at version `0.1.0` with `"type": "module"`, `"sideEffects": false`, full `exports` map, deps: `@suaveplan/audio-core workspace:^`, `@suaveplan/error workspace:^`, `@suaveplan/types workspace:^`, `@suaveplan/opentelemetry workspace:^`, `zod catalog:forms`.
- [ ] Write `tsconfig.json` extending `@suaveplan/typescript-config/library.json`.
- [ ] Write `vitest.config.ts` using `createTestConfig()` from `@suaveplan/testing/config`.
- [ ] Add `lint`, `typecheck`, `test`, `test:coverage`, `build`, `docs:check` scripts.
---
## 2. Types + errors
- [ ] Write `src/error-codes.ts` — `LOUDNESSErrorCode`, `AUDIO_ANALYSIS_LOUDNESS_ERROR_METADATA`, `registerAudioAnalysisErrorCodes()`.
- [ ] Write Zod schemas: `LoudnessMeterOptionsSchema`, `LoudnessResultSchema`.
---
## 3. Filters + algorithms
- [ ] Implement `k-weighting.ts` — ITU-R BS.1770 K-weighting filter (high-shelf + high-pass).
- [ ] Write `k-weighting.test.ts` and `k-weighting.md` (≥200 words).
- [ ] Implement `lufs.ts` — momentary/short-term/integrated loudness with gating.
- [ ] Write `lufs.test.ts` and `lufs.md` (≥200 words).
- [ ] Implement `true-peak.ts` — 4x oversampled true peak detection.
- [ ] Write `true-peak.test.ts` and `true-peak.md` (≥200 words).
- [ ] Implement `loudness-range.ts` — EBU Tech 3342 statistical loudness range.
- [ ] Write `loudness-range.test.ts` and `loudness-range.md` (≥200 words).
---
## 4. LoudnessMeter
- [ ] Implement `LoudnessMeter` class:
- `analyze()` — full-document BS.1770-4 measurement
- `analyzeStream()` — running integrated loudness as blocks arrive
- `analyzeBlock()` — momentary loudness for real-time meters
- [ ] Wire OTel: span `audio.analysis.loudness`, counter, histogram.
- [ ] Write `loudness-meter.test.ts` (≤500 lines): reference signal → known LUFS, silence → graceful, true peak > sample peak.
- [ ] Write `loudness-meter.md` (≥200 words).
---
## 5. Benchmarks
- [ ] Write `src/__benchmarks__/loudness.bench.ts` — meter 1-minute track, meter 10-minute track.
---
## 6. Tests
- [ ] Write `tests/integration.test.ts` — full loudness measurement round-trip on real audio fixture.
- [ ] Write `tests/property.test.ts` (fast-check) — LUFS monotonic with gain, true peak ≥ sample peak.
---
## 7. Docs + verify + publish
- [ ] All co-located `.md` files (≥200 words each).
- [ ] Write `README.md` at package root.
- [ ] `bunx turbo lint typecheck test --filter=@suaveplan/audio-analysis-loudness` — green.
- [ ] `bunx turbo test:coverage --filter=@suaveplan/audio-analysis-loudness` — 100%.
- [ ] `bun scripts/publish.ts --package @suaveplan/audio-analysis-loudness`.
- [ ] Fold `specs/audio-analysis-loudness/spec.md` into `openspec/specs/audio-analysis-loudness/spec.md`.
- [ ] Archive to `openspec/archive/2026-05-14-enterprise-audio-analysis-loudness/`.