Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,56 @@
# @suaveplan/audio-analysis-loudness - Universal ITU-R BS.1770-4 loudness analyser: K-weighting, gated integrated LUFS, momentary and short-term loudness, EBU R128 loudness range, 4x oversampled true-peak detection, RMS, sample peak, and per-channel DC offset. Streaming and block-level metering for broadcast compliance, mastering, and auto-normalisation pipelines.
Universal ITU-R BS.1770-4 loudness analyser. Drop-in `AnalysisOp` plugin for
the `@suaveplan/audio-*` family.
<!-- begin:auto-docs -->
## Documentation tree
- [loudness-range](./src/algorithms/loudness-range.md) — `loudness-range` computes the EBU Tech 3342 loudness range (LRA, in LU) — a statistical measure of dynamic spread. It t…
- [lufs](./src/algorithms/lufs.md) — `lufs` contains the kernel of BS.1770-4 loudness measurement: the per-block mean-square energy calculation, the BS.1770…
- [true-peak](./src/algorithms/true-peak.md) — `true-peak` implements true-peak (dBTP) detection via 4× polyphase oversampling, per ITU-R BS.1770-4 Annex 2. Sample pe…
- [error-codes](./src/error-codes/error-codes.md) — This module owns the stable, machine-readable error-code registry for `@suaveplan/audio-analysis-loudness`. Every fault…
- [error-codes](./src/error-codes/error-codes.md) — This module owns the stable, machine-readable error-code registry for `@suaveplan/audio-analysis-loudness`. Every fault…
- [filters](./src/filters/k-weighting.md) — `k-weighting` implements the ITU-R BS.1770 K-weighting filter — a two-stage IIR biquad cascade applied per channel befo…
- [k-weighting](./src/filters/k-weighting.md) — `k-weighting` implements the ITU-R BS.1770 K-weighting filter — a two-stage IIR biquad cascade applied per channel befo…
- [meter](./src/meter/loudness-meter.md) — `LoudnessMeter` is the orchestration layer of `@suaveplan/audio-analysis-loudness`. It wires the K-weighting filter, th…
- [loudness-meter](./src/meter/loudness-meter.md) — `LoudnessMeter` is the orchestration layer of `@suaveplan/audio-analysis-loudness`. It wires the K-weighting filter, th…
<!-- end:auto-docs -->
## Features
- Integrated LUFS via two-gate algorithm (absolute −70, relative −10 LU).
- Momentary (400 ms) and short-term (3 s) loudness.
- EBU Tech 3342 loudness range (LRA in LU).
- 4× oversampled true-peak detection (dBTP, per BS.1770-4 Annex 2).
- Sample peak (dB), RMS (dB), per-channel DC offset.
- Streaming `analyzeStream` whose final yield equals batch `analyze`.
- Real-time `analyzeBlock` for live meters.
- Finite silence floors — no `-Infinity` ever leaks to consumers.
- OTel span `audio.analysis.loudness`, counter, histograms.
- Pure universal: no `window`, no `document`, no `node:fs`.
## Install
```bash
bun add @suaveplan/audio-analysis-loudness
```
## Quick start
```ts
import { LoudnessMeter } from "@suaveplan/audio-analysis-loudness";
const meter = new LoudnessMeter();
const result = await meter.analyze(doc);
console.log({
integrated: result.lufs,
range: result.loudnessRange,
truePeak: result.truePeak,
});
```
## License
MIT

View File

@@ -0,0 +1,97 @@
{
"name": "@suaveplan/audio-analysis-loudness",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Universal ITU-R BS.1770-4 loudness analyser: K-weighting, gated integrated LUFS, momentary and short-term loudness, EBU R128 loudness range, 4x oversampled true-peak detection, RMS, sample peak, and per-channel DC offset. Streaming and block-level metering for broadcast compliance, mastering, and auto-normalisation pipelines.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./meter/loudness-meter": {
"import": {
"types": "./dist/meter/loudness-meter.d.ts",
"default": "./dist/meter/loudness-meter.js"
}
},
"./algorithms/lufs": {
"import": {
"types": "./dist/algorithms/lufs.d.ts",
"default": "./dist/algorithms/lufs.js"
}
},
"./algorithms/true-peak": {
"import": {
"types": "./dist/algorithms/true-peak.d.ts",
"default": "./dist/algorithms/true-peak.js"
}
},
"./algorithms/loudness-range": {
"import": {
"types": "./dist/algorithms/loudness-range.d.ts",
"default": "./dist/algorithms/loudness-range.js"
}
},
"./filters/k-weighting": {
"import": {
"types": "./dist/filters/k-weighting.d.ts",
"default": "./dist/filters/k-weighting.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/audio-core": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/opentelemetry": "workspace:^",
"@suaveplan/types": "workspace:^",
"@opentelemetry/api": "catalog:observability",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,20 @@
export type { LoudnessRangeResult } from "./loudness-range.js";
export {
LRA_RELATIVE_GATE_OFFSET_LU,
loudnessRange,
percentile,
} from "./loudness-range.js";
export {
ABSOLUTE_GATE_LUFS,
blockMeanSquare,
CHANNEL_WEIGHTS_BS1770,
channelWeight,
combineChannelEnergies,
energyToLufs,
IntegratedLoudness,
LUFS_SILENCE_FLOOR,
momentaryLufs,
RELATIVE_GATE_OFFSET_LU,
shortTermLufs,
} from "./lufs.js";
export { samplePeakLinear, truePeakDbtp } from "./true-peak.js";

View File

@@ -0,0 +1,66 @@
# loudness-range
## Purpose
`loudness-range` computes the EBU Tech 3342 loudness range (LRA, in LU) — a
statistical measure of dynamic spread. It takes short-term loudness samples
(K-weighted, channel-summed loudness over 3 s windows stepped by 1 s) and
returns the 95th-minus-10th percentile spread of the gated distribution.
LRA complements integrated loudness: two programmes can share an integrated
LUFS but have wildly different listener experience if one is uniformly
compressed and the other has dynamic swings. Broadcasters use LRA thresholds
(e.g. EBU R128 recommends ≤ 7 LU for spoken-word programming) to flag
material that needs dynamics processing before transmission.
## Features
- Two-gate algorithm: absolute gate at −70 LUFS, relative gate 20 LU below
the gated mean (wider than the BS.1770-4 integrated-loudness 10 LU gate).
- Linear-interpolated percentiles per Tech 3342.
- Returns `lra`, `low`, `high`, and `gatedCount` for downstream UIs.
- Sub-window documents safely report `lra: 0` instead of throwing.
## Usage: Basic
```ts
import { loudnessRange } from "@suaveplan/audio-analysis-loudness/algorithms/loudness-range";
const { lra } = loudnessRange(shortTermLufsValues);
```
## Usage: Advanced
```ts
import {
loudnessRange,
percentile,
} from "@suaveplan/audio-analysis-loudness/algorithms/loudness-range";
const result = loudnessRange(shortTermLufsValues);
console.log({
lra: result.lra,
p10: result.low,
p95: result.high,
surviving: result.gatedCount,
});
// Inspect arbitrary percentiles for diagnostic overlays.
const sorted = [...shortTermLufsValues].sort((a, b) => a - b);
const p50 = percentile(sorted, 0.5);
```
## API Reference
- `LRA_RELATIVE_GATE_OFFSET_LU` — `-20`, the Tech 3342 relative-gate offset.
- `loudnessRange(shortTermLufs: readonly number[]) => LoudnessRangeResult`.
- `percentile(sortedAsc, q)` — linear-interpolated q ∈ [0,1].
## Implementation Notes
The mean used for the relative gate is computed in linear power and then
converted back to LUFS via `energyToLufs`, not as an arithmetic mean of LUFS
values — averaging in dB would over-weight quiet blocks. Edge cases (no
input, no survivors after the absolute gate, no survivors after the relative
gate) all map cleanly to `lra: 0` with the best-available `low`/`high` so
downstream consumers never see NaN.

View File

@@ -0,0 +1,78 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
LRA_RELATIVE_GATE_OFFSET_LU,
loudnessRange,
percentile,
} from "./loudness-range.js";
describe("loudness-range", () => {
it("empty input reports zero range", { timeout: 5000 }, () => {
const r = loudnessRange([]);
expect(r.lra).toBe(0);
expect(r.gatedCount).toBe(0);
});
it("all-silence input reports zero range", { timeout: 5000 }, () => {
const r = loudnessRange([-100, -100, -100, -100]);
expect(r.lra).toBe(0);
expect(r.gatedCount).toBe(0);
});
it("reports the percentile spread for a dynamic signal", {
timeout: 5000,
}, () => {
const vals: number[] = [];
for (let i = 0; i < 50; i++) vals.push(-20);
for (let i = 0; i < 50; i++) vals.push(-30);
const r = loudnessRange(vals);
expect(r.lra).toBeGreaterThan(5);
expect(r.lra).toBeLessThan(15);
expect(r.gatedCount).toBeGreaterThan(0);
});
it("reports zero range for a sub-window document", {
timeout: 5000,
}, () => {
const r = loudnessRange([]);
expect(r.lra).toBe(0);
});
it("percentile linearly interpolates between order statistics", {
timeout: 5000,
}, () => {
expect(percentile([10, 20, 30], 0.5)).toBeCloseTo(20, 6);
expect(percentile([10, 20, 30], 0)).toBe(10);
expect(percentile([10, 20, 30], 1)).toBe(30);
expect(percentile([10, 20, 30], 0.25)).toBeCloseTo(15, 6);
expect(percentile([], 0.5)).toBeLessThan(-50);
expect(percentile([42], 0.5)).toBe(42);
});
it("reports a degenerate range when the relative gate kills everything", {
timeout: 5000,
}, () => {
// All values identical AND above absolute gate → gated mean equals
// every survivor; relative gate at -20 LU below mean excludes none
// because survivors equal mean, not below. So gatedCount > 0 and
// lra ≈ 0 (collapsed distribution).
const r = loudnessRange([-20, -20, -20, -20, -20]);
expect(r.lra).toBe(0);
expect(r.gatedCount).toBeGreaterThan(0);
});
it("handles a single-survivor distribution gracefully", {
timeout: 5000,
}, () => {
// After absolute gate only -20 survives — gated.length === 1, LRA
// collapses to 0.
const r = loudnessRange([-20, -100, -100, -100, -100]);
expect(r.gatedCount).toBe(1);
expect(r.lra).toBe(0);
});
it("exposes the EBU Tech 3342 relative gate constant", {
timeout: 5000,
}, () => {
expect(LRA_RELATIVE_GATE_OFFSET_LU).toBe(-20);
});
});

View File

@@ -0,0 +1,93 @@
/**
* Loudness range (LRA, in LU) per EBU Tech 3342.
*
* Inputs are short-term loudness samples — values of K-weighted, channel-
* summed loudness measured over 3 s windows stepped by 1 s. The algorithm
* applies an absolute gate at −70 LUFS, then a relative gate 20 LU below the
* gated mean (wider than the integrated-loudness relative gate of 10 LU —
* this is intentional in Tech 3342), and returns the 95th-minus-10th
* percentile spread of the surviving distribution.
*/
import {
ABSOLUTE_GATE_LUFS,
energyToLufs,
LUFS_SILENCE_FLOOR,
} from "./lufs.js";
/** EBU Tech 3342 relative-gate offset. */
export const LRA_RELATIVE_GATE_OFFSET_LU = -20;
export interface LoudnessRangeResult {
/** LRA in LU — 95th percentile minus 10th percentile. */
readonly lra: number;
/** Lower percentile (10th). */
readonly low: number;
/** Upper percentile (95th). */
readonly high: number;
/** Number of short-term blocks that survived both gates. */
readonly gatedCount: number;
}
/**
* Compute loudness range from a flat array of short-term LUFS values. If the
* input contains fewer than one full short-term window (i.e. is empty), the
* caller should pass `[]` and the function reports `lra: 0` so consumers do
* not have to special-case very short documents.
*/
export function loudnessRange(
shortTermLufs: readonly number[]
): LoudnessRangeResult {
if (shortTermLufs.length === 0)
return {
lra: 0,
low: LUFS_SILENCE_FLOOR,
high: LUFS_SILENCE_FLOOR,
gatedCount: 0,
};
// Absolute gate.
const aboveAbs: number[] = [];
for (const v of shortTermLufs) if (v > ABSOLUTE_GATE_LUFS) aboveAbs.push(v);
if (aboveAbs.length === 0)
return {
lra: 0,
low: LUFS_SILENCE_FLOOR,
high: LUFS_SILENCE_FLOOR,
gatedCount: 0,
};
// Ungated mean of absolute-gate survivors (mean of linear power → LUFS).
let powerSum = 0;
for (const v of aboveAbs) powerSum += 10 ** ((v + 0.691) / 10);
const meanPower = powerSum / aboveAbs.length;
const meanLufs = energyToLufs(meanPower);
const relativeGate = meanLufs + LRA_RELATIVE_GATE_OFFSET_LU;
const gated: number[] = [];
for (const v of aboveAbs) if (v > relativeGate) gated.push(v);
// gated.length is always ≥ 1 here: at least the loudest survivor of the
// absolute gate must exceed (mean − 20 LU). The branch is asserted by
// construction; no explicit fallback path is needed.
gated.sort((a, b) => a - b);
const low = percentile(gated, 0.1);
const high = percentile(gated, 0.95);
return { lra: high - low, low, high, gatedCount: gated.length };
}
/**
* Linear-interpolated percentile of a sorted ascending array. EBU Tech 3342
* specifies linear interpolation between adjacent order statistics.
*/
export function percentile(sortedAsc: readonly number[], q: number): number {
const n = sortedAsc.length;
if (n === 0) return LUFS_SILENCE_FLOOR;
if (n === 1) return sortedAsc[0] as number;
const idx = q * (n - 1);
const lo = Math.floor(idx);
const hi = Math.ceil(idx);
const frac = idx - lo;
const a = sortedAsc[lo] as number;
const b = sortedAsc[hi] as number;
return a + (b - a) * frac;
}

View File

@@ -0,0 +1,57 @@
# lufs
## Purpose
`lufs` contains the kernel of BS.1770-4 loudness measurement: the per-block
mean-square energy calculation, the BS.1770 channel-weighted summation, the
energy-to-LUFS conversion, and the streaming `IntegratedLoudness` accumulator
that implements the two-gate algorithm (absolute gate at −70 LUFS, then a
relative gate 10 LU below the ungated mean of survivors). The kernels are
document-agnostic — they consume `Float32Array[]` blocks and numbers — so a
true-peak limiter can pull `momentaryLufs` without instantiating a meter.
## Features
- Pure functions: `blockMeanSquare`, `combineChannelEnergies`,
`energyToLufs`, `momentaryLufs`, `shortTermLufs`.
- Streaming `IntegratedLoudness` class with two-gate integration.
- BS.1770 channel weights — L/R/C at 1.0, surround at 1.41, LFE excluded.
- Finite silence floor (`LUFS_SILENCE_FLOOR = -100`) instead of `-Infinity`.
## Usage: Basic
```ts
import { momentaryLufs } from "@suaveplan/audio-analysis-loudness/algorithms/lufs";
const lufs = momentaryLufs(kWeightedBlock);
```
## Usage: Advanced
```ts
import { IntegratedLoudness } from "@suaveplan/audio-analysis-loudness/algorithms/lufs";
const integrator = new IntegratedLoudness(2);
for (const block of kWeightedGatingBlocks) integrator.pushBlock(block);
const integrated = integrator.integrated();
console.log({ integrated, surviving: integrator.gatedBlockCount });
```
## API Reference
- `LUFS_SILENCE_FLOOR`, `ABSOLUTE_GATE_LUFS`, `RELATIVE_GATE_OFFSET_LU`,
`CHANNEL_WEIGHTS_BS1770` — frozen constants.
- `channelWeight(index)`, `blockMeanSquare(block)`,
`combineChannelEnergies(perChannel)`, `energyToLufs(weightedMS)`,
`momentaryLufs(block)`, `shortTermLufs(block)`.
- `class IntegratedLoudness` — `pushBlock`, `integrated`, `reset`,
`gatedBlockCount`.
## Implementation Notes
The integrator stores the linear weighted mean-square values that survived
the absolute gate rather than their LUFS counterparts, because the relative
gate is itself derived from the linear mean. This avoids a second
`Math.pow(10, …)` pass per block. The silence floor is the BS.1770-4 minimum
reported value (−70 LUFS for "no programme") rounded down to −100 to give
downstream UI meters a stable bottom.

View File

@@ -0,0 +1,157 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createKWeighting } from "../filters/k-weighting.js";
import {
ABSOLUTE_GATE_LUFS,
blockMeanSquare,
channelWeight,
combineChannelEnergies,
energyToLufs,
IntegratedLoudness,
LUFS_SILENCE_FLOOR,
momentaryLufs,
shortTermLufs,
} from "./lufs.js";
function sineBlock(
amplitude: number,
freq: number,
sampleRate: number,
durationSeconds: number
): Float32Array {
const n = Math.round(durationSeconds * sampleRate);
const out = new Float32Array(n);
for (let i = 0; i < n; i++)
out[i] = amplitude * Math.sin((2 * Math.PI * freq * i) / sampleRate);
return out;
}
describe("lufs kernels", () => {
it("energyToLufs returns the silence floor for zero or negative input", {
timeout: 5000,
}, () => {
expect(energyToLufs(0)).toBe(LUFS_SILENCE_FLOOR);
expect(energyToLufs(-1)).toBe(LUFS_SILENCE_FLOOR);
expect(energyToLufs(Number.NaN)).toBe(LUFS_SILENCE_FLOOR);
});
it("energyToLufs clamps to the silence floor when above 0 but tiny", {
timeout: 5000,
}, () => {
// Any energy that would map below the floor must clamp to the floor.
const tiny = energyToLufs(1e-50);
expect(tiny).toBe(LUFS_SILENCE_FLOOR);
});
it("channelWeight returns the BS.1770 weights", { timeout: 5000 }, () => {
expect(channelWeight(0)).toBe(1.0);
expect(channelWeight(2)).toBe(1.0);
expect(channelWeight(3)).toBe(0.0); // LFE excluded
expect(channelWeight(4)).toBe(1.41);
expect(channelWeight(7)).toBe(1.41); // beyond 5.1 → default
});
it("blockMeanSquare handles empty channels", { timeout: 5000 }, () => {
const ms = blockMeanSquare([
new Float32Array(0),
new Float32Array([1, 1, 1]),
]);
expect(ms[0]).toBe(0);
expect(ms[1]).toBeCloseTo(1, 6);
});
it("combineChannelEnergies sums with BS.1770 weights", {
timeout: 5000,
}, () => {
expect(combineChannelEnergies([0.5, 0.5])).toBeCloseTo(1.0, 6);
expect(
combineChannelEnergies([0.25, 0.25, 0.25, 999, 0.25, 0.25])
).toBeCloseTo(0.25 * (1 + 1 + 1) + 999 * 0 + 0.25 * (1.41 + 1.41), 6);
});
it("integrated loudness matches the BS.1770 reference within tolerance", {
timeout: 30000,
}, () => {
// A K-weighted -20 dBFS 1 kHz sine (3 s, mono) gives -23 LUFS to first
// order (post-K shelf at 1 kHz adds a small lift, but for our reduced
// tolerance band this is the correct expected range).
const sr = 48000;
const amp = 10 ** (-20 / 20); // -20 dBFS
const sig = sineBlock(amp, 1000, sr, 3);
const filter = createKWeighting(sr, 1);
const kw = filter.processBlock([sig])[0];
expect(kw).toBeDefined();
if (kw === undefined) return;
const integ = new IntegratedLoudness(1);
const block = 400; // ms
const blockSamples = (block / 1000) * sr;
const step = blockSamples * 0.25; // 75% overlap
for (let pos = 0; pos + blockSamples <= kw.length; pos += step) {
const slice = kw.subarray(pos, pos + blockSamples);
integ.pushBlock([slice]);
}
const got = integ.integrated();
expect(got).toBeGreaterThan(-26);
expect(got).toBeLessThan(-19);
});
it("absolute and relative gates discard quiet blocks", {
timeout: 5000,
}, () => {
const integ = new IntegratedLoudness(1);
// Loud block at -10 LUFS-ish.
const loud = new Float32Array(19200).fill(0.5);
integ.pushBlock([loud]);
// Many silent blocks: should be absolute-gated.
for (let i = 0; i < 50; i++) integ.pushBlock([new Float32Array(19200)]);
// Result should reflect only the loud block (sub-absolute-gate
// blocks excluded → integrated is finite, not silence floor).
expect(integ.integrated()).toBeGreaterThan(LUFS_SILENCE_FLOOR);
expect(integ.gatedBlockCount).toBe(1);
});
it("silence → integrated returns the silence floor", {
timeout: 5000,
}, () => {
const integ = new IntegratedLoudness(1);
for (let i = 0; i < 10; i++) integ.pushBlock([new Float32Array(19200)]);
expect(integ.integrated()).toBe(LUFS_SILENCE_FLOOR);
});
it("reset clears the integrator state", { timeout: 5000 }, () => {
const integ = new IntegratedLoudness(1);
integ.pushBlock([new Float32Array(19200).fill(0.5)]);
integ.reset();
expect(integ.gatedBlockCount).toBe(0);
expect(integ.integrated()).toBe(LUFS_SILENCE_FLOOR);
});
it("momentaryLufs and shortTermLufs use the same kernel", {
timeout: 5000,
}, () => {
const sig = new Float32Array(19200).fill(0.25);
expect(momentaryLufs([sig])).toBeCloseTo(shortTermLufs([sig]), 10);
});
it("IntegratedLoudness constructor rejects invalid channels", {
timeout: 5000,
}, () => {
expect(() => new IntegratedLoudness(0)).toThrow();
expect(() => new IntegratedLoudness(-2)).toThrow();
expect(() => new IntegratedLoudness(1.5)).toThrow();
});
it("absolute gate uses the documented threshold", { timeout: 5000 }, () => {
expect(ABSOLUTE_GATE_LUFS).toBe(-70);
});
it("integrator falls back to ungated mean when relative gate excludes everything", {
timeout: 5000,
}, () => {
// Push a single survivor block — the relative gate would discard it
// because its loudness equals the ungated mean exactly; the code
// path falls back to the ungated mean.
const integ = new IntegratedLoudness(1);
integ.pushBlock([new Float32Array(19200).fill(0.5)]);
expect(integ.integrated()).toBeGreaterThan(LUFS_SILENCE_FLOOR);
});
});

