Skip to content

Commit 3746703

Browse files
feat(core): truepeak, a lookahead true-peak limiter FX (#5419)
* feat(core): add a lookahead true-peak limiter FX (truepeak) The existing limiter is an envelope follower with no lookahead and no inter-sample detection, so a -14 dB ceiling can leave the true peak several dB over. truepeak estimates the true peak on a 4x polyphase interpolator, takes the minimum required gain over a lookahead window and smooths it with a boxcar of the same length, so the gain is already down when the peak leaves the delay line. Release is exponential and can only lower the gain further; channels share one gain. The offline render lengthens the OfflineAudioContext by the chain's latency and drops the lead-in, so a limited clip stays sample-aligned with its source. Preview plays the lookahead (about 3.3 ms at the default). — Jerrai * docs(audio): truepeak ceiling is a 4x estimate; measure it with an ideal interpolator The unit test's meter was a 96-tap Kaiser sinc, which rolls off near Nyquist and reads full-band noise about 0.5 dB low, so the PR reported a 0.3 dB broadband overshoot. Against an ideal 16x interpolation (zero-padded FFT, cross-checked with a 16x soxr resample) hot full-band white noise ends 1.2 to 1.7 dB over the ceiling (median 1.4 over 28 runs of 10 to 30 s). ffmpeg's ebur128 true peak is also a 4x meter and reads the same files 0.1 dB over. - The test meter is now the FFT interpolation. Tolerances are what it measures on each fixed signal: tones 0.01 dB (they land on the ceiling), the dense program 0.5 dB (reads 0.445, the old 0.4 passed only because the meter read low), and a new full-band noise case at -1, -6 and -14 dBTP with 1.35 dB (reads 1.09 to 1.31). - audio-effects.mdx and the hyperframes-audio skill say the ceiling is a 4x estimate, give the measured overshoot and recommend a 2 dB margin under a hard delivery limit. - skills-manifest.json regenerated. — Jerrai * fix(core): truepeak UI copy no longer promises a hard ceiling The registry description and the rack summary said the true peak is never above the ceiling. The limiter holds a 4x estimate, and full-band noise can land up to about 1.7 dB above it, as the docs now say. The summary reads "held near", the description names the estimate and the overshoot, and the rack blurb says "holds ... to a ceiling". No skill file changed, so the skills manifest is unchanged. — Jerrai * fix(core): Studio truepeak copy states the 4x-estimate margin The ceiling control was labelled "Never exceed" and the module said the mix "must not clip", so a Studio user never saw that dense full-band content can end up to about 1.7 dB above the ceiling. The label is now "Target ceiling" with a hint that names the 4x estimate and the headroom to leave, the blurb says "toward a target ceiling", the rack summary says peaks are pulled down toward the value, and the registry hint no longer says "highest true peak allowed". The audio skill's "hard dBTP ceiling" wording is gone and the skills manifest is regenerated. A copy test keeps the promise out. — Jerrai * fix(core): truepeak clears on a seek, and the review nits (fallow, comments) - Unexport TRUE_PEAK_OVERSAMPLE, TRUE_PEAK_DETECTOR_DELAY and truePeakLookaheadSamples: nothing imports them (Fallow audit). - A persistent group bus keeps its chain across a seek, so the limiter's delay line and held gain carried over. The worklet takes __hfReset, the graph exposes FxChainHandle.reset(), and reanchor() calls it. - The processor-loading harness is shared by the truepeak and worklet tests (it was a 12-line clone); the test FFT no longer repeats audioCarve's bit-reversal; two test bodies are under the CRAP threshold. - Comment blocks cut to four lines or fewer (Comments check). - Docs state the preview delay in ms. — Jerrai
1 parent e23b3a3 commit 3746703

22 files changed

Lines changed: 867 additions & 49 deletions

‎docs/reference/audio-effects.mdx‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,13 +207,34 @@ Web Audio spec leaves it unused for them.
207207
| `gain` | `gain` −60–12 dB (0) **AUTO** |
208208
| `compressor` | `threshold` −60–0 dB (−24) · `ratio` 1–20 (4) · `attack` 0.01–2000 ms (20, log) · `release` 0.01–9000 ms (250, log) · `knee` 1–8 (2.83) · `makeup` 0–36 dB (0) · `mix` 0–1 (1) |
209209
| `limiter` | `limit` −24–0 dB (−1) · `attack` 0.1–80 ms (5) · `release` 1–8000 ms (50, log) · `level_out` −24–24 dB (0) |
210+
| `truepeak` | `ceiling` −24–0 dBTP (−1) · `lookahead` 0.5–10 ms (3) · `release` 10–2000 ms (80, log) |
210211
| `gate` | `threshold` −80–0 dB (−35) · `range` −80–0 dB (−24) · `ratio` 1–20 (10) · `attack` 0.01–9000 ms (1, log) · `release` 0.01–9000 ms (100, log) · `knee` 1–8 (2.83) |
211212

212213
Cuts on `gain` reach −60 dB, boosts stop at +12: it is a level stage for making
213214
room, and a chain that could add 40 dB would clip long before that was useful.
214215
`knee` of 1 is a hard corner. `mix` below 1 blends the dry signal back in
215216
(parallel compression). `range` is how far down the gate pulls when closed.
216217

218+
`limiter` is an envelope follower: it reacts to the level of the signal, has no
219+
lookahead, and can let a sample peak or an inter-sample peak through. `truepeak`
220+
is the one to use for a delivery ceiling. It estimates the true peak on a 4x
221+
oversampled signal, reads `lookahead` ms ahead so the gain is already down when
222+
the peak arrives, and holds that estimate at `ceiling`. Channels share one
223+
gain. It delays its output by `lookahead` plus about 0.3 ms (3.3 to 10.3 ms across
224+
the range): the offline render trims that delay, live preview plays it, so the
225+
sound is that much behind the picture. A seek clears the limiter's delay line and
226+
held gain on a group bus. Changing `lookahead` rebuilds the node.
227+
228+
The ceiling is held against that 4x estimate, not against the waveform itself. A
229+
4x interpolator cannot see the peaks of content close to Nyquist, so the true peak
230+
can end above `ceiling`. Measured against an ideal 16x interpolation, tones, pink
231+
noise and a dense mix ended at most 0.6 dB over. Full-band white noise driven 8 dB
232+
or more into the limiter ended 1.2 to 1.7 dB over (median 1.4 dB, 28 runs of 10
233+
to 30 s). A meter that also reads at 4x, such as ffmpeg's `ebur128=peak=true`,
234+
reports that same noise about 0.1 dB over, so it will not show the miss. Under a
235+
hard delivery limit set `ceiling` at least 2 dB below it: −3 dBTP for a −1 dBTP
236+
limit.
237+
217238
### Nonlinear — changes the waveform's shape
218239

219240
| Effect | Parameters |
@@ -253,7 +274,7 @@ Three kinds do not:
253274

254275
| Kind | Effects | Consequence |
255276
| --- | --- | --- |
256-
| Worklet processor options | `compressor`, `limiter`, `gate`, `bitcrush`, `pitchshift` | **No parameter is automatable at all** |
277+
| Worklet processor options | `compressor`, `limiter`, `truepeak`, `gate`, `bitcrush`, `pitchshift` | **No parameter is automatable at all** |
257278
| A WaveShaper curve | `saturate`'s `type`, `threshold`, `oversample` | Only its `output` stage is a real param |
258279
| A convolution impulse | `reverb`'s `size`, `damping` | `wet` / `dry` are gain stages and automate fine |
259280

‎packages/core/src/audio/audioFxGraph.test.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,35 @@ describe("levels and per-channel state", () => {
412412
expect(other.update(phaser({ speed: 2 }))).toBe(true);
413413
});
414414

415+
it("builds the true-peak limiter on its own processor and rebuilds when the lookahead moves", () => {
416+
workletNodes.length = 0;
417+
const limiter = (params: Record<string, number>): HfAudioFxChain => ({
418+
version: 1,
419+
nodes: [
420+
{
421+
type: "truepeak",
422+
enabled: true,
423+
params: { ...defaultAudioFxParams("truepeak"), ...params },
424+
},
425+
],
426+
});
427+
const built = buildFxChain(asCtx(ctx()), limiter({}));
428+
expect(workletNodes.map((w) => w.name)).toEqual(["hf-truepeak"]);
429+
expect(built.update(limiter({ ceiling: -3, release: 200 }))).toBe(true);
430+
// The delay line is sized from the lookahead when the processor is built.
431+
expect(built.update(limiter({ lookahead: 6 }))).toBe(false);
432+
});
433+
434+
it("resets only the effects that carry signal history", () => {
435+
workletNodes.length = 0;
436+
const built = buildFxChain(asCtx(ctx()), chain("truepeak", "compressor"));
437+
built.reset();
438+
expect(workletNodes.map((w) => [w.name, w.messages])).toEqual([
439+
["hf-truepeak", [{ __hfReset: true }]],
440+
["hf-compressor", []],
441+
]);
442+
});
443+
415444
/**
416445
* An LFO's phase is the whole reason it is a looping buffer rather than an
417446
* OscillatorNode, whose phase is zero at `start()` and cannot be set.

‎packages/core/src/audio/audioFxGraph.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ export interface FxNodeHandle {
8181
* parameter key. Absent for a node whose values cannot be scheduled.
8282
*/
8383
automation?: Record<string, FxParamTarget[]>;
84+
/** Drop internal state (delay lines, held gain) when the signal jumps, as on a seek. */
85+
reset?(): void;
8486
dispose(): void;
8587
}
8688