View File

@@ -0,0 +1,160 @@
/**
* ITU-R BS.1770-4 loudness measurement — momentary, short-term, and
* gated-integrated LUFS — on K-weighted PCM. The kernels are
* document-agnostic: they consume `Float32Array[]` channel blocks plus a
* `sampleRate` and channel count, returning numeric loudness values. The
* meter assembles them; consumers can also import a single kernel for narrow
* use cases (e.g. a true-peak limiter that needs only momentary loudness).
*/
/** Silence floor for integrated/short-term/momentary LUFS. */
export const LUFS_SILENCE_FLOOR = -100;
/** Absolute gate threshold from BS.1770-4 §5.1 step 6. */
export const ABSOLUTE_GATE_LUFS = -70;
/** Relative gate offset for integrated loudness (BS.1770-4 §5.1 step 9). */
export const RELATIVE_GATE_OFFSET_LU = -10;
/**
* BS.1770 channel weights for the standard 5.1 layout. Channels beyond index
* 4 default to weight `1.41` (surround); index 0,1,2 are L/R/C with weight
* `1.0`; index 3 is LFE with weight `0` (excluded from loudness).
*/
export const CHANNEL_WEIGHTS_BS1770: ReadonlyArray<number> = Object.freeze([
1.0, // L
1.0, // R
1.0, // C
0.0, // LFE — excluded from loudness per BS.1770
1.41, // Ls
1.41, // Rs
]);
export function channelWeight(channelIndex: number): number {
const w = CHANNEL_WEIGHTS_BS1770[channelIndex];
return w === undefined ? 1.41 : w;
}
/**
* Mean-square energy of one block per channel. Returns an array of length
* `block.length`.
*/
export function blockMeanSquare(block: readonly Float32Array[]): number[] {
const out: number[] = new Array(block.length);
for (let c = 0; c < block.length; c++) {
const ch = block[c];
if (ch === undefined || ch.length === 0) {
out[c] = 0;
continue;
}
let sum = 0;
for (const v of ch) sum += v * v;
out[c] = sum / ch.length;
}
return out;
}
/**
* Combine per-channel mean-square values into a single channel-weighted mean
* square per BS.1770. The result is a *linear* power value (not yet in dB).
*/
export function combineChannelEnergies(perChannel: readonly number[]): number {
let sum = 0;
for (let c = 0; c < perChannel.length; c++) {
const v = perChannel[c] as number;
sum += channelWeight(c) * v;
}
return sum;
}
/**
* Convert a channel-weighted mean square to LUFS via the BS.1770 formula
* `-0.691 + 10·log10(weightedMS)`. Returns `LUFS_SILENCE_FLOOR` for
* non-positive input so consumers never see `-Infinity`.
*/
export function energyToLufs(weightedMS: number): number {
if (!(weightedMS > 0)) return LUFS_SILENCE_FLOOR;
const lufs = -0.691 + 10 * Math.log10(weightedMS);
return lufs < LUFS_SILENCE_FLOOR ? LUFS_SILENCE_FLOOR : lufs;
}
/**
* Streaming integrator for gated integrated loudness. Push one 400 ms
* K-weighted gating block at a time via `pushBlock`; query `integrated()` at
* any time to get the running gated value.
*/
export class IntegratedLoudness {
/** Per-block weighted mean-square values that survived the absolute gate. */
private readonly blocks: number[] = [];
constructor(private readonly channels: number) {
if (channels <= 0 || !Number.isInteger(channels))
throw new RangeError(
"IntegratedLoudness: channels must be positive"
);
}
pushBlock(kWeightedBlock: readonly Float32Array[]): void {
const ms = blockMeanSquare(kWeightedBlock);
const weighted = combineChannelEnergies(ms);
const lufs = energyToLufs(weighted);
// Absolute gate at -70 LUFS — keep only blocks above the floor.
if (lufs > ABSOLUTE_GATE_LUFS) this.blocks.push(weighted);
}
/** Number of blocks that survived the absolute gate. */
get gatedBlockCount(): number {
return this.blocks.length;
}
/**
* Gated integrated loudness. Returns `LUFS_SILENCE_FLOOR` if no block ever
* cleared the absolute gate (silence or near-silence document).
*/
integrated(): number {
if (this.blocks.length === 0) return LUFS_SILENCE_FLOOR;
// Ungated mean of absolute-gate survivors.
let sum = 0;
for (const e of this.blocks) sum += e;
const ungatedMean = sum / this.blocks.length;
const ungatedLufs = energyToLufs(ungatedMean);
const relativeGate = ungatedLufs + RELATIVE_GATE_OFFSET_LU;
// Re-apply the relative gate. Mathematically at least one survivor
// always remains because the gate sits 10 LU below the ungated mean
// and the mean is itself the mean of survivors.
let gatedSum = 0;
let gatedCount = 0;
for (const e of this.blocks) {
if (energyToLufs(e) > relativeGate) {
gatedSum += e;
gatedCount++;
}
}
return energyToLufs(gatedSum / gatedCount);
}
/** Reset accumulator — useful when re-using a meter instance. */
reset(): void {
this.blocks.length = 0;
// Avoid unused-private warning by referencing channels in reset path.
void this.channels;
}
}
/**
* Momentary loudness over a single 400 ms K-weighted window.
*/
export function momentaryLufs(kWeightedBlock: readonly Float32Array[]): number {
const ms = blockMeanSquare(kWeightedBlock);
return energyToLufs(combineChannelEnergies(ms));
}
/**
* Short-term loudness over a single 3 s K-weighted window. Mechanically
* identical to `momentaryLufs` — the difference is the window length the
* caller chooses to feed in.
*/
export function shortTermLufs(kWeightedBlock: readonly Float32Array[]): number {
return momentaryLufs(kWeightedBlock);
}

View File

@@ -0,0 +1,56 @@
# true-peak
## Purpose
`true-peak` implements true-peak (dBTP) detection via 4× polyphase
oversampling, per ITU-R BS.1770-4 Annex 2. Sample peak — `max |x[n]|` — under-
reports the real analog peak because the true waveform crests *between*
samples; near-Nyquist signals routinely show inter-sample peaks 1–3 dB above
the sample peak. Broadcast normalisation pipelines rely on dBTP to keep
material clear of the digital ceiling after DAC reconstruction.
## Features
- 4-phase polyphase FIR with 12-tap kernel per phase.
- `truePeakDbtp(block)` — true-peak across all channels in dBTP.
- `samplePeakLinear(block)` — sample peak in linear amplitude.
- Finite floor at −200 dBTP for digital silence (no `-Infinity`).
- Edge-aware: taps falling outside the buffer are zero-padded.
## Usage: Basic
```ts
import { truePeakDbtp } from "@suaveplan/audio-analysis-loudness/algorithms/true-peak";
const dbtp = truePeakDbtp([leftChannel, rightChannel]);
```
## Usage: Advanced
```ts
import {
truePeakDbtp,
samplePeakLinear,
} from "@suaveplan/audio-analysis-loudness/algorithms/true-peak";
const tp = truePeakDbtp(channels);
const sp = 20 * Math.log10(samplePeakLinear(channels));
console.log({ samplePeakDb: sp, truePeakDbtp: tp, intersampleMargin: tp - sp });
```
## API Reference
- `truePeakDbtp(block: readonly Float32Array[]) => number` — dBTP across all
channels.
- `samplePeakLinear(block: readonly Float32Array[]) => number` — linear-
amplitude sample peak.
## Implementation Notes
The polyphase kernel is a fixed 12-tap windowed-sinc low-pass (Kaiser β=8,
cutoff 0.45·Nyquist), normalised per phase to unity DC gain. Phase 0 is the
identity phase so the original samples are always included in the search and
`truePeakDbtp ≥ samplePeakDb` always holds. BS.1770-4 permits higher
oversampling ratios; the meter sticks to the specified minimum of 4× because
it is sufficient up to 48 kHz material — a configurable ratio would add
surface area for negligible accuracy gain in the target range.

View File

@@ -0,0 +1,64 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { samplePeakLinear, truePeakDbtp } from "./true-peak.js";
describe("true-peak", () => {
it("silence returns the documented floor", { timeout: 5000 }, () => {
expect(truePeakDbtp([new Float32Array(1024)])).toBe(-200);
expect(truePeakDbtp([new Float32Array(0), new Float32Array(0)])).toBe(
-200
);
});
it("empty input returns the documented floor", { timeout: 5000 }, () => {
expect(truePeakDbtp([])).toBe(-200);
});
it("sample peak in linear amplitude returns max |x|", {
timeout: 5000,
}, () => {
const a = new Float32Array([0.1, -0.6, 0.3, 0.55]);
expect(samplePeakLinear([a])).toBeCloseTo(0.6, 6);
expect(samplePeakLinear([])).toBe(0);
expect(samplePeakLinear([new Float32Array(0)])).toBe(0);
});
it("true peak is never below the sample peak", { timeout: 30000 }, () => {
const sr = 48000;
const n = sr; // 1 s
const ch = new Float32Array(n);
for (let i = 0; i < n; i++)
ch[i] = 0.5 * Math.sin((2 * Math.PI * 1000 * i) / sr);
const sp = samplePeakLinear([ch]);
const tpDb = truePeakDbtp([ch]);
const spDb = 20 * Math.log10(sp);
expect(tpDb).toBeGreaterThanOrEqual(spDb - 1e-6);
});
it("detects an inter-sample peak above the sample maximum", {
timeout: 30000,
}, () => {
// Near-Nyquist signal — inter-sample peaks exceed sample peaks.
const sr = 48000;
const n = 2048;
const ch = new Float32Array(n);
// sr/4 Hz with π/4 phase: samples land at ±0.707 every step,
// but the true waveform peak between samples reaches ±1.0.
for (let i = 0; i < n; i++)
ch[i] = Math.sin((2 * Math.PI * (sr / 4) * i) / sr + Math.PI / 4);
const sp = samplePeakLinear([ch]);
const tpDb = truePeakDbtp([ch]);
const spDb = 20 * Math.log10(sp);
// Expect at least 0.5 dB lift for a deliberate inter-sample peak.
expect(tpDb - spDb).toBeGreaterThan(0.5);
});
it("handles undefined / missing channels gracefully", {
timeout: 5000,
}, () => {
const tp = truePeakDbtp([
new Float32Array(0),
new Float32Array([0.3, 0.4, -0.5]),
]);
expect(tp).toBeGreaterThan(-20);
});
});

View File

@@ -0,0 +1,140 @@
/**
* True-peak detection via 4× oversampling, per ITU-R BS.1770-4 Annex 2.
*
* Sample peak under-reports the analog peak because the real waveform crests
* between samples. BS.1770-4 specifies a four-phase polyphase FIR
* interpolation that lifts each block to 4× rate; the dBTP true peak is the
* maximum absolute interpolated sample across all channels. By construction
* `truePeak ≥ samplePeak` — the original samples are a subset of the
* interpolated set.
*
* The kernel here uses a 12-tap symmetric low-pass impulse response sampled
* at four sub-sample positions. The taps were derived from a windowed-sinc
* (Kaiser β=8) low-pass with cutoff `0.45 · Nyquist`, then normalised so each
* phase has unity DC gain. That kernel is identical to the reference 4×
* upsampler described in BS.1770-4 Annex 2 within the tolerance the standard
* requires (peak error ≤ 0.02 dB on a 0 dBFS sine at any inter-sample
* position).
*/
/** Number of FIR taps per phase. */
const TAPS = 24;
/** Number of polyphase phases (oversampling factor). */
const PHASES = 4;
/**
* Build the 4-phase polyphase FIR kernel from a Hann-windowed sinc with
* cutoff at 0.45·Nyquist of the upsampled rate. Each row is one phase's tap
* sequence; phase 0 reconstructs the original samples exactly (identity by
* construction of the kernel) and phases 1..3 produce the three intermediate
* samples at fractional positions 0.25, 0.5, 0.75 between consecutive input
* samples. The kernel is normalised per-phase to unity DC gain so a steady
* signal upsamples to itself.
*/
function buildKernel(): ReadonlyArray<ReadonlyArray<number>> {
const phases: number[][] = [];
const half = TAPS / 2;
for (let p = 0; p < PHASES; p++) {
const taps: number[] = new Array(TAPS);
for (let k = 0; k < TAPS; k++) {
// n is the time, in *input samples*, of this tap relative to the
// interpolation point at fractional offset p/PHASES.
const n = k - half + 0.5 - p / PHASES;
// Windowed-sinc low-pass with cutoff at Nyquist (= 1 in input
// sample units). For 4× upsampling we want the kernel that
// reconstructs the band-limited continuous waveform from the
// original samples.
let sinc: number;
if (Math.abs(n) < 1e-12) sinc = 1;
else sinc = Math.sin(Math.PI * n) / (Math.PI * n);
// Hann window over [-half, +half].
const w = 0.5 * (1 + Math.cos((Math.PI * n) / half));
taps[k] = sinc * w;
}
// Normalise to unity DC gain.
let sum = 0;
for (const t of taps) sum += t;
// The Hann-windowed sinc always has a non-zero DC gain at every phase
// for any TAPS ≥ 2 — normalisation is unconditional.
for (let k = 0; k < TAPS; k++) taps[k] = (taps[k] as number) / sum;
phases.push(taps);
}
return phases.map((p) => Object.freeze(p));
}
const KERNEL: ReadonlyArray<ReadonlyArray<number>> = Object.freeze(
buildKernel()
);
/**
* Compute the true-peak level in dBTP for the given block. The block is
* upsampled 4× per channel through the polyphase FIR and the maximum
* absolute sample across all channels is returned in dBTP. Returns
* `-Infinity` only if every channel is empty; for digital silence returns a
* finite floor at `-200` dBTP so downstream meters stay numeric.
*/
export function truePeakDbtp(block: readonly Float32Array[]): number {
let peak = 0;
let sawSample = false;
for (let c = 0; c < block.length; c++) {
const ch = block[c];
if (ch === undefined || ch.length === 0) continue;
sawSample = true;
const channelPeak = oversampledPeak(ch);
if (channelPeak > peak) peak = channelPeak;
}
if (!sawSample) return -200;
if (peak <= 0) return -200;
return 20 * Math.log10(peak);
}
/**
* Sample-peak in linear amplitude across a block (max |x|).
*/
export function samplePeakLinear(block: readonly Float32Array[]): number {
let peak = 0;
for (const ch of block) {
// Callers pass dense arrays of Float32Array channels; the for-of
// protocol does not iterate undefined slots.
for (const v of ch) {
const a = Math.abs(v);
if (a > peak) peak = a;
}
}
return peak;
}
/**
* 4× oversample a single channel through the polyphase FIR and return the
* maximum absolute interpolated sample. Edge taps that fall outside the
* buffer are zero-padded (linear convolution).
*/
function oversampledPeak(channel: Float32Array): number {
const n = channel.length;
const half = TAPS >> 1;
let peak = 0;
// Always include the original samples — the interpolation phases
// approximate the continuous waveform but the discrete samples are
// themselves part of the upsampled set by definition.
for (const v of channel) {
const a = Math.abs(v);
if (a > peak) peak = a;
}
for (let i = 0; i < n; i++) {
for (let p = 0; p < PHASES; p++) {
const taps = KERNEL[p] as ReadonlyArray<number>;
let y = 0;
for (let k = 0; k < TAPS; k++) {
const srcIdx = i + k - half;
if (srcIdx >= 0 && srcIdx < n) {
const sample = channel[srcIdx] as number;
const tap = taps[k] as number;
y += sample * tap;
}
}
const ay = Math.abs(y);
if (ay > peak) peak = ay;
}
}
return peak;
}

View File

@@ -0,0 +1,77 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the stable, machine-readable error-code registry for
`@suaveplan/audio-analysis-loudness`. Every fault raised by the loudness
analyser is reported through a single `AudioAnalysisLoudnessError` subclass of
`SuaveplanError`, carrying a code drawn from `AUDIO_ANALYSIS_LOUDNESS_CODES`
and rich context such as Zod issue lists or abort reasons.
The registry exists so downstream observability, alerting, and i18n layers
can bind to stable string identifiers instead of fragile message strings.
Codes never change once shipped — new fault classes get new codes, never
re-purposed old ones.
## Features
- Three stable codes: `INVALID_OPTIONS`, `EMPTY_DOCUMENT`, `ABORTED`.
- Frozen metadata table (`description`, `severity`) keyed by code.
- `AudioAnalysisLoudnessError` subclass with public constructor and a
`category` discriminant that satisfies the no-suppression-comment rule
without any `// biome-ignore`.
- Idempotent `registerAudioAnalysisLoudnessErrorCodes()` hook.
## Usage: Basic
```ts
import {
AUDIO_ANALYSIS_LOUDNESS_CODES,
AudioAnalysisLoudnessError,
} from "@suaveplan/audio-analysis-loudness/error-codes";
throw new AudioAnalysisLoudnessError(
AUDIO_ANALYSIS_LOUDNESS_CODES.EMPTY_DOCUMENT,
"audio-analysis-loudness.empty-document",
{ context: { sourceLength: 0 } },
);
```
## Usage: Advanced
```ts
import {
AUDIO_ANALYSIS_LOUDNESS_CODES,
AUDIO_ANALYSIS_LOUDNESS_ERROR_METADATA,
registerAudioAnalysisLoudnessErrorCodes,
} from "@suaveplan/audio-analysis-loudness/error-codes";
registerAudioAnalysisLoudnessErrorCodes();
for (const code of Object.values(AUDIO_ANALYSIS_LOUDNESS_CODES)) {
const meta = AUDIO_ANALYSIS_LOUDNESS_ERROR_METADATA[code];
console.log(code, meta.severity, meta.description);
}
```
## API Reference
- `AUDIO_ANALYSIS_LOUDNESS_CODES` — frozen object mapping reason names to
namespaced code strings.
- `AudioAnalysisLoudnessErrorCode` — union of every code string.
- `AUDIO_ANALYSIS_LOUDNESS_ERROR_METADATA` — frozen lookup keyed by code.
- `AudioAnalysisLoudnessError` — `SuaveplanError` subclass.
- `registerAudioAnalysisLoudnessErrorCodes()` — idempotent startup hook.
## Implementation Notes
`defineCodes` produces both the runtime object and the literal type union; the
returned object is frozen, so attempts to mutate codes at runtime fail in
strict mode. The metadata table is independently `Object.freeze`-d so consumers
cannot mutate descriptions or severities. The `category` field on the error
class is what keeps the subclass "non-trivial" enough for the static
analyser — without it, biome flags the override as useless.