@@ -250,13 +252,14 @@ function onePoleBuilder(kind: "highpass" | "lowpass"): Builder {
250252
};
251253
}
252254

253-
function workletBuilder(processor: string): Builder {
255+
function workletBuilder(processor: string, resettable = false): Builder {
254256
return (ctx, p) => {
255257
const node = new AudioWorkletNode(ctx, processor, { processorOptions: { ...p } });
256258
return {
257259
input: node,
258260
output: node,
259261
update: (v) => node.port.postMessage({ ...v }),
262+
...(resettable ? { reset: () => node.port.postMessage({ __hfReset: true }) } : {}),
260263
dispose: () => {
261264
// Disconnecting is not enough to retire an AudioWorkletProcessor: it
262265
// lives until its `process()` returns false, and these all returned
@@ -503,6 +506,7 @@ const BUILDERS: Record<string, Builder> = {
503506
"biquad-lowpass": biquad("lowpass", false),
504507
"worklet-compressor": workletBuilder("hf-compressor"),
505508
"worklet-limiter": workletBuilder("hf-limiter"),
509+
"worklet-truepeak": workletBuilder("hf-truepeak", true),
506510
"worklet-gate": workletBuilder("hf-gate"),
507511
"worklet-bitcrush": workletBuilder("hf-bitcrush"),
508512
"worklet-pitchshift": workletBuilder("hf-pitchshift"),
@@ -549,6 +553,8 @@ export interface FxChainHandle {
549553
presets: Record<string, FxParamTarget[]>;
550554
/** Re-parameterise in place when the shape is unchanged; false if a rebuild is needed. */
551555
update(chain: HfAudioFxChain): boolean;
556+
/** Clear the state of every effect that carries signal history. */
557+
reset(): void;
552558
dispose(): void;
553559
}
554560

@@ -609,7 +615,8 @@ function shapeOf(chain: HfAudioFxChain): string {
609615
// author's own node while the render, which rebuilds, blended out the
610616
// preset's.
611617
const run = node.fromPreset ? `%${node.fromPreset}` : "";
612-
return `${node.type}${poles}${fixedFreq}${wave}${run}`;
618+
const lookahead = node.type === "truepeak" ? `^${p.lookahead}` : "";
619+
return `${node.type}${poles}${fixedFreq}${wave}${lookahead}${run}`;
613620
})
614621
.join("|");
615622
}
@@ -731,6 +738,9 @@ export function buildFxChain(
731738
// join per observer tick to write back the string that was already there.
732739
return true;
733740
},
741+
reset() {
742+
for (const { handle } of handles) handle.reset?.();
743+
},
734744
dispose() {
735745
for (const { handle } of handles) handle.dispose();
736746
// The wrap is not one of `handles` — it belongs to the chain rather than
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { ensureAudioFxWorklets } from "./audioFxWorklets.js";
2+
3+
export interface Processor {
4+
port: { postMessage(data: unknown): void };
5+
process(inputs: Float32Array[][], outputs: Float32Array[][]): boolean;
6+
}
7+
export type ProcessorClass = new (o: unknown) => Processor;
8+
9+
/** The registered processors, evaluated from the module `addModule` is handed. */
10+
export async function loadProcessors(sampleRate: number): Promise<Map<string, ProcessorClass>> {
11+
let moduleSource = "";
12+
await ensureAudioFxWorklets({
13+
audioWorklet: {
14+
addModule: async (url: string) => {
15+
moduleSource = atob(url.replace("data:text/javascript;base64,", ""));
16+
},
17+
},
18+
} as unknown as BaseAudioContext);
19+
const made = new Map<string, ProcessorClass>();
20+
class Base {
21+
port = {
22+
onmessage: null as ((e: { data: unknown }) => void) | null,
23+
postMessage: (data: unknown) => this.port.onmessage?.({ data }),
24+
};
25+
}
26+
new Function("AudioWorkletProcessor", "registerProcessor", "sampleRate", moduleSource)(
27+
Base,
28+
(name: string, cls: ProcessorClass) => made.set(name, cls),
29+
sampleRate,
30+
);
31+
return made;
32+
}

‎packages/core/src/audio/audioFxTail.test.ts‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, it } from "vitest";
2-
import { chainTailSeconds, MAX_FX_TAIL_SECONDS } from "./audioFxTail.js";
2+
import { chainLatencySamples, chainTailSeconds, MAX_FX_TAIL_SECONDS } from "./audioFxTail.js";
3+
import { truePeakLatencySamples } from "./audioFxTruePeak.js";
34
import { synthesizeReverbImpulse } from "./audioFxGraph.js";
45
import type { HfAudioFxChain } from "../audioFx.js";
56
import type { HfAutomation } from "../audioAutomation.js";
@@ -101,3 +102,31 @@ describe("chainTailSeconds", () => {
101102
expect(chainTailSeconds(withLane, automation)).toBeCloseTo(0.6 + 0.5 * 2.6, 5);
102103
});
103104
});
105+
106+
describe("chainLatencySamples", () => {
107+
it("is zero for a chain with no lookahead", () => {
108+
expect(
109+
chainLatencySamples(
110+
chain([{ type: "limiter", id: "l", params: { limit: -1, attack: 5, release: 50 } }]),
111+
48000,
112+
),
113+
).toBe(0);
114+
});
115+
116+
it("sums each enabled true-peak limiter's lookahead plus detector delay", () => {
117+
const nodes: HfAudioFxChain["nodes"] = [
118+
{ type: "truepeak", id: "a", params: { ceiling: -1, lookahead: 3, release: 80 } },
119+
{ type: "truepeak", id: "b", params: { ceiling: -1, lookahead: 1.5, release: 80 } },
120+
{ type: "truepeak", id: "c", enabled: false, params: { ceiling: -1, lookahead: 9 } },
121+
];
122+
expect(chainLatencySamples(chain(nodes), 48000)).toBe(
123+
truePeakLatencySamples(3, 48000) + truePeakLatencySamples(1.5, 48000),
124+
);
125+
});
126+
127+
it("reads the registry default when the attribute omits the lookahead", () => {
128+
expect(chainLatencySamples(chain([{ type: "truepeak", id: "a", params: {} }]), 48000)).toBe(
129+
truePeakLatencySamples(3, 48000),
130+
);
131+
});
132+
});

‎packages/core/src/audio/audioFxTail.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,13 @@
99
*/
1010

1111
import { fxAutomationTarget, type HfAutomation } from "../audioAutomation.js";
12-
import { normalizeAudioFxParams, type HfAudioFxChain, type HfAudioFxNode } from "../audioFx.js";
12+
import {
13+
enabledAudioFxNodes,
14+
normalizeAudioFxParams,
15+
type HfAudioFxChain,
16+
type HfAudioFxNode,
17+
} from "../audioFx.js";
18+
import { truePeakLatencySamples } from "./audioFxTruePeak.js";
1319

1420
/**
1521
* Ceiling on the extension, in seconds.
@@ -113,3 +119,13 @@ export function chainTailSeconds(chain: HfAudioFxChain, automation?: HfAutomatio
113119
const total = chain.nodes.reduce((sum, node) => sum + nodeTail(node, automation), 0);
114120
return Math.min(MAX_FX_TAIL_SECONDS, total);
115121
}
122+
123+
/** Samples the offline render trims so a lookahead limiter does not shift a clip. */
124+
export function chainLatencySamples(chain: HfAudioFxChain, sampleRate: number): number {
125+
return enabledAudioFxNodes(chain)
126+
.filter((node) => node.type === "truepeak")
127+
.reduce((sum, node) => {
128+
const { lookahead } = normalizeAudioFxParams(node.type, node.params);
129+
return sum + truePeakLatencySamples(Number(lookahead), sampleRate);
130+
}, 0);
131+
}

0 commit comments

Comments
 (0)