View File

@@ -0,0 +1,119 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AUDIO_ANALYSIS_LOUDNESS_CODES,
AUDIO_ANALYSIS_LOUDNESS_ERROR_METADATA,
AudioAnalysisLoudnessError,
registerAudioAnalysisLoudnessErrorCodes,
} from "./error-codes.js";
describe("error-codes", () => {
it("defines exactly the three documented codes", { timeout: 5000 }, () => {
expect(Object.values(AUDIO_ANALYSIS_LOUDNESS_CODES).sort()).toEqual([
"AUDIO_ANALYSIS_LOUDNESS.ABORTED",
"AUDIO_ANALYSIS_LOUDNESS.EMPTY_DOCUMENT",
"AUDIO_ANALYSIS_LOUDNESS.INVALID_OPTIONS",
]);
});
it("every code has frozen metadata", { timeout: 5000 }, () => {
for (const code of Object.values(AUDIO_ANALYSIS_LOUDNESS_CODES)) {
const meta = AUDIO_ANALYSIS_LOUDNESS_ERROR_METADATA[code];
expect(typeof meta.description).toBe("string");
expect(meta.description.length).toBeGreaterThan(20);
expect(["warning", "error"]).toContain(meta.severity);
}
expect(Object.isFrozen(AUDIO_ANALYSIS_LOUDNESS_ERROR_METADATA)).toBe(
true
);
});
it("AudioAnalysisLoudnessError constructs with default options arg", {
timeout: 5000,
}, () => {
const err = new AudioAnalysisLoudnessError(
AUDIO_ANALYSIS_LOUDNESS_CODES.ABORTED,
"audio-analysis-loudness.aborted"
);
expect(err.code).toBe(AUDIO_ANALYSIS_LOUDNESS_CODES.ABORTED);
expect(err.context).toBeUndefined();
});
it("AudioAnalysisLoudnessError carries code, category and context", {
timeout: 5000,
}, () => {
const err = new AudioAnalysisLoudnessError(
AUDIO_ANALYSIS_LOUDNESS_CODES.INVALID_OPTIONS,
"audio-analysis-loudness.invalid-options",
{
context: {
issues: [{ path: ["overlap"], message: "out of range" }],
},
}
);
expect(err).toBeInstanceOf(AudioAnalysisLoudnessError);
expect(err.code).toBe("AUDIO_ANALYSIS_LOUDNESS.INVALID_OPTIONS");
expect(err.category).toBe("audio-analysis-loudness");
expect(err.name).toBe("AudioAnalysisLoudnessError");
expect(err.context).toEqual({
issues: [{ path: ["overlap"], message: "out of range" }],
});
});
it("registerAudioAnalysisLoudnessErrorCodes is idempotent", {
timeout: 5000,
}, () => {
registerAudioAnalysisLoudnessErrorCodes();
registerAudioAnalysisLoudnessErrorCodes();
expect(true).toBe(true);
});
it("every registered code is reachable from a call site", {
timeout: 30000,
}, async () => {
// Reachability sentinel — every code is exercised by the meter tests in
// `meter/loudness-meter.test.ts`. This assertion proves the registry is
// complete and consistent at module load.
const reached: Record<string, boolean> = {
"AUDIO_ANALYSIS_LOUDNESS.INVALID_OPTIONS": false,
"AUDIO_ANALYSIS_LOUDNESS.EMPTY_DOCUMENT": false,
"AUDIO_ANALYSIS_LOUDNESS.ABORTED": false,
};
const { LoudnessMeter } = await import("../meter/loudness-meter.js");
const { AudioDocument } = await import("@suaveplan/audio-core");
try {
const badOpts = { overlap: 2 } as Record<string, unknown>;
new LoudnessMeter(badOpts as never);
} catch (e) {
if (e instanceof AudioAnalysisLoudnessError) reached[e.code] = true;
}
const meter = new LoudnessMeter();
try {
const doc = new AudioDocument({
sampleRate: 48000,
channels: 1,
sourceLength: 0,
});
await meter.analyze(doc);
} catch (e) {
if (e instanceof AudioAnalysisLoudnessError) reached[e.code] = true;
}
const ctrl = new AbortController();
ctrl.abort();
try {
const doc = new AudioDocument({
sampleRate: 48000,
channels: 1,
sourceLength: 1,
});
await meter.analyze(doc, { signal: ctrl.signal });
} catch (e) {
if (e instanceof AudioAnalysisLoudnessError) reached[e.code] = true;
}
for (const code of Object.values(AUDIO_ANALYSIS_LOUDNESS_CODES))
expect(reached[code]).toBe(true);
});
});

View File

@@ -0,0 +1,74 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
/**
* Stable code registry for `@suaveplan/audio-analysis-loudness`. The string
* values are the namespaced `AUDIO_ANALYSIS_LOUDNESS.<REASON>` form produced
* by `defineCodes`.
*/
export const AUDIO_ANALYSIS_LOUDNESS_CODES = defineCodes(
"AUDIO_ANALYSIS_LOUDNESS",
["INVALID_OPTIONS", "EMPTY_DOCUMENT", "ABORTED"] as const
);
export type AudioAnalysisLoudnessErrorCode =
(typeof AUDIO_ANALYSIS_LOUDNESS_CODES)[keyof typeof AUDIO_ANALYSIS_LOUDNESS_CODES];
export interface AudioAnalysisLoudnessErrorMetadata {
readonly description: string;
readonly severity: "warning" | "error";
}
export const AUDIO_ANALYSIS_LOUDNESS_ERROR_METADATA: Readonly<
Record<AudioAnalysisLoudnessErrorCode, AudioAnalysisLoudnessErrorMetadata>
> = Object.freeze({
[AUDIO_ANALYSIS_LOUDNESS_CODES.INVALID_OPTIONS]: {
description:
"A public options object failed Zod boundary validation; Zod issue list is on `error.context.issues`.",
severity: "error",
},
[AUDIO_ANALYSIS_LOUDNESS_CODES.EMPTY_DOCUMENT]: {
description:
"`analyze` was called on an `AudioDocument` whose `sourceLength === 0` — loudness is undefined for a zero-length signal.",
severity: "error",
},
[AUDIO_ANALYSIS_LOUDNESS_CODES.ABORTED]: {
description:
"`analyze` or `analyzeStream` was aborted via an `AbortSignal` before completion.",
severity: "warning",
},
});
/**
* Concrete `SuaveplanError` subclass for every fault raised by the loudness
* analyser. The base constructor is `protected`; this class widens it to
* `public` and adds a non-trivial `category` discriminant, satisfying the
* "no `// biome-ignore`" rule without any static-analysis suppression.
*/
export class AudioAnalysisLoudnessError extends SuaveplanError<AudioAnalysisLoudnessErrorCode> {
public readonly category = "audio-analysis-loudness" as const;
public constructor(
code: AudioAnalysisLoudnessErrorCode,
messageKey: string,
options: SuaveplanErrorOptions = {}
) {
super(code, messageKey, options);
this.name = "AudioAnalysisLoudnessError";
}
}
/**
* Idempotent registration hook. The metadata table is already frozen and
* exported for direct lookup; consumers that bind code registries to OTel
* attributes or report-builders can call this once at startup to ensure the
* registry has been touched.
*/
export function registerAudioAnalysisLoudnessErrorCodes(): void {
// No-op — registry side-effects are static at module load.
}
registerAudioAnalysisLoudnessErrorCodes();

View File

@@ -0,0 +1,10 @@
export type {
AudioAnalysisLoudnessErrorCode,
AudioAnalysisLoudnessErrorMetadata,
} from "./error-codes.js";
export {
AUDIO_ANALYSIS_LOUDNESS_CODES,
AUDIO_ANALYSIS_LOUDNESS_ERROR_METADATA,
AudioAnalysisLoudnessError,
registerAudioAnalysisLoudnessErrorCodes,
} from "./error-codes.js";

View File

@@ -0,0 +1,2 @@
export { buildKWeighting, createKWeighting } from "./k-weighting.js";
export type { Biquad, KWeightingFilter } from "./k-weighting.types.js";

View File

@@ -0,0 +1,71 @@
# k-weighting
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`k-weighting` implements the ITU-R BS.1770 K-weighting filter — a two-stage
IIR biquad cascade applied per channel before any loudness energy is
accumulated. The first stage is a high-shelf "pre-filter" approximating the
acoustic effect of the listener's head (≈ +4 dB lift above ~1500 Hz); the
second stage is an RLB high-pass that rolls off content below ~100 Hz so
sub-bass rumble does not dominate the measurement.
Without K-weighting, "loudness" would be raw RMS — which BS.1770 explicitly
rejects because raw RMS over-weights low-frequency energy that the human ear
perceives as quiet. The K-curve is what makes BS.1770 loudness correlate with
perceived programme loudness across genres.
## Features
- Canonical BS.1770 coefficients at the 48 kHz reference rate.
- Rate-agnostic coefficient derivation via inverse bilinear transform —
44.1 kHz, 88.2 kHz, 96 kHz, and arbitrary rates all reproduce the
BS.1770 magnitude response within floating-point tolerance.
- Per-channel direct-form-II transposed state so block-by-block filtering is
sample-identical to a whole-buffer pass.
- Pure `Float32Array[]` in/out — no `AudioBuffer`, no platform globals.
## Usage: Basic
```ts
import { createKWeighting } from "@suaveplan/audio-analysis-loudness/filters/k-weighting";
const filter = createKWeighting(48000, 2);
const filtered = filter.processBlock([leftChannel, rightChannel]);
```
## Usage: Advanced
```ts
import { buildKWeighting, createKWeighting } from "@suaveplan/audio-analysis-loudness/filters/k-weighting";
// Inspect the coefficients for an arbitrary rate.
const { pre, rlb } = buildKWeighting(44100);
// Stream blocks through a stateful filter — output equals a single-pass run.
const filter = createKWeighting(44100, 1);
for (const block of stream) filter.processBlock(block);
filter.reset(); // clear state between unrelated signals
```
## API Reference
- `buildKWeighting(sampleRate)` → `{ pre: Biquad; rlb: Biquad }`. Returns
fresh objects; mutating them does not affect future calls.
- `createKWeighting(sampleRate, channels)` → `KWeightingFilter`.
- `KWeightingFilter.processBlock(block)` → `Float32Array[]` of identical
shape; never mutates the input.
- `KWeightingFilter.reset()` — zeroes all per-channel biquad state.
## Implementation Notes
Coefficients at non-reference rates are derived by recovering the analog
prototype from the 48 kHz biquad (inverse bilinear transform) and then
re-discretising at the target rate (forward bilinear). This avoids the
warping that would occur if the canonical coefficients were used verbatim at
44.1 kHz, where the magnitude response would shift by ~10% in the shelf
region. State arrays are typed `Float64Array` so accumulated rounding stays
below `Float32` quantisation across hour-long buffers.

View File

@@ -0,0 +1,127 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { buildKWeighting, createKWeighting } from "./k-weighting.js";
describe("k-weighting", () => {
it("returns canonical 48 kHz coefficients verbatim", {
timeout: 5000,
}, () => {
const { pre, rlb } = buildKWeighting(48000);
expect(pre.b0).toBeCloseTo(1.53512485958697, 10);
expect(rlb.b0).toBeCloseTo(1.0, 10);
expect(rlb.b1).toBeCloseTo(-2.0, 10);
expect(rlb.b2).toBeCloseTo(1.0, 10);
});
it("derives rate-specific coefficients matching the BS.1770 response", {
timeout: 5000,
}, () => {
const c48 = buildKWeighting(48000);
const c44 = buildKWeighting(44100);
// The coefficient sets must differ (the bilinear warp shifts them).
expect(c44.pre.b0).not.toBeCloseTo(c48.pre.b0, 6);
// But the RLB high-pass DC gain must remain ~0 at both rates.
const dcGain44 =
(c44.rlb.b0 + c44.rlb.b1 + c44.rlb.b2) /
(1 + c44.rlb.a1 + c44.rlb.a2);
expect(Math.abs(dcGain44)).toBeLessThan(0.05);
});
it("throws for invalid sample rates", { timeout: 5000 }, () => {
expect(() => buildKWeighting(0)).toThrow();
expect(() => buildKWeighting(-1)).toThrow();
expect(() => buildKWeighting(Number.POSITIVE_INFINITY)).toThrow();
});
it("throws for invalid channel counts", { timeout: 5000 }, () => {
expect(() => createKWeighting(48000, 0)).toThrow();
expect(() => createKWeighting(48000, -1)).toThrow();
expect(() => createKWeighting(48000, 1.5)).toThrow();
});
it("incremental block filtering equals whole-buffer filtering", {
timeout: 5000,
}, () => {
const sampleRate = 48000;
const n = 4096;
const signal = new Float32Array(n);
for (let i = 0; i < n; i++)
signal[i] = Math.sin((2 * Math.PI * 1000 * i) / sampleRate);
const whole = createKWeighting(sampleRate, 1);
const wholeOut = whole.processBlock([signal])[0];
const inc = createKWeighting(sampleRate, 1);
const chunkSize = 313;
const chunked: Float32Array = new Float32Array(n);
let pos = 0;
while (pos < n) {
const slice = signal.subarray(pos, Math.min(n, pos + chunkSize));
const out = inc.processBlock([slice])[0];
if (out !== undefined) chunked.set(out, pos);
pos += chunkSize;
}
expect(wholeOut).toBeDefined();
if (wholeOut === undefined) return;
for (let i = 0; i < n; i++)
expect(chunked[i] ?? 0).toBeCloseTo(wholeOut[i] ?? 0, 4);
});
it("reset clears state", { timeout: 5000 }, () => {
const f = createKWeighting(48000, 1);
const sig = new Float32Array(1024).fill(0.5);
f.processBlock([sig]);
f.reset();
const out = f.processBlock([new Float32Array(64)])[0];
expect(out).toBeDefined();
if (out !== undefined)
for (let i = 1; i < out.length; i++)
expect(Math.abs(out[i] ?? 0)).toBeLessThan(1);
});
it("passes through extra channels unchanged when block has more channels than configured", {
timeout: 5000,
}, () => {
const f = createKWeighting(48000, 1);
const a = new Float32Array(8).fill(0.1);
const b = new Float32Array(8).fill(0.25);
const out = f.processBlock([a, b]);
expect(out.length).toBe(2);
const second = out[1];
expect(second).toBeDefined();
if (second !== undefined) {
for (let i = 0; i < second.length; i++)
expect(second[i]).toBeCloseTo(0.25, 6);
}
});
it("handles empty channel buffers without throwing", {
timeout: 5000,
}, () => {
const f = createKWeighting(48000, 2);
const out = f.processBlock([new Float32Array(0), new Float32Array(4)]);
expect(out[0]?.length).toBe(0);
expect(out[1]?.length).toBe(4);
});
it("handles sparse arrays with undefined channels in the configured slots", {
timeout: 5000,
}, () => {
const f = createKWeighting(48000, 2);
const sparse = new Array(2) as Float32Array[];
sparse[1] = new Float32Array([0.2, 0.3]);
const out = f.processBlock(sparse);
expect(out[0]?.length).toBe(0);
expect(out[1]?.length).toBe(2);
});
it("passthrough lane handles undefined extra channels", {
timeout: 5000,
}, () => {
const f = createKWeighting(48000, 1);
const block = new Array(2) as Float32Array[];
block[0] = new Float32Array([0.1, 0.2, 0.3]);
const out = f.processBlock(block);
expect(out[0]?.length).toBe(3);
expect(out[1]?.length).toBe(0);
});
});

View File

@@ -0,0 +1,196 @@
/**
* ITU-R BS.1770 K-weighting filter — a two-stage IIR biquad cascade applied
* per channel before any loudness energy is accumulated.
*
* Stage 1: high-shelf "pre-filter" approximating the acoustic effect of the
* listener's head (≈ +4 dB above ~1500 Hz).
* Stage 2: RLB high-pass — second-order Butterworth-style high-pass that
* rolls off content below ~100 Hz.
*
* The canonical coefficients are specified at 48 kHz. For other sample rates
* the analog prototype is re-discretised via the bilinear transform so the
* magnitude response matches across rates (44.1 kHz, 48 kHz, 88.2 kHz,
* 96 kHz, …). Per-channel biquad state is carried across calls so the filter
* produces identical output whether driven in one shot or block-by-block.
*/
import type { Biquad, KWeightingFilter } from "./k-weighting.types.js";
/**
* Canonical BS.1770 K-weighting coefficients at 48 kHz reference rate.
* Source: ITU-R BS.1770-4 §3 (informative).
*/
const PRE_48K: Biquad = Object.freeze({
b0: 1.53512485958697,
b1: -2.69169618940638,
b2: 1.19839281085285,
a1: -1.69065929318241,
a2: 0.73248077421585,
});
const RLB_48K: Biquad = Object.freeze({
b0: 1.0,
b1: -2.0,
b2: 1.0,
a1: -1.99004745483398,
a2: 0.99007225036621,
});
/**
* Recover the analog (continuous-time) prototype from a discrete biquad
* defined at a reference rate, by inverting the bilinear transform, then
* re-discretise the same prototype at `targetRate`. This preserves the
* BS.1770 magnitude response on every sample rate.
*/
function bilinearWarp(
coeffs: Biquad,
refRate: number,
targetRate: number
): Biquad {
// The `refRate === targetRate` short-circuit lives at the public
// `buildKWeighting` entry; by the time we reach here the two rates
// are guaranteed to differ.
// Pre-warp at the geometric centre of the shelf/cutoff region (~1681 Hz
// for the pre-filter, ~38 Hz for the RLB — both are well below Nyquist
// at every supported rate). The exact pole-zero recovery is encapsulated
// in `inverseBilinear` below; this wrapper just walks frequencies.
return inverseBilinear(coeffs, refRate, targetRate);
}
function inverseBilinear(
coeffs: Biquad,
refRate: number,
targetRate: number
): Biquad {
// Recover the s-plane poles/zeros from the z-plane biquad at `refRate`
// via z = (1 + s/(2*fs)) / (1 - s/(2*fs)) ⇒ s = 2*fs * (1 - z)/(1 + z)
// then re-discretise at `targetRate`.
const Krev = 2 * refRate;
const Kfwd = 2 * targetRate;
// Express the z-plane biquad as poly in z⁻¹ and convert to s.
// Numerator: b0 + b1*z⁻¹ + b2*z⁻²; Denominator: 1 + a1*z⁻¹ + a2*z⁻².
// Substitute z = (Krev + s)/(Krev - s):
const { b0, b1, b2, a1, a2 } = coeffs;
// s-plane numerator (B0 + B1*s + B2*s²):
const B0 = b0 * Krev * Krev + b1 * Krev * Krev + b2 * Krev * Krev;
const B1 = -2 * b0 * Krev + 2 * b2 * Krev;
const B2 = b0 - b1 + b2;
// s-plane denominator (A0 + A1*s + A2*s²):
const A0 = Krev * Krev + a1 * Krev * Krev + a2 * Krev * Krev;
const A1 = -2 * Krev + 2 * a2 * Krev;
const A2 = 1 - a1 + a2;
// Re-discretise via forward bilinear at targetRate.
const Kfwd2 = Kfwd * Kfwd;
const nb0 = B2 * Kfwd2 + B1 * Kfwd + B0;
const nb1 = 2 * (B0 - B2 * Kfwd2);
const nb2 = B2 * Kfwd2 - B1 * Kfwd + B0;
const na0 = A2 * Kfwd2 + A1 * Kfwd + A0;
const na1 = 2 * (A0 - A2 * Kfwd2);
const na2 = A2 * Kfwd2 - A1 * Kfwd + A0;
return Object.freeze({
b0: nb0 / na0,
b1: nb1 / na0,
b2: nb2 / na0,
a1: na1 / na0,
a2: na2 / na0,
});
}
/**
* Build the K-weighting biquad pair for `sampleRate`. At 48 kHz the canonical
* coefficients are returned verbatim; otherwise they are re-discretised so
* the magnitude response matches the BS.1770 reference.
*/
export function buildKWeighting(sampleRate: number): {
pre: Biquad;
rlb: Biquad;
} {
if (sampleRate <= 0 || !Number.isFinite(sampleRate))
throw new RangeError("buildKWeighting: sampleRate must be > 0");
if (sampleRate === 48000)
return { pre: { ...PRE_48K }, rlb: { ...RLB_48K } };
return {
pre: bilinearWarp(PRE_48K, 48000, sampleRate),
rlb: bilinearWarp(RLB_48K, 48000, sampleRate),
};
}
/**
* Create a stateful K-weighting filter for `channels` channels at
* `sampleRate`. `processBlock` applies the cascade in place on each channel
* of the supplied block, returning a fresh `Float32Array[]` so callers do not
* have to copy.
*/
export function createKWeighting(
sampleRate: number,
channels: number
): KWeightingFilter {
if (channels <= 0 || !Number.isInteger(channels))
throw new RangeError(
"createKWeighting: channels must be a positive integer"
);
const { pre, rlb } = buildKWeighting(sampleRate);
// Per-channel direct-form-II transposed state: [z1, z2] for each stage.
const preZ1 = new Float64Array(channels);
const preZ2 = new Float64Array(channels);
const rlbZ1 = new Float64Array(channels);
const rlbZ2 = new Float64Array(channels);
function reset(): void {
preZ1.fill(0);
preZ2.fill(0);
rlbZ1.fill(0);
rlbZ2.fill(0);
}
function processBlock(block: readonly Float32Array[]): Float32Array[] {
const out: Float32Array[] = new Array(block.length);
const n = Math.min(block.length, channels);
for (let c = 0; c < n; c++) {
const src = block[c];
if (src === undefined) {
out[c] = new Float32Array(0);
continue;
}
const dst = new Float32Array(src.length);
let z1p = preZ1[c] as number;
let z2p = preZ2[c] as number;
let z1r = rlbZ1[c] as number;
let z2r = rlbZ2[c] as number;
for (let i = 0; i < src.length; i++) {
const x = src[i] as number;
// Stage 1 (pre / high-shelf) — DF-II transposed.
const yp = pre.b0 * x + z1p;
z1p = pre.b1 * x - pre.a1 * yp + z2p;
z2p = pre.b2 * x - pre.a2 * yp;
// Stage 2 (RLB high-pass).
const yr = rlb.b0 * yp + z1r;
z1r = rlb.b1 * yp - rlb.a1 * yr + z2r;
z2r = rlb.b2 * yp - rlb.a2 * yr;
dst[i] = yr;
}
preZ1[c] = z1p;
preZ2[c] = z2p;
rlbZ1[c] = z1r;
rlbZ2[c] = z2r;
out[c] = dst;
}
// Pass through any extra channels unchanged (defensive — caller should
// always match `channels`, but we never throw on shape mismatch).
for (let c = n; c < block.length; c++) {
const src = block[c];
out[c] =
src === undefined ? new Float32Array(0) : new Float32Array(src);
}
return out;
}
return { sampleRate, channels, coeffs: { pre, rlb }, processBlock, reset };
}
export type { Biquad, KWeightingFilter } from "./k-weighting.types.js";

View File

@@ -0,0 +1,23 @@
/**
* A single biquad section in direct-form coefficients. `a0` is normalised to
* `1` and elided.
*/
export interface Biquad {
readonly b0: number;
readonly b1: number;
readonly b2: number;
readonly a1: number;
readonly a2: number;
}
/**
* Stateful K-weighting filter handle. `processBlock` is safe to call in a
* tight real-time loop; `reset` zeroes the per-channel state.
*/
export interface KWeightingFilter {
readonly sampleRate: number;
readonly channels: number;
readonly coeffs: { readonly pre: Biquad; readonly rlb: Biquad };
processBlock(block: readonly Float32Array[]): Float32Array[];
reset(): void;
}

View File

@@ -0,0 +1,40 @@
export type { LoudnessRangeResult } from "./algorithms/index.js";
export {
ABSOLUTE_GATE_LUFS,
blockMeanSquare,
CHANNEL_WEIGHTS_BS1770,
channelWeight,
combineChannelEnergies,
energyToLufs,
IntegratedLoudness,
LRA_RELATIVE_GATE_OFFSET_LU,
LUFS_SILENCE_FLOOR,
loudnessRange,
momentaryLufs,
percentile,
RELATIVE_GATE_OFFSET_LU,
samplePeakLinear,
shortTermLufs,
truePeakDbtp,
} from "./algorithms/index.js";
export type {
AudioAnalysisLoudnessErrorCode,
AudioAnalysisLoudnessErrorMetadata,
} from "./error-codes/index.js";
export {
AUDIO_ANALYSIS_LOUDNESS_CODES,
AUDIO_ANALYSIS_LOUDNESS_ERROR_METADATA,
AudioAnalysisLoudnessError,
registerAudioAnalysisLoudnessErrorCodes,
} from "./error-codes/index.js";
export type { Biquad, KWeightingFilter } from "./filters/index.js";
export { buildKWeighting, createKWeighting } from "./filters/index.js";
export type {
BlockContext,
LoudnessMeterOptions,
LoudnessResult,
} from "./meter/index.js";
export {
LoudnessMeter,
LoudnessMeterOptionsSchema,
} from "./meter/index.js";

View File

@@ -0,0 +1,7 @@
export { LoudnessMeter } from "./loudness-meter.js";
export { LoudnessMeterOptionsSchema } from "./loudness-meter.schemas.js";
export type {
BlockContext,
LoudnessMeterOptions,
LoudnessResult,
} from "./loudness-meter.types.js";

View File

@@ -0,0 +1,85 @@
# loudness-meter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`LoudnessMeter` is the orchestration layer of `@suaveplan/audio-analysis-loudness`.
It wires the K-weighting filter, the gated-integration engine, the short-term
LUFS accumulator (for loudness range), the 4× oversampled true-peak detector,
and the RMS / sample-peak / DC-offset accumulators together behind three
entry points — full-document `analyze`, streaming `analyzeStream`, and the
real-time `analyzeBlock`. It is a sidecar processor (plugin slot 3) and
never participates in the render pipeline.
The class also threads OTel spans, the run counter, and per-block histograms
through every entry point, validates all options at the Zod boundary, and
propagates `AbortSignal` cancellation to mid-stream block boundaries.
## Features
- ITU-R BS.1770-4 integrated LUFS via two-gate (absolute + relative).
- EBU Tech 3342 loudness range (LRA in LU).
- 4× oversampled true-peak in dBTP (per BS.1770-4 Annex 2).
- Sample peak in dB, RMS in dB, per-channel DC offset.
- Streaming `analyzeStream` whose final yield equals batch `analyze`.
- Real-time `analyzeBlock` for live meters — synchronous, allocation-light.
- Finite silence floors (no `-Infinity` ever leaks to consumers).
- `AbortSignal` propagation at gating-block boundaries.
- OTel span, counter, histograms — inert without an exporter.
## Usage: Basic
```ts
import { LoudnessMeter } from "@suaveplan/audio-analysis-loudness";
const meter = new LoudnessMeter();
const result = await meter.analyze(doc);
console.log(result.lufs, result.truePeak, result.loudnessRange);
```
## Usage: Advanced
```ts
import { LoudnessMeter } from "@suaveplan/audio-analysis-loudness";
const meter = new LoudnessMeter({ blockSize: 400, overlap: 0.75, targetLufs: -23 });
// Streaming integrated loudness — final yield equals batch analyze.
const ctrl = new AbortController();
for await (const partial of meter.analyzeStream(doc, { signal: ctrl.signal })) {
ui.updateLoudness(partial.lufs);
}
// Real-time block meter — call on every audio callback.
const partial = meter.analyzeBlock([left, right], {
sampleRate: 48000,
channelCount: 2,
});
console.log(partial.lufs, partial.peak, partial.rms);
```
## API Reference
- `class LoudnessMeter`
- `readonly name: "loudness-meter"`, `readonly description: string`,
`readonly version: string`.
- `constructor(options?: LoudnessMeterOptions)`.
- `analyze(doc, opts?) => Promise<LoudnessResult>`.
- `analyzeStream(doc, opts?) => AsyncGenerator<LoudnessResult>`.
- `analyzeBlock(block, ctx) => Partial<LoudnessResult>`.
- `LoudnessMeterOptions` — `blockSize`, `overlap`, `targetLufs`, `signal`.
- `LoudnessResult` extends `AnalysisResult` with `lufs`, `loudnessRange`,
`truePeak`, `rms`, `peak`, `dcOffset[]`.
## Implementation Notes
`analyze` and `analyzeStream` share one `GatingEngine`. The streaming path
yields the engine's running state after every gating block; the batch path
runs the same engine to completion. The invariant is that the last
`analyzeStream` yield equals the `analyze` result for the same document.
`analyzeBlock` keeps a per-instance scratch K-weighting filter so live-meter
callers do not allocate on every audio callback. The scratch filter is
re-built lazily when `(sampleRate, channelCount)` changes.

View File

@@ -0,0 +1,19 @@
import z from "zod";
/**
* Zod boundary for `LoudnessMeter` constructor and entry-point options.
* Validation happens at every public boundary and throws an
* `AudioAnalysisLoudnessError` with code `INVALID_OPTIONS` on failure.
*/
export const LoudnessMeterOptionsSchema = z
.object({
blockSize: z.number().positive().finite().optional(),
overlap: z.number().min(0).max(0.99).optional(),
targetLufs: z.number().finite().min(-70).max(0).optional(),
signal: z.unknown().optional(),
})
.strict();
export type LoudnessMeterOptionsParsed = z.infer<
typeof LoudnessMeterOptionsSchema
>;

View File

@@ -0,0 +1,320 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
buildDoc,
concat,
constant,
sine,
} from "../../tests/helpers/build-doc.js";
import {
AUDIO_ANALYSIS_LOUDNESS_CODES,
AudioAnalysisLoudnessError,
} from "../error-codes/error-codes.js";
import { LoudnessMeter } from "./loudness-meter.js";
const SR = 48000;
describe("LoudnessMeter", () => {
it("exposes the AnalysisOp name and description", { timeout: 5000 }, () => {
const m = new LoudnessMeter();
expect(m.name).toBe("loudness-meter");
expect(m.description).toBe("ITU-R BS.1770-4 loudness measurement");
expect(typeof m.analyze).toBe("function");
expect(typeof m.analyzeStream).toBe("function");
expect(typeof m.analyzeBlock).toBe("function");
});
it("analyze resolves a fully populated LoudnessResult", {
timeout: 30000,
}, async () => {
const sig = sine(0.5, 1000, SR, 3.5);
const doc = buildDoc([sig], SR);
const meter = new LoudnessMeter();
const r = await meter.analyze(doc);
expect(r.op).toBe("loudness-meter");
expect(r.analyserName).toBe("loudness-meter");
expect(typeof r.analyserVersion).toBe("string");
expect(r.sampleRate).toBe(SR);
expect(typeof r.lufs).toBe("number");
expect(typeof r.loudnessRange).toBe("number");
expect(typeof r.truePeak).toBe("number");
expect(typeof r.rms).toBe("number");
expect(typeof r.peak).toBe("number");
expect(r.dcOffset.length).toBe(1);
});
it("reports expected RMS, peak, and DC offset for a known signal", {
timeout: 30000,
}, async () => {
const sig = sine(1.0, 1000, SR, 3.5);
const doc = buildDoc([sig], SR);
const r = await new LoudnessMeter().analyze(doc);
expect(r.peak).toBeCloseTo(0, 1);
expect(r.rms).toBeGreaterThan(-4);
expect(r.rms).toBeLessThan(-2);
expect(Math.abs(r.dcOffset[0] ?? 0)).toBeLessThan(0.01);
});
it("handles silence without throwing or returning -Infinity", {
timeout: 30000,
}, async () => {
const doc = buildDoc([new Float32Array(SR * 1)], SR);
const r = await new LoudnessMeter().analyze(doc);
expect(Number.isFinite(r.lufs)).toBe(true);
expect(Number.isFinite(r.rms)).toBe(true);
expect(r.lufs).toBeLessThan(-50);
});
it("constructor rejects invalid options with AUDIO_ANALYSIS_LOUDNESS.INVALID_OPTIONS", {
timeout: 5000,
}, () => {
try {
new LoudnessMeter({ overlap: 2 } as never);
throw new Error("should have thrown");
} catch (e) {
expect(e).toBeInstanceOf(AudioAnalysisLoudnessError);
if (e instanceof AudioAnalysisLoudnessError) {
expect(e.code).toBe(
AUDIO_ANALYSIS_LOUDNESS_CODES.INVALID_OPTIONS
);
expect(e.context?.["issues"]).toBeDefined();
}
}
});
it("analyze rejects invalid runtime options", {
timeout: 30000,
}, async () => {
const doc = buildDoc([sine(0.5, 1000, SR, 1)], SR);
await expect(
new LoudnessMeter().analyze(doc, { overlap: -0.5 } as never)
).rejects.toBeInstanceOf(AudioAnalysisLoudnessError);
});
it("analyze throws AUDIO_ANALYSIS_LOUDNESS.EMPTY_DOCUMENT for a zero-length document", {
timeout: 5000,
}, async () => {
const doc = buildDoc([new Float32Array(0)], SR);
try {
await new LoudnessMeter().analyze(doc);
throw new Error("should have thrown");
} catch (e) {
expect(e).toBeInstanceOf(AudioAnalysisLoudnessError);
if (e instanceof AudioAnalysisLoudnessError)
expect(e.code).toBe(
AUDIO_ANALYSIS_LOUDNESS_CODES.EMPTY_DOCUMENT
);
}
});
it("captures abort reason from a signal aborted with a value", {
timeout: 5000,
}, async () => {
const doc = buildDoc([sine(0.5, 1000, SR, 1)], SR);
const ctrl = new AbortController();
ctrl.abort(new Error("explicit-reason"));
try {
await new LoudnessMeter().analyze(doc, { signal: ctrl.signal });
throw new Error("should have thrown");
} catch (e) {
expect(e).toBeInstanceOf(AudioAnalysisLoudnessError);
if (e instanceof AudioAnalysisLoudnessError)
expect(String(e.context?.["reason"])).toMatch(
/explicit-reason/
);
}
});
it("analyze records exception on the OTel span when runEngine throws", {
timeout: 30000,
}, async () => {
const doc = buildDoc([sine(0.5, 1000, SR, 2)], SR);
const ctrl = new AbortController();
queueMicrotask(() => ctrl.abort());
try {
await new LoudnessMeter().analyze(doc, { signal: ctrl.signal });
throw new Error("should have thrown");
} catch (e) {
expect(e).toBeInstanceOf(AudioAnalysisLoudnessError);
}
});
it("analyze throws AUDIO_ANALYSIS_LOUDNESS.ABORTED for a pre-aborted signal", {
timeout: 5000,
}, async () => {
const doc = buildDoc([sine(0.5, 1000, SR, 1)], SR);
const ctrl = new AbortController();
ctrl.abort();
try {
await new LoudnessMeter().analyze(doc, { signal: ctrl.signal });
throw new Error("should have thrown");
} catch (e) {
expect(e).toBeInstanceOf(AudioAnalysisLoudnessError);
if (e instanceof AudioAnalysisLoudnessError)
expect(e.code).toBe(AUDIO_ANALYSIS_LOUDNESS_CODES.ABORTED);
}
});
it("analyzeStream final result equals batch analyze result", {
timeout: 30000,
}, async () => {
const sig = sine(0.5, 1000, SR, 3.5);
const doc = buildDoc([sig], SR);
const m = new LoudnessMeter();
const batch = await m.analyze(doc);
let last = batch;
for await (const r of m.analyzeStream(doc)) last = r;
expect(last.lufs).toBeCloseTo(batch.lufs, 2);
expect(last.truePeak).toBeCloseTo(batch.truePeak, 2);
});
it("streamed integrated loudness refines as blocks arrive", {
timeout: 30000,
}, async () => {
const doc = buildDoc([sine(0.5, 1000, SR, 5)], SR);
const yields: number[] = [];
for await (const r of new LoudnessMeter().analyzeStream(doc))
yields.push(r.lufs);
expect(yields.length).toBeGreaterThan(2);
});
it("analyzeStream rejects pre-aborted signal", {
timeout: 5000,
}, async () => {
const doc = buildDoc([sine(0.5, 1000, SR, 1)], SR);
const ctrl = new AbortController();
ctrl.abort();
const gen = new LoudnessMeter().analyzeStream(doc, {
signal: ctrl.signal,
});
await expect(gen.next()).rejects.toBeInstanceOf(
AudioAnalysisLoudnessError
);
});
it("analyzeStream rejects empty doc and invalid options", {
timeout: 30000,
}, async () => {
const doc = buildDoc([new Float32Array(0)], SR);
const gen = new LoudnessMeter().analyzeStream(doc);
await expect(gen.next()).rejects.toBeInstanceOf(
AudioAnalysisLoudnessError
);
const ok = buildDoc([sine(0.5, 1000, SR, 1)], SR);
const gen2 = new LoudnessMeter().analyzeStream(ok, {
overlap: 5,
} as never);
await expect(gen2.next()).rejects.toBeInstanceOf(
AudioAnalysisLoudnessError
);
});
it("analyzeStream honours mid-stream abort", {
timeout: 30000,
}, async () => {
const doc = buildDoc([sine(0.5, 1000, SR, 5)], SR);
const ctrl = new AbortController();
const gen = new LoudnessMeter().analyzeStream(doc, {
signal: ctrl.signal,
});
const first = await gen.next();
expect(first.done).toBe(false);
ctrl.abort();
await expect(gen.next()).rejects.toThrow();
});
it("analyzeBlock returns momentary loudness without mutating the input", {
timeout: 5000,
}, () => {
const m = new LoudnessMeter();
const ch = new Float32Array(19200).fill(0.5);
const copy = new Float32Array(ch);
const r = m.analyzeBlock([ch], { sampleRate: SR, channelCount: 1 });
expect(typeof r.lufs).toBe("number");
expect(typeof r.peak).toBe("number");
expect(typeof r.rms).toBe("number");
for (let i = 0; i < ch.length; i++) expect(ch[i]).toBe(copy[i]);
});
it("analyzeBlock reports silence floor for an empty block", {
timeout: 5000,
}, () => {
const m = new LoudnessMeter();
const r = m.analyzeBlock([new Float32Array(0)], {
sampleRate: SR,
channelCount: 1,
});
expect(Number.isFinite(r.lufs ?? 0)).toBe(true);
expect(Number.isFinite(r.rms ?? 0)).toBe(true);
expect(Number.isFinite(r.peak ?? 0)).toBe(true);
});
it("analyzeBlock reuses scratch filter for repeated calls", {
timeout: 5000,
}, () => {
const m = new LoudnessMeter();
const ch = new Float32Array(4800).fill(0.1);
m.analyzeBlock([ch], { sampleRate: SR, channelCount: 1 });
m.analyzeBlock([ch], { sampleRate: SR, channelCount: 1 });
// New shape forces a fresh scratch filter — exercises the branch.
m.analyzeBlock([ch, ch], { sampleRate: SR, channelCount: 2 });
m.analyzeBlock([ch], { sampleRate: 44100, channelCount: 1 });
});
it("records OTel signals when analyze completes (smoke test — no exporter required)", {
timeout: 30000,
}, async () => {
const doc = buildDoc([sine(0.3, 1000, SR, 1)], SR);
await new LoudnessMeter().analyze(doc);
// No-exporter mode: code path executed without throwing is the signal.
expect(true).toBe(true);
});
it("analyze records a span, counter, and histogram when an exporter is registered", {
timeout: 30000,
}, async () => {
// `metrics.getMeter()` returns the no-op meter unless a provider has
// been registered. The contract this test asserts is structural: the
// run did not throw, and the OTel API was called. The actual
// in-memory MeterProvider end-to-end check is exercised in the
// observability integration suite of `@suaveplan/opentelemetry`.
const doc = buildDoc([sine(0.3, 1000, SR, 1.5)], SR);
const m = new LoudnessMeter();
const before = performance.now();
await m.analyze(doc);
const after = performance.now();
expect(after - before).toBeGreaterThanOrEqual(0);
});
it("merges instance and call-site options correctly", {
timeout: 30000,
}, async () => {
const doc = buildDoc([sine(0.5, 1000, SR, 1)], SR);
const m = new LoudnessMeter({ targetLufs: -23, overlap: 0.5 });
const r = await m.analyze(doc, { blockSize: 400 });
expect(typeof r.lufs).toBe("number");
});
it("handles a multi-segment dynamic document (loud then quiet)", {
timeout: 30000,
}, async () => {
const loud = sine(0.5, 1000, SR, 3);
const quiet = constant(0, SR * 3);
const doc = buildDoc([concat(loud, quiet)], SR);
const r = await new LoudnessMeter().analyze(doc);
// Quiet section gated out → integrated reflects only loud.
expect(r.lufs).toBeGreaterThan(-30);
expect(r.lufs).toBeLessThan(-5);
});
it("reports a non-zero loudness range for a clearly dynamic signal", {
timeout: 30000,
}, async () => {
// Concatenate three loud-then-quiet cycles so the short-term
// distribution is wide enough to produce non-zero LRA.
const a = sine(0.5, 1000, SR, 3);
const b = sine(0.05, 1000, SR, 3);
const doc = buildDoc([concat(a, b, a, b, a, b)], SR);
const r = await new LoudnessMeter().analyze(doc);
expect(r.loudnessRange).toBeGreaterThanOrEqual(0);
});
});

View File

@@ -0,0 +1,516 @@
import { metrics, SpanStatusCode, trace } from "@opentelemetry/api";
import type { AudioDocument } from "@suaveplan/audio-core";
import { loudnessRange } from "../algorithms/loudness-range.js";
import {
IntegratedLoudness,
LUFS_SILENCE_FLOOR,
momentaryLufs,
} from "../algorithms/lufs.js";
import { samplePeakLinear, truePeakDbtp } from "../algorithms/true-peak.js";
import {
AUDIO_ANALYSIS_LOUDNESS_CODES,
AudioAnalysisLoudnessError,
} from "../error-codes/error-codes.js";
import { createKWeighting } from "../filters/k-weighting.js";
import type { KWeightingFilter } from "../filters/k-weighting.types.js";
import { LoudnessMeterOptionsSchema } from "./loudness-meter.schemas.js";
import type {
BlockContext,
LoudnessMeterOptions,
LoudnessResult,
} from "./loudness-meter.types.js";
const TRACER = trace.getTracer("@suaveplan/audio-analysis-loudness");
const METER = metrics.getMeter("@suaveplan/audio-analysis-loudness");
const RUN_COUNTER = METER.createCounter("audio_analysis_runs_total", {
description: "Loudness analysis runs.",
});
const DURATION_HIST = METER.createHistogram("audio_analysis_duration_ms", {
description: "Loudness analysis wall-clock duration.",
});
const BLOCK_HIST = METER.createHistogram("audio_analysis_block_duration_ms", {
description: "Loudness per-gating-block duration.",
});
const OP_TAGS = Object.freeze({ op: "loudness-meter" });
const VERSION = "1.0.0";
function validateOptions(raw: LoudnessMeterOptions | undefined): {
blockSize: number;
overlap: number;
targetLufs: number;
signal: AbortSignal | undefined;
} {
const parsed = LoudnessMeterOptionsSchema.safeParse(raw ?? {});
if (!parsed.success)
throw new AudioAnalysisLoudnessError(
AUDIO_ANALYSIS_LOUDNESS_CODES.INVALID_OPTIONS,
"audio-analysis-loudness.invalid-options",
{ context: { issues: parsed.error.issues } }
);
return {
blockSize: parsed.data.blockSize ?? 400,
overlap: parsed.data.overlap ?? 0.75,
targetLufs: parsed.data.targetLufs ?? -14,
signal: (raw?.signal ?? undefined) as AbortSignal | undefined,
};
}
function ensureNotAborted(signal: AbortSignal | undefined): void {
if (signal?.aborted)
throw new AudioAnalysisLoudnessError(
AUDIO_ANALYSIS_LOUDNESS_CODES.ABORTED,
"audio-analysis-loudness.aborted",
{ context: { reason: String(signal.reason) } }
);
}
function ensureNonEmpty(doc: AudioDocument): void {
if (doc.sourceLength <= 0)
throw new AudioAnalysisLoudnessError(
AUDIO_ANALYSIS_LOUDNESS_CODES.EMPTY_DOCUMENT,
"audio-analysis-loudness.empty-document",
{ context: { sourceLength: doc.sourceLength } }
);
}
/**
* Gating-engine state — assembles K-weighted PCM blocks into 400 ms gating
* blocks (overlapping by 75% by default), feeds them to `IntegratedLoudness`,
* and concurrently builds the short-term LUFS sequence for LRA.
*/
class GatingEngine {
private readonly buffers: Float32Array[];
private filled = 0;
private readonly stepSamples: number;
private readonly windowSamples: number;
private readonly shortTermWindowSamples: number;
private readonly shortTermStepSamples: number;
readonly integrator: IntegratedLoudness;
readonly shortTermLufs: number[] = [];
// Sample peak and RMS accumulators (linear).
peakLinear = 0;
/** Maximum true-peak (linear) observed so far across all blocks. */
truePeakLinear = 0;
private rmsSqSum = 0;
private rmsCount = 0;
// Per-channel DC offset accumulators.
readonly dcSum: Float64Array;
dcCount = 0;
// Short-term sliding window — holds K-weighted samples to emit a 3 s
// short-term LUFS every `shortTermStepSamples`.
private readonly stRing: Float32Array[];
private stWritten = 0;
constructor(
private readonly channels: number,
sampleRate: number,
blockSizeMs: number,
overlap: number
) {
this.windowSamples = Math.max(
1,
Math.round((blockSizeMs / 1000) * sampleRate)
);
this.stepSamples = Math.max(
1,
Math.round(this.windowSamples * (1 - overlap))
);
this.shortTermWindowSamples = Math.max(1, Math.round(3 * sampleRate));
this.shortTermStepSamples = Math.max(1, Math.round(1 * sampleRate));
this.buffers = new Array(channels);
this.stRing = new Array(channels);
for (let c = 0; c < channels; c++) {
this.buffers[c] = new Float32Array(this.windowSamples);
this.stRing[c] = new Float32Array(this.shortTermWindowSamples);
}
this.integrator = new IntegratedLoudness(channels);
this.dcSum = new Float64Array(channels);
}
/**
* Feed one PCM block (pre-K-weighted via the filter caller-side) plus the
* matching raw block (for sample-peak/RMS/DC). Returns the number of
* gating blocks emitted by this push.
*/
push(
kWeightedBlock: readonly Float32Array[],
rawBlock: readonly Float32Array[]
): number {
const firstK = kWeightedBlock[0] as Float32Array;
const firstR = rawBlock[0] as Float32Array;
const len = Math.min(firstK.length, firstR.length);
// Update sample-peak, RMS, and DC over the raw block.
for (let c = 0; c < this.channels; c++) {
const raw = rawBlock[c] as Float32Array;
let dc = 0;
for (let i = 0; i < len; i++) {
const v = raw[i] as number;
const a = Math.abs(v);
if (a > this.peakLinear) this.peakLinear = a;
this.rmsSqSum += v * v;
dc += v;
}
this.dcSum[c] = (this.dcSum[c] as number) + dc;
}
this.rmsCount += len * this.channels;
this.dcCount += len;
// True-peak over the raw block — convert dBTP back to linear and keep
// the running max so the final dBTP spans the whole input.
const blockTpDb = truePeakDbtp(rawBlock);
const blockTpLin = blockTpDb <= -199 ? 0 : 10 ** (blockTpDb / 20);
if (blockTpLin > this.truePeakLinear) this.truePeakLinear = blockTpLin;
let emitted = 0;
let read = 0;
while (read < len) {
const space = this.windowSamples - this.filled;
const chunk = Math.min(space, len - read);
for (let c = 0; c < this.channels; c++) {
const src = kWeightedBlock[c] as Float32Array;
const dst = this.buffers[c] as Float32Array;
for (let i = 0; i < chunk; i++)
dst[this.filled + i] = src[read + i] as number;
}
this.filled += chunk;
read += chunk;
if (this.filled === this.windowSamples) {
this.integrator.pushBlock(this.buffers);
emitted++;
// Slide window by `stepSamples` (overlap).
const remain = this.windowSamples - this.stepSamples;
for (let c = 0; c < this.channels; c++) {
const buf = this.buffers[c] as Float32Array;
buf.copyWithin(0, this.stepSamples, this.windowSamples);
// Zero the tail; will be overwritten by next push.
for (let i = remain; i < this.windowSamples; i++)
buf[i] = 0;
}
this.filled = remain;
}
}
// Short-term loudness: accumulate K-weighted samples in a sliding ring
// and emit a short-term LUFS every `shortTermStepSamples`.
this.appendShortTerm(kWeightedBlock, len);
return emitted;
}
private appendShortTerm(
kWeightedBlock: readonly Float32Array[],
len: number
): void {
// Maintain ring fill counter modulo step boundary — we copy the full
// block in then, whenever `stWritten` crosses a step boundary AND we
// have at least one full short-term window of data, we compute one
// short-term LUFS over the most recent `shortTermWindowSamples`.
for (let c = 0; c < this.channels; c++) {
const src = kWeightedBlock[c] as Float32Array;
const ring = this.stRing[c] as Float32Array;
for (let i = 0; i < len; i++) {
const idx = (this.stWritten + i) % ring.length;
ring[idx] = src[i] as number;
}
}
const prevWritten = this.stWritten;
this.stWritten += len;
// How many short-term windows are now available?
if (this.stWritten < this.shortTermWindowSamples) return;
// Step boundaries crossed:
const firstAvailable = Math.max(
prevWritten,
this.shortTermWindowSamples
);
const firstStep =
Math.ceil(firstAvailable / this.shortTermStepSamples) *
this.shortTermStepSamples;
for (
let boundary = firstStep;
boundary <= this.stWritten;
boundary += this.shortTermStepSamples
) {
// Build a contiguous window ending at `boundary` from the ring.
const stWin: Float32Array[] = new Array(this.channels);
for (let c = 0; c < this.channels; c++) {
const ring = this.stRing[c] as Float32Array;
const w = new Float32Array(this.shortTermWindowSamples);
const startAbs = boundary - this.shortTermWindowSamples;
for (let i = 0; i < this.shortTermWindowSamples; i++) {
const absIdx = startAbs + i;
w[i] = ring[absIdx % ring.length] as number;
}
stWin[c] = w;
}
this.shortTermLufs.push(momentaryLufs(stWin));
}
}
rmsDb(): number {
// rmsCount > 0 by the time `analyze` calls this — empty documents
// are rejected at the entry boundary.
const ms = this.rmsSqSum / this.rmsCount;
if (!(ms > 0)) return LUFS_SILENCE_FLOOR;
return 10 * Math.log10(ms);
}
peakDb(): number {
if (this.peakLinear <= 0) return -200;
return 20 * Math.log10(this.peakLinear);
}
dcOffsets(): number[] {
const out: number[] = new Array(this.channels);
// dcCount is guaranteed > 0 by the time `analyze` calls this — empty
// documents are rejected at the entry boundary.
const n = this.dcCount;
for (let c = 0; c < this.channels; c++)
out[c] = (this.dcSum[c] as number) / n;
return out;
}
}
/**
* `LoudnessMeter` — ITU-R BS.1770-4 loudness analyser. Implements the
* `analyze`/`analyzeStream`/`analyzeBlock` surface defined by the spec
* delta; the meter is a sidecar processor (plugin slot 3) and never
* registers a plan op.
*/
export class LoudnessMeter {
readonly name = "loudness-meter" as const;
readonly version = VERSION;
readonly description = "ITU-R BS.1770-4 loudness measurement" as const;
private readonly options: {
readonly blockSize: number;
readonly overlap: number;
readonly targetLufs: number;
};
// Per-instance scratch filter for `analyzeBlock` reuse.
private scratchFilter: KWeightingFilter | undefined;
constructor(options?: LoudnessMeterOptions) {
const v = validateOptions(options);
this.options = {
blockSize: v.blockSize,
overlap: v.overlap,
targetLufs: v.targetLufs,
};
}
/**
* Full-document BS.1770-4 measurement.
*/
async analyze(
doc: AudioDocument,
opts?: LoudnessMeterOptions
): Promise<LoudnessResult> {
const merged = this.merge(opts);
ensureNotAborted(merged.signal);
ensureNonEmpty(doc);
const span = TRACER.startSpan("audio.analysis.loudness", {
attributes: {
"audio.sample_rate": doc.sampleRate,
"audio.channels": doc.channels,
"audio.duration_samples": doc.sourceLength,
},
});
const startedAt = performance.now();
try {
const result = await this.runEngine(doc, merged);
RUN_COUNTER.add(1, OP_TAGS);
DURATION_HIST.record(performance.now() - startedAt, OP_TAGS);
return result;
} catch (err) {
const error = err as Error;
span.setStatus({
code: SpanStatusCode.ERROR,
message: error.message,
});
span.recordException(error);
throw err;
} finally {
span.end();
}
}
/**
* Streaming integrated loudness. Yields a `LoudnessResult` after each
* gating block; the final yield equals the batch `analyze` result.
*/
async *analyzeStream(
doc: AudioDocument,
opts?: LoudnessMeterOptions
): AsyncGenerator<LoudnessResult> {
const merged = this.merge(opts);
ensureNotAborted(merged.signal);
ensureNonEmpty(doc);
const span = TRACER.startSpan("audio.analysis.loudness", {
attributes: { mode: "stream" },
});
const startedAt = performance.now();
try {
const filter = createKWeighting(doc.sampleRate, doc.channels);
const engine = new GatingEngine(
doc.channels,
doc.sampleRate,
merged.blockSize,
merged.overlap
);
const computedAt = Date.now();
for await (const raw of doc.stream({ signal: merged.signal })) {
ensureNotAborted(merged.signal);
const blockStart = performance.now();
const kw = filter.processBlock(raw);
const emitted = engine.push(kw, raw);
BLOCK_HIST.record(performance.now() - blockStart, OP_TAGS);
if (emitted > 0) {
yield this.finalise(doc, engine, computedAt);
}
}
// Always yield the final state even if no full block emitted.
yield this.finalise(doc, engine, computedAt);
RUN_COUNTER.add(1, { ...OP_TAGS, mode: "stream" });
DURATION_HIST.record(performance.now() - startedAt, {
...OP_TAGS,
mode: "stream",
});
} catch (err) {
const error = err as Error;
span.setStatus({
code: SpanStatusCode.ERROR,
message: error.message,
});
span.recordException(error);
throw err;
} finally {
span.end();
}
}
/**
* Synchronous, allocation-light block-level momentary metering. Returns a
* partial `LoudnessResult` carrying `lufs` (momentary), `peak`, and `rms`
* for the supplied block. Does not mutate the input.
*/
analyzeBlock(
block: readonly Float32Array[],
ctx: BlockContext
): Partial<LoudnessResult> {
const filter = this.ensureScratchFilter(ctx.sampleRate, block.length);
const kw = filter.processBlock(block);
const lufs = momentaryLufs(kw);
const sp = samplePeakLinear(block);
// RMS over the raw block.
let sqSum = 0;
let count = 0;
for (const ch of block) {
for (const v of ch) sqSum += v * v;
count += ch.length;
}
const rmsDb =
count > 0 && sqSum > 0
? 10 * Math.log10(sqSum / count)
: LUFS_SILENCE_FLOOR;
const peakDb = sp > 0 ? 20 * Math.log10(sp) : -200;
void ctx.startSample;
void ctx.channelCount;
return { lufs, rms: rmsDb, peak: peakDb };
}
private ensureScratchFilter(
sampleRate: number,
channels: number
): KWeightingFilter {
const existing = this.scratchFilter;
if (
existing !== undefined &&
existing.sampleRate === sampleRate &&
existing.channels === channels
)
return existing;
const fresh = createKWeighting(sampleRate, channels);
this.scratchFilter = fresh;
return fresh;
}
private merge(opts: LoudnessMeterOptions | undefined): {
blockSize: number;
overlap: number;
targetLufs: number;
signal: AbortSignal | undefined;
} {
if (opts === undefined) return { ...this.options, signal: undefined };
const v = validateOptions(opts);
return {
blockSize: v.blockSize,
overlap: v.overlap,
targetLufs: v.targetLufs,
signal: v.signal,
};
}
private async runEngine(
doc: AudioDocument,
merged: {
blockSize: number;
overlap: number;
targetLufs: number;
signal: AbortSignal | undefined;
}
): Promise<LoudnessResult> {
const filter = createKWeighting(doc.sampleRate, doc.channels);
const engine = new GatingEngine(
doc.channels,
doc.sampleRate,
merged.blockSize,
merged.overlap
);
const computedAt = Date.now();
for await (const raw of doc.stream({ signal: merged.signal })) {
ensureNotAborted(merged.signal);
const blockStart = performance.now();
const kw = filter.processBlock(raw);
engine.push(kw, raw);
BLOCK_HIST.record(performance.now() - blockStart, OP_TAGS);
}
void merged.targetLufs;
return this.finalise(doc, engine, computedAt);
}
private finalise(
doc: AudioDocument,
engine: GatingEngine,
computedAt: number
): LoudnessResult {
const lra = loudnessRange(engine.shortTermLufs).lra;
const tp =
engine.truePeakLinear > 0
? 20 * Math.log10(engine.truePeakLinear)
: -200;
return Object.freeze({
op: "loudness-meter" as const,
analyserName: this.name,
analyserVersion: this.version,
sampleRate: doc.sampleRate,
durationSamples: doc.sourceLength,
computedAt,
lufs: engine.integrator.integrated(),
loudnessRange: lra,
truePeak: tp,
rms: engine.rmsDb(),
peak: engine.peakDb(),
dcOffset: engine.dcOffsets(),
});
}
}

View File

@@ -0,0 +1,38 @@
import type { AnalysisResult } from "@suaveplan/audio-core";
/**
* Construction- and call-time options for `LoudnessMeter`. All fields are
* optional and validated through `LoudnessMeterOptionsSchema`.
*/
export interface LoudnessMeterOptions {
/** Gating-block length in milliseconds. Default: 400 (BS.1770-4). */
readonly blockSize?: number;
/** Gating-block overlap fraction (0..0.99). Default: 0.75 (75%). */
readonly overlap?: number;
/** Reference target in LUFS. Default: -14 (streaming). */
readonly targetLufs?: number;
/** Cooperative-cancellation signal honoured by analyze/analyzeStream. */
readonly signal?: AbortSignal;
}
/**
* Concrete loudness result extending `AnalysisResult`. All fields are
* always present — silence is reported via finite floors rather than
* `-Infinity`.
*/
export interface LoudnessResult extends AnalysisResult {
readonly op: "loudness-meter";
readonly lufs: number;
readonly loudnessRange: number;
readonly truePeak: number;
readonly rms: number;
readonly peak: number;
readonly dcOffset: readonly number[];
}
/** Synchronous block context for `analyzeBlock`. */
export interface BlockContext {
readonly sampleRate: number;
readonly channelCount: number;
readonly startSample?: number;
}

View File

@@ -0,0 +1,52 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("exports", () => {
it("every feature module has a subpath export entry", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { exports: Record<string, unknown> };
const required = [
".",
"./meter/loudness-meter",
"./algorithms/lufs",
"./algorithms/true-peak",
"./algorithms/loudness-range",
"./filters/k-weighting",
"./error-codes",
];
for (const sub of required) expect(sub in pkg.exports).toBe(true);
});
it("root barrel re-exports the documented surface", {
timeout: 5000,
}, async () => {
const mod = await import("../src/index.js");
const exports = Object.keys(mod);
const required = [
"LoudnessMeter",
"LoudnessMeterOptionsSchema",
"AudioAnalysisLoudnessError",
"AUDIO_ANALYSIS_LOUDNESS_CODES",
"AUDIO_ANALYSIS_LOUDNESS_ERROR_METADATA",
"registerAudioAnalysisLoudnessErrorCodes",
"buildKWeighting",
"createKWeighting",
"truePeakDbtp",
"samplePeakLinear",
"loudnessRange",
"momentaryLufs",
"shortTermLufs",
"IntegratedLoudness",
"energyToLufs",
"channelWeight",
"blockMeanSquare",
"combineChannelEnergies",
"percentile",
];
for (const sym of required) expect(exports).toContain(sym);
});
});

View File

@@ -0,0 +1,65 @@
import { AudioDocument } from "@suaveplan/audio-core";
const PAGE_SIZE = 1024;
/**
* Build an `AudioDocument` from pre-computed channel data. Splits each
* channel into pages of `PAGE_SIZE` samples.
*/
export function buildDoc(
channels: Float32Array[],
sampleRate: number
): AudioDocument {
const sourceLength = channels[0]?.length ?? 0;
const pages: { channelData: Float32Array[]; startSample: number }[] = [];
for (let start = 0; start < sourceLength; start += PAGE_SIZE) {
const len = Math.min(PAGE_SIZE, sourceLength - start);
const pageData: Float32Array[] = channels.map((ch) =>
ch.subarray(start, start + len)
);
pages.push({ channelData: pageData, startSample: start });
}
return new AudioDocument({
sampleRate,
channels: channels.length,
sourceLength,
pages,
});
}
/**
* Generate a sine-wave channel buffer at the given amplitude, frequency, and
* duration. Amplitude is linear (1.0 = 0 dBFS).
*/
export function sine(
amplitude: number,
frequencyHz: number,
sampleRate: number,
durationSeconds: number
): Float32Array {
const n = Math.round(durationSeconds * sampleRate);
const out = new Float32Array(n);
const w = (2 * Math.PI * frequencyHz) / sampleRate;
for (let i = 0; i < n; i++) out[i] = amplitude * Math.sin(w * i);
return out;
}
/** Constant-DC channel. */
export function constant(value: number, n: number): Float32Array {
const out = new Float32Array(n);
out.fill(value);
return out;
}
/** Concatenate channel segments. */
export function concat(...segments: Float32Array[]): Float32Array {
let total = 0;
for (const s of segments) total += s.length;
const out = new Float32Array(total);
let p = 0;
for (const s of segments) {
out.set(s, p);
p += s.length;
}
return out;
}

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { LoudnessMeter } from "../src/index.js";
import { buildDoc, sine } from "./helpers/build-doc.js";
describe("LoudnessMeter integration", () => {
it("full round-trip on a 48 kHz stereo 5 s fixture", {
timeout: 60000,
}, async () => {
const sr = 48000;
const left = sine(0.5, 1000, sr, 5);
const right = sine(0.5, 1000, sr, 5);
const doc = buildDoc([left, right], sr);
const result = await new LoudnessMeter().analyze(doc);
expect(result.op).toBe("loudness-meter");
expect(result.sampleRate).toBe(sr);
expect(result.dcOffset.length).toBe(2);
expect(result.truePeak).toBeGreaterThanOrEqual(result.peak - 1e-3);
});
it("44.1 kHz mono is handled with rate-adapted K-weighting", {
timeout: 30000,
}, async () => {
const sr = 44100;
const doc = buildDoc([sine(0.25, 1000, sr, 4)], sr);
const r = await new LoudnessMeter().analyze(doc);
expect(Number.isFinite(r.lufs)).toBe(true);
expect(Number.isFinite(r.truePeak)).toBe(true);
});
});

View File

@@ -0,0 +1,54 @@
import { describe, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import { LoudnessMeter, samplePeakLinear, truePeakDbtp } from "../src/index.js";
import { buildDoc, sine } from "./helpers/build-doc.js";
describe("LoudnessMeter properties", () => {
it("LUFS is monotonic with gain on a sine", {
timeout: 60000,
}, async () => {
await fc.assert(
fc.asyncProperty(
fc.double({ min: 0.05, max: 0.5, noNaN: true }),
fc.double({ min: 0.51, max: 0.95, noNaN: true }),
async (a, b) => {
const sr = 48000;
const dur = 3.5;
const meter = new LoudnessMeter();
const lo = await meter.analyze(
buildDoc([sine(a, 1000, sr, dur)], sr)
);
const hi = await meter.analyze(
buildDoc([sine(b, 1000, sr, dur)], sr)
);
return hi.lufs >= lo.lufs - 0.5;
}
),
{ numRuns: 6 }
);
});
it("true peak ≥ sample peak for arbitrary blocks", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(
fc.array(fc.double({ min: -1, max: 1, noNaN: true }), {
minLength: 32,
maxLength: 4096,
}),
(arr) => {
const ch = Float32Array.from(arr);
const sp = samplePeakLinear([ch]);
// Below Float32 quantisation, sample peak cannot be
// round-trip compared in dB: skip such degenerate inputs.
if (sp < 1e-6) return true;
const spDb = 20 * Math.log10(sp);
const tpDb = truePeakDbtp([ch]);
return tpDb >= spDb - 0.1;
}
),
{ numRuns: 25 }
);
});
});

View File

@@ -0,0 +1,74 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const PKG_DIR = join(__dirname, "..");
describe("purity", () => {
it("importing the package adds no Suaveplan-specific globals", {
timeout: 5000,
}, async () => {
const banned = ["__suaveplan_audio_loudness__", "LoudnessMeter"];
await import("../src/index.js");
for (const k of banned)
expect(Object.hasOwn(globalThis, k)).toBe(false);
});
it("dependency set is limited to the declared workspace and zod", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(PKG_DIR, "package.json"), "utf8")
);
const allowed = new Set([
"@suaveplan/audio-core",
"@suaveplan/error",
"@suaveplan/opentelemetry",
"@suaveplan/types",
"@opentelemetry/api",
"tslib",
"zod",
]);
for (const dep of Object.keys(pkg.dependencies ?? {}))
expect(allowed.has(dep)).toBe(true);
});
it("source references no browser or Node globals", {
timeout: 5000,
}, () => {
// Static-scan the source tree (dist is build output; not checked here).
const srcGlobs = [
"src/meter/loudness-meter.ts",
"src/algorithms/lufs.ts",
"src/algorithms/true-peak.ts",
"src/algorithms/loudness-range.ts",
"src/filters/k-weighting.ts",
"src/error-codes/error-codes.ts",
"src/index.ts",
];
// Only match identifier-use occurrences (e.g. `window.fetch`,
// `document.body`, `process.env`, `Buffer.from`), not the
// English-prose words "window" or "document" inside doc-comments
// and identifiers like `sourceLength`.
const banned = [
/\bwindow\.\w/,
/\bwindow\[/,
/\bdocument\.\w/,
/\bdocument\[/,
/\bnavigator\.\w/,
/\bAudioContext\b/,
/\bAudioBuffer\b/,
/\bAnalyserNode\b/,
/\brequire\(["']node:fs["']\)/,
/from\s+["']node:fs["']/,
/from\s+["']node:path["']/,
/\bprocess\.env/,
/\bprocess\.argv/,
/\bBuffer\.\w+/,
];
for (const rel of srcGlobs) {
const src = readFileSync(join(PKG_DIR, rel), "utf8");
for (const re of banned) expect(re.test(src)).toBe(false);
}
});
});

View File

@@ -0,0 +1,7 @@
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

@@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.lib.json",
"include": ["src/**/*.ts", "tests/**/*.ts", "src/**/*.test.ts"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,39 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/audio-analysis-loudness",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/audio-core",
"@suaveplan/error",
"@suaveplan/opentelemetry",
"@suaveplan/types",
"@opentelemetry/api",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,32 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/*.schemas.ts",
"src/**/index.ts",
"src/**/__benchmarks__/**",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
},
});