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
This commit is contained in:
Jerrai
2026-10-10 18:28:21 +00:00
committed by GitHub
parent e23b3a3e31
commit 3746703ea7
22 changed files with 867 additions and 49 deletions
+22 -1
View File
@@ -207,6 +207,7 @@ Web Audio spec leaves it unused for them.
| `gain` | `gain` −60–12 dB (0) **AUTO** |
| `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) |
| `limiter` | `limit` −24–0 dB (−1) · `attack` 0.1–80 ms (5) · `release` 1–8000 ms (50, log) · `level_out` −24–24 dB (0) |
| `truepeak` | `ceiling` −24–0 dBTP (−1) · `lookahead` 0.5–10 ms (3) · `release` 10–2000 ms (80, log) |
| `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) |
Cuts on `gain` reach −60 dB, boosts stop at +12: it is a level stage for making
@@ -214,6 +215,26 @@ room, and a chain that could add 40 dB would clip long before that was useful.
`knee` of 1 is a hard corner. `mix` below 1 blends the dry signal back in
(parallel compression). `range` is how far down the gate pulls when closed.
`limiter` is an envelope follower: it reacts to the level of the signal, has no
lookahead, and can let a sample peak or an inter-sample peak through. `truepeak`
is the one to use for a delivery ceiling. It estimates the true peak on a 4x
oversampled signal, reads `lookahead` ms ahead so the gain is already down when
the peak arrives, and holds that estimate at `ceiling`. Channels share one
gain. It delays its output by `lookahead` plus about 0.3 ms (3.3 to 10.3 ms across
the range): the offline render trims that delay, live preview plays it, so the
sound is that much behind the picture. A seek clears the limiter's delay line and
held gain on a group bus. Changing `lookahead` rebuilds the node.
The ceiling is held against that 4x estimate, not against the waveform itself. A
4x interpolator cannot see the peaks of content close to Nyquist, so the true peak
can end above `ceiling`. Measured against an ideal 16x interpolation, tones, pink
noise and a dense mix ended at most 0.6 dB over. Full-band white noise driven 8 dB
or more into the limiter ended 1.2 to 1.7 dB over (median 1.4 dB, 28 runs of 10
to 30 s). A meter that also reads at 4x, such as ffmpeg's `ebur128=peak=true`,
reports that same noise about 0.1 dB over, so it will not show the miss. Under a
hard delivery limit set `ceiling` at least 2 dB below it: −3 dBTP for a −1 dBTP
limit.
### Nonlinear — changes the waveform's shape
| Effect | Parameters |
@@ -253,7 +274,7 @@ Three kinds do not:
| Kind | Effects | Consequence |
| --- | --- | --- |
| Worklet processor options | `compressor`, `limiter`, `gate`, `bitcrush`, `pitchshift` | **No parameter is automatable at all** |
| Worklet processor options | `compressor`, `limiter`, `truepeak`, `gate`, `bitcrush`, `pitchshift` | **No parameter is automatable at all** |
| A WaveShaper curve | `saturate`'s `type`, `threshold`, `oversample` | Only its `output` stage is a real param |
| A convolution impulse | `reverb`'s `size`, `damping` | `wet` / `dry` are gain stages and automate fine |
@@ -412,6 +412,35 @@ describe("levels and per-channel state", () => {
expect(other.update(phaser({ speed: 2 }))).toBe(true);
});
it("builds the true-peak limiter on its own processor and rebuilds when the lookahead moves", () => {
workletNodes.length = 0;
const limiter = (params: Record<string, number>): HfAudioFxChain => ({
version: 1,
nodes: [
{
type: "truepeak",
enabled: true,
params: { ...defaultAudioFxParams("truepeak"), ...params },
},
],
});
const built = buildFxChain(asCtx(ctx()), limiter({}));
expect(workletNodes.map((w) => w.name)).toEqual(["hf-truepeak"]);
expect(built.update(limiter({ ceiling: -3, release: 200 }))).toBe(true);
// The delay line is sized from the lookahead when the processor is built.
expect(built.update(limiter({ lookahead: 6 }))).toBe(false);
});
it("resets only the effects that carry signal history", () => {
workletNodes.length = 0;
const built = buildFxChain(asCtx(ctx()), chain("truepeak", "compressor"));
built.reset();
expect(workletNodes.map((w) => [w.name, w.messages])).toEqual([
["hf-truepeak", [{ __hfReset: true }]],
["hf-compressor", []],
]);
});
/**
* An LFO's phase is the whole reason it is a looping buffer rather than an
* OscillatorNode, whose phase is zero at `start()` and cannot be set.
+12 -2
View File
@@ -81,6 +81,8 @@ export interface FxNodeHandle {
* parameter key. Absent for a node whose values cannot be scheduled.
*/
automation?: Record<string, FxParamTarget[]>;
/** Drop internal state (delay lines, held gain) when the signal jumps, as on a seek. */
reset?(): void;
dispose(): void;
}
@@ -250,13 +252,14 @@ function onePoleBuilder(kind: "highpass" | "lowpass"): Builder {
};
}
function workletBuilder(processor: string): Builder {
function workletBuilder(processor: string, resettable = false): Builder {
return (ctx, p) => {
const node = new AudioWorkletNode(ctx, processor, { processorOptions: { ...p } });
return {
input: node,
output: node,
update: (v) => node.port.postMessage({ ...v }),
...(resettable ? { reset: () => node.port.postMessage({ __hfReset: true }) } : {}),
dispose: () => {
// Disconnecting is not enough to retire an AudioWorkletProcessor: it
// lives until its `process()` returns false, and these all returned
@@ -503,6 +506,7 @@ const BUILDERS: Record<string, Builder> = {
"biquad-lowpass": biquad("lowpass", false),
"worklet-compressor": workletBuilder("hf-compressor"),
"worklet-limiter": workletBuilder("hf-limiter"),
"worklet-truepeak": workletBuilder("hf-truepeak", true),
"worklet-gate": workletBuilder("hf-gate"),
"worklet-bitcrush": workletBuilder("hf-bitcrush"),
"worklet-pitchshift": workletBuilder("hf-pitchshift"),
@@ -549,6 +553,8 @@ export interface FxChainHandle {
presets: Record<string, FxParamTarget[]>;
/** Re-parameterise in place when the shape is unchanged; false if a rebuild is needed. */
update(chain: HfAudioFxChain): boolean;
/** Clear the state of every effect that carries signal history. */
reset(): void;
dispose(): void;
}
@@ -609,7 +615,8 @@ function shapeOf(chain: HfAudioFxChain): string {
// author's own node while the render, which rebuilds, blended out the
// preset's.
const run = node.fromPreset ? `%${node.fromPreset}` : "";
return `${node.type}${poles}${fixedFreq}${wave}${run}`;
const lookahead = node.type === "truepeak" ? `^${p.lookahead}` : "";
return `${node.type}${poles}${fixedFreq}${wave}${lookahead}${run}`;
})
.join("|");
}
@@ -731,6 +738,9 @@ export function buildFxChain(
// join per observer tick to write back the string that was already there.
return true;
},
reset() {
for (const { handle } of handles) handle.reset?.();
},
dispose() {
for (const { handle } of handles) handle.dispose();
// The wrap is not one of `handles` — it belongs to the chain rather than
@@ -0,0 +1,32 @@
import { ensureAudioFxWorklets } from "./audioFxWorklets.js";
export interface Processor {
port: { postMessage(data: unknown): void };
process(inputs: Float32Array[][], outputs: Float32Array[][]): boolean;
}
export type ProcessorClass = new (o: unknown) => Processor;
/** The registered processors, evaluated from the module `addModule` is handed. */
export async function loadProcessors(sampleRate: number): Promise<Map<string, ProcessorClass>> {
let moduleSource = "";
await ensureAudioFxWorklets({
audioWorklet: {
addModule: async (url: string) => {
moduleSource = atob(url.replace("data:text/javascript;base64,", ""));
},
},
} as unknown as BaseAudioContext);
const made = new Map<string, ProcessorClass>();
class Base {
port = {
onmessage: null as ((e: { data: unknown }) => void) | null,
postMessage: (data: unknown) => this.port.onmessage?.({ data }),
};
}
new Function("AudioWorkletProcessor", "registerProcessor", "sampleRate", moduleSource)(
Base,
(name: string, cls: ProcessorClass) => made.set(name, cls),
sampleRate,
);
return made;
}
+30 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { chainTailSeconds, MAX_FX_TAIL_SECONDS } from "./audioFxTail.js";
import { chainLatencySamples, chainTailSeconds, MAX_FX_TAIL_SECONDS } from "./audioFxTail.js";
import { truePeakLatencySamples } from "./audioFxTruePeak.js";
import { synthesizeReverbImpulse } from "./audioFxGraph.js";
import type { HfAudioFxChain } from "../audioFx.js";
import type { HfAutomation } from "../audioAutomation.js";
@@ -101,3 +102,31 @@ describe("chainTailSeconds", () => {
expect(chainTailSeconds(withLane, automation)).toBeCloseTo(0.6 + 0.5 * 2.6, 5);
});
});
describe("chainLatencySamples", () => {
it("is zero for a chain with no lookahead", () => {
expect(
chainLatencySamples(
chain([{ type: "limiter", id: "l", params: { limit: -1, attack: 5, release: 50 } }]),
48000,
),
).toBe(0);
});
it("sums each enabled true-peak limiter's lookahead plus detector delay", () => {
const nodes: HfAudioFxChain["nodes"] = [
{ type: "truepeak", id: "a", params: { ceiling: -1, lookahead: 3, release: 80 } },
{ type: "truepeak", id: "b", params: { ceiling: -1, lookahead: 1.5, release: 80 } },
{ type: "truepeak", id: "c", enabled: false, params: { ceiling: -1, lookahead: 9 } },
];
expect(chainLatencySamples(chain(nodes), 48000)).toBe(
truePeakLatencySamples(3, 48000) + truePeakLatencySamples(1.5, 48000),
);
});
it("reads the registry default when the attribute omits the lookahead", () => {
expect(chainLatencySamples(chain([{ type: "truepeak", id: "a", params: {} }]), 48000)).toBe(
truePeakLatencySamples(3, 48000),
);
});
});
+17 -1
View File
@@ -9,7 +9,13 @@
*/
import { fxAutomationTarget, type HfAutomation } from "../audioAutomation.js";
import { normalizeAudioFxParams, type HfAudioFxChain, type HfAudioFxNode } from "../audioFx.js";
import {
enabledAudioFxNodes,
normalizeAudioFxParams,
type HfAudioFxChain,
type HfAudioFxNode,
} from "../audioFx.js";
import { truePeakLatencySamples } from "./audioFxTruePeak.js";
/**
* Ceiling on the extension, in seconds.
@@ -113,3 +119,13 @@ export function chainTailSeconds(chain: HfAudioFxChain, automation?: HfAutomatio
const total = chain.nodes.reduce((sum, node) => sum + nodeTail(node, automation), 0);
return Math.min(MAX_FX_TAIL_SECONDS, total);
}
/** Samples the offline render trims so a lookahead limiter does not shift a clip. */
export function chainLatencySamples(chain: HfAudioFxChain, sampleRate: number): number {
return enabledAudioFxNodes(chain)
.filter((node) => node.type === "truepeak")
.reduce((sum, node) => {
const { lookahead } = normalizeAudioFxParams(node.type, node.params);
return sum + truePeakLatencySamples(Number(lookahead), sampleRate);
}, 0);
}
@@ -0,0 +1,356 @@
import { describe, expect, it } from "vitest";
import { loadProcessors, type Processor } from "./audioFxProcessors.test-helpers.js";
import { truePeakLatencySamples } from "./audioFxTruePeak.js";
const SR = 48000;
const BLOCK = 128;
const processors = await loadProcessors(SR);
function makeProcessor(name: string, options: Record<string, number>): Processor {
const Cls = processors.get(name);
if (!Cls) throw new Error(`no processor ${name}`);
return new Cls({ processorOptions: options });
}
/** Run planes through a processor in render quanta, returning planes of the same length. */
function run(p: Processor, planes: Float32Array[]): Float32Array[] {
const frames = planes[0]?.length ?? 0;
const out = planes.map(() => new Float32Array(frames));
for (let at = 0; at < frames; at += BLOCK) {
const n = Math.min(BLOCK, frames - at);
const inBlock = planes.map((pl) => {
const b = new Float32Array(BLOCK);
b.set(pl.subarray(at, at + n));
return b;
});
const outBlock = planes.map(() => new Float32Array(BLOCK));
p.process([inBlock], [outBlock]);
outBlock.forEach((b, c) => out[c]?.set(b.subarray(0, n), at));
}
return out;
}
const sampleOf = (plane: Float32Array | undefined, i: number): number => plane?.[i] ?? 0;
const dbToLin = (db: number): number => Math.pow(10, db / 20);
const linToDb = (lin: number): number => 20 * Math.log10(Math.max(lin, 1e-12));
/** In-place radix-2 FFT; the inverse leaves the 1/n scale to the caller. */
function fft(re: Float64Array, im: Float64Array, inverse: boolean): void {
const n = re.length;
const bits = Math.log2(n);
for (let i = 0; i < n; i++) {
let j = 0;
for (let b = 0; b < bits; b++) j |= ((i >> b) & 1) << (bits - 1 - b);
if (j > i) {
[re[i], re[j]] = [re[j] as number, re[i] as number];
[im[i], im[j]] = [im[j] as number, im[i] as number];
}
}
for (let len = 2; len <= n; len <<= 1) {
const half = len >> 1;
const angle = ((inverse ? 2 : -2) * Math.PI) / len;
const stepRe = Math.cos(angle);
const stepIm = Math.sin(angle);
for (let at = 0; at < n; at += len) {
let wr = 1;
let wi = 0;
for (let k = 0; k < half; k++) {
const a = at + k;
const b = a + half;
const tr = (re[b] as number) * wr - (im[b] as number) * wi;
const ti = (re[b] as number) * wi + (im[b] as number) * wr;
re[b] = (re[a] as number) - tr;
im[b] = (im[a] as number) - ti;
re[a] = (re[a] as number) + tr;
im[a] = (im[a] as number) + ti;
const next = wr * stepRe - wi * stepIm;
wi = wr * stepIm + wi * stepRe;
wr = next;
}
}
}
}
/** Samples at each end left out of the peak search: the zero padding rings there. */
const METER_EDGE = 512;
const METER_OVERSAMPLE = 16;
/**
* True peak in dBFS from an ideal 16x interpolation (zero-padded FFT), not the
* limiter's 4x detector: a finite FIR meter reads full-band noise about 0.5 dB low.
*/
function truePeakDb(x: Float32Array): number {
let n = 1;
while (n < x.length) n <<= 1;
const re = new Float64Array(n);
const im = new Float64Array(n);
re.set(x);
fft(re, im, false);
const m = n * METER_OVERSAMPLE;
const upRe = new Float64Array(m);
const upIm = new Float64Array(m);
const half = n >> 1;
for (let k = 0; k < half; k++) {
upRe[k] = re[k] as number;
upIm[k] = im[k] as number;
if (k > 0) {
upRe[m - k] = re[n - k] as number;
upIm[m - k] = im[n - k] as number;
}
}
// The Nyquist bin is real and belongs to both sides of the wider spectrum.
upRe[half] = (re[half] as number) / 2;
upRe[m - half] = (re[half] as number) / 2;
fft(upRe, upIm, true);
let peak = 0;
const from = METER_EDGE * METER_OVERSAMPLE;
const to = (x.length - METER_EDGE) * METER_OVERSAMPLE;
for (let i = from; i < to; i++) peak = Math.max(peak, Math.abs(upRe[i] as number) / n);
return linToDb(peak);
}
const sine = (freq: number, amp: number, seconds: number, phase = 0): Float32Array => {
const out = new Float32Array(Math.round(seconds * SR));
for (let i = 0; i < out.length; i++)
out[i] = amp * Math.sin(2 * Math.PI * freq * (i / SR) + phase);
return out;
};
/** What the ideal-interpolation meter reads on tones, rounded up: they land on the ceiling. */
const TOLERANCE_DB = 0.01;
/** A dense program of tones and low-passed noise bursts reads 0.445 dB over. */
const BROADBAND_TOLERANCE_DB = 0.5;
/** This 2 s hot Gaussian noise reads 1.09 to 1.31 dB over; across 28 longer runs the worst was 1.67. */
const FULL_BAND_NOISE_TOLERANCE_DB = 1.35;
describe("hf-truepeak", () => {
it("holds an inter-sample peak that the sample peak hides under the ceiling", () => {
// fs/4 at 45 degrees: every sample is +-0.707 but the waveform between them
// reaches 1.0, a +3 dB overshoot no sample-peak detector can see.
const input = sine(SR / 4, 1, 0.5, Math.PI / 4);
expect(linToDb(Math.max(...input.map(Math.abs)))).toBeCloseTo(-3.01, 1);
expect(truePeakDb(input)).toBeGreaterThan(-0.2);
const ceiling = -6;
const [out] = run(makeProcessor("hf-truepeak", { ceiling, lookahead: 3, release: 80 }), [
input,
]);
expect(truePeakDb(out as Float32Array)).toBeLessThanOrEqual(ceiling + TOLERANCE_DB);
});
it("does not let the envelope limiter's miss through: same signal, same ceiling", () => {
const input = sine(SR / 4, 1, 0.5, Math.PI / 4);
const ceiling = -6;
const [old] = run(makeProcessor("hf-limiter", { limit: ceiling, attack: 5, release: 50 }), [
input,
]);
const [next] = run(makeProcessor("hf-truepeak", { ceiling, lookahead: 3, release: 80 }), [
input,
]);
expect(truePeakDb(old as Float32Array)).toBeGreaterThan(ceiling + 1);
expect(truePeakDb(next as Float32Array)).toBeLessThanOrEqual(ceiling + TOLERANCE_DB);
});
it("is in place before an abrupt onset reaches the output", () => {
const quiet = sine(997, 0.1, 0.2);
const loud = sine(997, 1.4, 0.2);
const input = new Float32Array(quiet.length + loud.length);
input.set(quiet);
input.set(loud, quiet.length);
const ceiling = -3;
const [out] = run(makeProcessor("hf-truepeak", { ceiling, lookahead: 1.5, release: 120 }), [
input,
]);
expect(truePeakDb(out as Float32Array)).toBeLessThanOrEqual(ceiling + TOLERANCE_DB);
});
it("keeps a dense program with random peaks and broadband noise under the ceiling", () => {
let seed = 12345;
const rand = (): number => {
seed = (seed * 1664525 + 1013904223) >>> 0;
return seed / 0xffffffff - 0.5;
};
const input = new Float32Array(SR);
const tones = [220, 1450, 5200, 11800];
let smooth = 0;
for (let i = 0; i < input.length; i++) {
let v = 0;
for (const f of tones) v += 0.35 * Math.sin(2 * Math.PI * f * (i / SR) + f);
// Noise through a one-pole lowpass near 6 kHz, gated into bursts.
smooth += 0.5 * (rand() - smooth);
input[i] = v + 3 * smooth * (Math.sin(2 * Math.PI * 3 * (i / SR)) > 0.7 ? 1 : 0.1);
}
expect(truePeakDb(input)).toBeGreaterThan(3);
const ceiling = -14;
const [out] = run(makeProcessor("hf-truepeak", { ceiling, lookahead: 3, release: 80 }), [
input,
]);
expect(truePeakDb(out as Float32Array)).toBeLessThanOrEqual(ceiling + BROADBAND_TOLERANCE_DB);
});
it("lets full-band noise end up over the ceiling by the measured distance, no further", () => {
let seed = 99;
const uniform = (): number => {
seed = (seed * 1664525 + 1013904223) >>> 0;
return (seed + 0.5) / 4294967296;
};
const input = new Float32Array(SR * 2);
for (let i = 0; i < input.length; i++) {
input[i] = 0.5 * Math.sqrt(-2 * Math.log(uniform())) * Math.cos(2 * Math.PI * uniform());
}
expect(truePeakDb(input)).toBeGreaterThan(6);
for (const ceiling of [-1, -6, -14]) {
const [out] = run(makeProcessor("hf-truepeak", { ceiling, lookahead: 3, release: 80 }), [
input,
]);
const over = truePeakDb(out as Float32Array) - ceiling;
expect(over).toBeLessThanOrEqual(FULL_BAND_NOISE_TOLERANCE_DB);
}
}, 30_000);
it("passes a signal under the ceiling unchanged, delayed by exactly its latency", () => {
const input = sine(440, 0.1, 0.3);
const lookahead = 3;
const latency = truePeakLatencySamples(lookahead, SR);
const [out] = run(makeProcessor("hf-truepeak", { ceiling: -1, lookahead, release: 80 }), [
input,
]);
for (let i = latency; i < input.length; i++) {
expect(out?.[i]).toBeCloseTo(input[i - latency] ?? 0, 6);
}
});
it("delays an impulse by truePeakLatencySamples and by nothing else", () => {
for (const lookahead of [0.5, 1.5, 3, 10]) {
const input = new Float32Array(2400);
input[100] = 0.1;
const [out] = run(makeProcessor("hf-truepeak", { ceiling: -1, lookahead, release: 80 }), [
input,
]);
const at = (out as Float32Array).findIndex((v) => Math.abs(v) > 1e-6);
expect(at).toBe(100 + truePeakLatencySamples(lookahead, SR));
expect(out?.[at]).toBeCloseTo(0.1, 6);
}
});
describe("release", () => {
/** Output peak over input peak, in 1 ms windows, aligned for the latency. */
function gainAt(input: Float32Array, out: Float32Array, latency: number, t: number): number {
const win = Math.round(0.001 * SR);
const start = Math.round(t * SR);
let a = 0;
let b = 0;
for (let i = start; i < start + win; i++) {
a = Math.max(a, Math.abs(input[i] ?? 0));
b = Math.max(b, Math.abs(out[i + latency] ?? 0));
}
return b / a;
}
function burst(): { input: Float32Array; endsAt: number } {
const bed = sine(1000, 0.25, 0.3);
const hit = sine(1000, 1, 0.05);
const tail = sine(1000, 0.25, 1.2);
const input = new Float32Array(bed.length + hit.length + tail.length);
input.set(bed);
input.set(hit, bed.length);
input.set(tail, bed.length + hit.length);
return { input, endsAt: (bed.length + hit.length) / SR };
}
it("recovers along the release time constant after the peak has passed", () => {
const { input, endsAt } = burst();
const lookahead = 3;
const latency = truePeakLatencySamples(lookahead, SR);
const ceiling = -6;
const [out] = run(makeProcessor("hf-truepeak", { ceiling, lookahead, release: 100 }), [
input,
]);
const held = gainAt(input, out as Float32Array, latency, endsAt - 0.01);
expect(held).toBeCloseTo(dbToLin(ceiling), 1);
// One time constant after the hit: a one-pole has closed 63% of the gap.
const after = gainAt(input, out as Float32Array, latency, endsAt + 0.1);
const expected = 1 - (1 - held) * Math.exp(-1);
expect(after).toBeGreaterThan(expected - 0.03);
expect(after).toBeLessThan(expected + 0.03);
expect(gainAt(input, out as Float32Array, latency, endsAt + 1)).toBeGreaterThan(0.995);
});
it("recovers more slowly with a longer release", () => {
const { input, endsAt } = burst();
const latency = truePeakLatencySamples(3, SR);
const quick = run(makeProcessor("hf-truepeak", { ceiling: -6, lookahead: 3, release: 50 }), [
input,
])[0] as Float32Array;
const slow = run(makeProcessor("hf-truepeak", { ceiling: -6, lookahead: 3, release: 400 }), [
input,
])[0] as Float32Array;
const t = endsAt + 0.15;
expect(gainAt(input, quick, latency, t)).toBeGreaterThan(
gainAt(input, slow, latency, t) + 0.15,
);
});
});
it("applies one gain to every channel so the image does not move", () => {
const loud = sine(SR / 4, 1, 0.3, Math.PI / 4);
const quiet = sine(330, 0.05, 0.3);
const latency = truePeakLatencySamples(3, SR);
const [l, r] = run(makeProcessor("hf-truepeak", { ceiling: -6, lookahead: 3, release: 80 }), [
loud,
quiet,
]);
// The quiet channel is far under the ceiling on its own, yet it ducks with the loud one.
const gains: [number, number][] = [];
for (let k = Math.round(0.2 * SR); k < Math.round(0.25 * SR); k++) {
const inL = sampleOf(loud, k);
const inR = sampleOf(quiet, k);
if (Math.abs(inL) >= 0.3 && Math.abs(inR) >= 0.02) {
gains.push([sampleOf(l, k + latency) / inL, sampleOf(r, k + latency) / inR]);
}
}
expect(gains.length).toBeGreaterThan(100);
for (const [gainL, gainR] of gains) {
expect(gainL).toBeLessThan(0.9);
expect(gainR).toBeCloseTo(gainL, 3);
}
});
it("forgets its delay line and held gain when told the signal jumped", () => {
const loud = sine(SR / 4, 1, 0.1, Math.PI / 4);
const quiet = sine(440, 0.05, 0.1);
const p = makeProcessor("hf-truepeak", { ceiling: -6, lookahead: 3, release: 2000 });
run(p, [loud]);
p.port.postMessage({ __hfReset: true });
const [out] = run(p, [quiet]);
const latency = truePeakLatencySamples(3, SR);
expect(Math.max(...(out as Float32Array).subarray(0, latency).map(Math.abs))).toBe(0);
for (let i = latency; i < quiet.length; i++) {
expect(out?.[i]).toBeCloseTo(quiet[i - latency] ?? 0, 6);
}
const kept = makeProcessor("hf-truepeak", { ceiling: -6, lookahead: 3, release: 2000 });
run(kept, [loud]);
const [carried] = run(kept, [quiet]);
expect(
Math.max(...(carried as Float32Array).subarray(0, latency).map(Math.abs)),
).toBeGreaterThan(0);
expect(Math.abs((carried?.[latency + 200] ?? 0) / (quiet[200] ?? 1))).toBeLessThan(0.9);
});
it("takes ceiling and release live but keeps the lookahead it was built with", () => {
const p = makeProcessor("hf-truepeak", { ceiling: 0, lookahead: 3, release: 80 });
const input = sine(SR / 4, 1, 0.3, Math.PI / 4);
p.port.postMessage({ ceiling: -6, lookahead: 10 });
const [out] = run(p, [input]);
expect(truePeakDb(out as Float32Array)).toBeLessThanOrEqual(-6 + TOLERANCE_DB);
const impulse = new Float32Array(2400);
impulse[50] = 0.05;
const q = makeProcessor("hf-truepeak", { ceiling: -1, lookahead: 3, release: 80 });
q.port.postMessage({ lookahead: 10 });
const [o2] = run(q, [impulse]);
expect((o2 as Float32Array).findIndex((v) => Math.abs(v) > 1e-6)).toBe(
50 + truePeakLatencySamples(3, SR),
);
});
});
+169
View File
@@ -0,0 +1,169 @@
/**
* The `hf-truepeak` worklet: a lookahead limiter that holds a 4x estimate of the
* true peak at a ceiling in dBTP; full-band noise can end up to 1.7 dB over.
*/
const TRUE_PEAK_OVERSAMPLE = 4;
/** Group delay of the interpolator, in input samples. */
const TRUE_PEAK_DETECTOR_DELAY = 16;
const KERNEL_HALF = TRUE_PEAK_OVERSAMPLE * TRUE_PEAK_DETECTOR_DELAY;
/** Taps per polyphase branch: the longest branch of a 2 * KERNEL_HALF + 1 kernel. */
const BRANCH_TAPS = 2 * TRUE_PEAK_DETECTOR_DELAY + 1;
function truePeakLookaheadSamples(lookaheadMs: number, sampleRate: number): number {
return Math.max(1, Math.round((lookaheadMs * sampleRate) / 1000));
}
/** Samples between a sample entering the limiter and leaving it. */
export function truePeakLatencySamples(lookaheadMs: number, sampleRate: number): number {
return truePeakLookaheadSamples(lookaheadMs, sampleRate) + TRUE_PEAK_DETECTOR_DELAY;
}
export const TRUE_PEAK_WORKLET_SOURCE = `
const TP_OS = ${TRUE_PEAK_OVERSAMPLE};
const TP_HALF = ${KERNEL_HALF};
const TP_BRANCH = ${BRANCH_TAPS};
const TP_DELAY = ${TRUE_PEAK_DETECTOR_DELAY};
function besselI0(x) {
let sum = 1, term = 1;
for (let k = 1; k < 40; k++) {
term *= (x / (2 * k)) * (x / (2 * k));
sum += term;
}
return sum;
}
/** Kaiser-windowed sinc, one array per polyphase branch, newest tap first. */
function tpBranches() {
const beta = 6;
const norm = besselI0(beta);
const branches = [];
for (let p = 0; p < TP_OS; p++) {
const taps = new Float64Array(TP_BRANCH);
let sum = 0;
for (let j = 0; j < TP_BRANCH; j++) {
const k = p + TP_OS * j;
if (k > 2 * TP_HALF) continue;
const t = (k - TP_HALF) / TP_OS;
const sinc = t === 0 ? 1 : Math.sin(Math.PI * t) / (Math.PI * t);
const r = (k - TP_HALF) / TP_HALF;
taps[j] = sinc * (besselI0(beta * Math.sqrt(Math.max(0, 1 - r * r))) / norm);
sum += taps[j];
}
// Unity gain at DC on every branch, so a steady level reads as itself.
for (let j = 0; j < TP_BRANCH; j++) taps[j] /= sum;
branches.push(taps);
}
return branches;
}
class HfTruePeak extends AudioWorkletProcessor {
constructor(o) {
super();
this.p = o.processorOptions || {};
this.look = Math.max(1, Math.round((this.p.lookahead ?? 3) * sampleRate / 1000));
this.delayLen = this.look + TP_DELAY;
this.branches = tpBranches();
this.setLevels();
this.clear();
this.port.onmessage = (e) => {
if (e.data && e.data.__hfDispose) { this.dead = true; return; }
if (e.data && e.data.__hfReset) { this.clear(); return; }
this.p = { ...this.p, ...e.data };
this.setLevels();
};
}
/** Back to the state of a fresh processor: no audio in the delay line, no gain reduction. */
clear() {
this.ch = [];
this.rel = 1;
this.dqIdx = new Int32Array(this.look + 2);
this.dqVal = new Float64Array(this.look + 2);
this.dqHead = 0;
this.dqTail = 0;
this.t = 0;
this.avgRing = new Float64Array(this.look + 1).fill(1);
this.avgPos = 0;
this.avgSum = this.look + 1;
}
setLevels() {
this.ceiling = Math.pow(10, (this.p.ceiling ?? -1) / 20);
this.relCoef = 1 - Math.exp(-1 / (sampleRate * Math.max(1e-4, (this.p.release ?? 80) / 1000)));
}
channel(c) {
if (!this.ch[c]) {
this.ch[c] = {
delay: new Float32Array(this.delayLen),
w: 0,
hist: new Float32Array(TP_BRANCH * 2),
pos: 0,
};
}
return this.ch[c];
}
/** Largest interpolated magnitude across channels for the newest sample. */
peak(chans, inputs, n) {
let peak = 0;
for (let c = 0; c < chans; c++) {
const s = this.channel(c);
const x = inputs[c][n];
s.pos = (s.pos + 1) % TP_BRANCH;
s.hist[s.pos] = x;
s.hist[s.pos + TP_BRANCH] = x;
const base = s.pos + TP_BRANCH;
for (let p = 0; p < TP_OS; p++) {
const taps = this.branches[p];
let acc = 0;
for (let j = 0; j < TP_BRANCH; j++) acc += taps[j] * s.hist[base - j];
const a = acc < 0 ? -acc : acc;
if (a > peak) peak = a;
}
}
return peak;
}
/** Windowed minimum of the required gain over the last look + 1 values. */
windowMin(v) {
const cap = this.dqIdx.length;
while (this.dqTail !== this.dqHead && this.dqVal[(this.dqTail + cap - 1) % cap] >= v) {
this.dqTail = (this.dqTail + cap - 1) % cap;
}
this.dqIdx[this.dqTail] = this.t;
this.dqVal[this.dqTail] = v;
this.dqTail = (this.dqTail + 1) % cap;
while (this.dqIdx[this.dqHead] <= this.t - (this.look + 1)) {
this.dqHead = (this.dqHead + 1) % cap;
}
this.t++;
return this.dqVal[this.dqHead];
}
process(inputs, outputs) {
if (this.dead) return false;
const i = inputs[0], o = outputs[0];
if (!i || !i.length) return true;
const chans = i.length;
const frames = i[0].length;
for (let n = 0; n < frames; n++) {
const peak = this.peak(chans, i, n);
const need = peak > this.ceiling ? this.ceiling / peak : 1;
const m = this.windowMin(need);
this.avgSum += m - this.avgRing[this.avgPos];
this.avgRing[this.avgPos] = m;
this.avgPos = (this.avgPos + 1) % this.avgRing.length;
const target = Math.min(1, this.avgSum / this.avgRing.length);
const released = this.rel + (1 - this.rel) * this.relCoef;
this.rel = released < target ? released : target;
if (target >= 1 && this.rel > 0.9999999) this.rel = 1;
for (let c = 0; c < chans; c++) {
const s = this.channel(c);
o[c][n] = s.delay[s.w] * this.rel;
s.delay[s.w] = i[c][n];
s.w = (s.w + 1) % this.delayLen;
}
}
return true;
}
}
registerProcessor("hf-truepeak", HfTruePeak);
`;
@@ -1,5 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import { audioFxWorkletsReady, ensureAudioFxWorklets } from "./audioFxWorklets.js";
import {
loadProcessors as loadProcessorsAt,
type Processor,
} from "./audioFxProcessors.test-helpers.js";
/** Just enough of a BaseAudioContext for the registration cache to key on. */
const contextWith = (addModule: (url: string) => Promise<void>): BaseAudioContext =>
@@ -51,33 +55,7 @@ describe("ensureAudioFxWorklets", () => {
* `addModule`, so this also proves the URL carries what it claims to.
*/
describe("the worklet processors themselves", () => {
/** Evaluate the registered module and hand back the processor classes by name. */
async function loadProcessors(): Promise<Map<string, new (o: unknown) => Processor>> {
let moduleSource = "";
await ensureAudioFxWorklets(
contextWith(async (url: string) => {
moduleSource = atob(url.replace("data:text/javascript;base64,", ""));
}),
);
const made = new Map<string, new (o: unknown) => Processor>();
class Base {
port = {
onmessage: null as ((e: { data: unknown }) => void) | null,
postMessage: (data: unknown) => this.port.onmessage?.({ data }),
};
}
new Function("AudioWorkletProcessor", "registerProcessor", "sampleRate", moduleSource)(
Base,
(name: string, cls: new (o: unknown) => Processor) => made.set(name, cls),
48000,
);
return made;
}
interface Processor {
port: { postMessage(data: unknown): void };
process(inputs: Float32Array[][], outputs: Float32Array[][]): boolean;
}
const loadProcessors = () => loadProcessorsAt(48000);
const block = (): Float32Array[][] => [[new Float32Array(128)]];
@@ -89,6 +67,7 @@ describe("the worklet processors themselves", () => {
"hf-gate",
"hf-bitcrush",
"hf-pitchshift",
"hf-truepeak",
]);
for (const [name, Cls] of processors) {
+5 -2
View File
@@ -1,3 +1,5 @@
import { TRUE_PEAK_WORKLET_SOURCE } from "./audioFxTruePeak.js";
/**
* AudioWorklet processors for the effects Web Audio has no native node for.
*
@@ -9,7 +11,8 @@
* Kept as a source string so it can be registered from a Blob URL without a
* separate bundled asset, which keeps the studio's build unchanged.
*/
const AUDIO_FX_WORKLET_SOURCE = `
const AUDIO_FX_WORKLET_SOURCE =
`
const dbToLin = (db) => Math.pow(10, db / 20);
/**
@@ -345,7 +348,7 @@ class HfPitchshift extends AudioWorkletProcessor {
}
}
registerProcessor("hf-pitchshift", HfPitchshift);
`;
` + TRUE_PEAK_WORKLET_SOURCE;
// Registration is per context, not per module: a processor registered on one
// AudioContext does not exist on another, so caching a single promise made
+43
View File
@@ -334,6 +334,49 @@ export const HF_AUDIO_FX: readonly HfAudioFxDef[] = [
],
web: "worklet-limiter",
},
{
id: "truepeak",
label: "True-peak limiter",
group: "dynamics",
description:
"Lookahead limiter that holds a 4x estimate of the inter-sample peak at a dBTP ceiling; full-band noise can land up to about 1.7 dB above it. Adds lookahead plus about 0.3 ms of latency.",
params: [
{
kind: "number",
key: "ceiling",
label: "Ceiling",
unit: "dBTP",
min: -24,
max: 0,
step: 0.1,
default: -1,
hint: "Target true peak, measured on a 4x oversampled signal. Dense full-band sound can end up to about 1.7 dB above it.",
},
{
kind: "number",
key: "lookahead",
label: "Lookahead",
unit: "ms",
min: 0.5,
max: 10,
step: 0.1,
default: 3,
hint: "How far ahead the gain starts falling. Longer is smoother and adds the same latency.",
},
{
kind: "number",
key: "release",
label: "Release",
unit: "ms",
min: 10,
max: 2000,
step: 1,
default: 80,
scale: "log",
},
],
web: "worklet-truepeak",
},
{
id: "gate",
label: "Noise Gate",
+23
View File
@@ -174,3 +174,26 @@ describe("no copy assumes the track is a voice", () => {
expect(voiced.some(([, text]) => SPEECH.test(text))).toBe(true);
});
});
/** Everything Studio shows for the true-peak limiter, and the ceiling knob's hint. */
function truePeakCopy(): { shown: string; hint: string } {
const copy = EFFECT_COPY.truepeak;
const ceiling = copy?.params.ceiling;
const summary = SUMMARY.truepeak;
if (!copy || !ceiling?.hint || !summary) throw new Error("truepeak copy is missing");
const shown = [copy.does, copy.reachFor, ceiling.label, ceiling.hint, summary({})];
return { shown: shown.join("\n"), hint: ceiling.hint };
}
describe("the true-peak copy does not promise a hard ceiling", () => {
it("never says never or must-not-clip", () => {
expect(truePeakCopy().shown).not.toMatch(/never exceed|must not clip|never above|guarantee/i);
});
it("tells the author about the 4x estimate and the headroom to leave", () => {
const { hint } = truePeakCopy();
expect(hint).toMatch(/4x/);
expect(hint).toMatch(/1\.7 dB/);
expect(hint).toMatch(/headroom/);
});
});
+17
View File
@@ -166,6 +166,22 @@ export const EFFECT_COPY: Record<string, EffectCopy> = {
level_out: { label: "Level after" },
},
},
truepeak: {
title: "True-Peak Ceiling",
does: "Pulls the loudest moments, including the ones between samples, down toward a target ceiling.",
reachFor: "Peaks need to stay under a delivery limit.",
primary: "ceiling",
primaryEnds: { low: "A lot of headroom", high: "Little headroom" },
params: {
ceiling: {
label: "Target ceiling",
hint: "Measured with a 4x estimate. Dense full-band sound can end up to about 1.7 dB above it, so leave headroom: -3 for a -1 dBTP limit.",
ends: { low: "A lot of headroom", high: "Little headroom" },
},
lookahead: { label: "How far ahead it looks" },
release: { label: "How fast it recovers" },
},
},
gate: {
title: "Silence the Gaps",
does: "Mutes the pauses. Whatever sits underneath stays — this closes the gaps, it does not remove noise.",
@@ -396,6 +412,7 @@ export const SUMMARY: Record<string, (p: P) => string> = {
compressor: (p) =>
`Evening out — ${strength(Math.min(1, (n(p.ratio, 3) - 1) / 7), ["gentle", "moderate", "firm"])}`,
limiter: (p) => `Nothing louder than ${n(p.limit, -1)} dB`,
truepeak: (p) => `Peaks pulled down toward ${n(p.ceiling, -1)} dBTP`,
gate: (p) => `Closing gaps quieter than ${n(p.threshold, -45)} dB`,
saturate: (p) =>
`${strength(Math.min(1, Math.abs(n(p.threshold, -6)) / 30), ["A little", "Some", "Heavy"])} warmth`,
+31
View File
@@ -647,6 +647,37 @@ describe("attachElementFxChain", () => {
}
});
it("clears a limiter's state when a persistent chain is re-anchored for a new pass", async () => {
const messages: unknown[] = [];
class RecordingWorkletStub extends WorkletNodeStub {
override port = { postMessage: (...sent: unknown[]) => void messages.push(...sent) };
}
const original = (globalThis as { AudioWorkletNode?: unknown }).AudioWorkletNode;
(globalThis as { AudioWorkletNode?: unknown }).AudioWorkletNode = RecordingWorkletStub;
try {
const node = document.createElement("audio");
node.setAttribute(
"data-fx-chain",
JSON.stringify({ version: 1, nodes: [{ type: "truepeak", id: "n1", params: {} }] }),
);
document.body.append(node);
const timing = { scheduledAt: 0, elapsed: 0, rate: 1 };
const fx = attachElementFxChain(
new WorkletCtx() as unknown as BaseAudioContext,
node,
new Node() as never,
new Node() as never,
timing,
);
await settle();
expect(messages).not.toContainEqual({ __hfReset: true });
fx?.reanchor(timing);
expect(messages).toContainEqual({ __hfReset: true });
} finally {
(globalThis as { AudioWorkletNode?: unknown }).AudioWorkletNode = original;
}
});
it("keeps the carve alive when a compressor is added to a playing track", async () => {
// The reported case, and the worse of the two paths: adding a worklet effect
// to an already-attached chain rebuilt it, the build threw because nothing
+1
View File
@@ -292,6 +292,7 @@ export function attachElementFxChain(
const at = timingNow();
if (at) cancelParamLane(automated, at.scheduledAt);
frame = { ...next };
handle?.reset();
scheduleFor(readChain(el).chain, frame);
},
setRate: (rate: number) => {
@@ -19,7 +19,7 @@ import {
import { scheduleChainAutomation } from "../src/audio/audioFxAutomation.js";
import { parseAutomation, resolveAutomation } from "../src/audioAutomation.js";
import { parseAudioFxChain, type HfAudioFxChain } from "../src/audioFx.js";
import { chainTailSeconds } from "../src/audio/audioFxTail.js";
import { chainLatencySamples, chainTailSeconds } from "../src/audio/audioFxTail.js";
declare global {
interface Window {
@@ -63,11 +63,11 @@ function toBuffer(
return buffer;
}
/** Every rendered channel, copied out of the result buffer. */
function toPlanes(rendered: AudioBuffer): Float32Array[] {
/** Every rendered channel past `skip` leading samples, copied out of the result buffer. */
function toPlanes(rendered: AudioBuffer, skip: number): Float32Array[] {
const out: Float32Array[] = [];
for (let c = 0; c < rendered.numberOfChannels; c++) {
out.push(new Float32Array(rendered.getChannelData(c)));
out.push(new Float32Array(rendered.getChannelData(c).subarray(skip)));
}
return out;
}
@@ -90,7 +90,10 @@ async function render(
? resolveAutomation(parseAutomation(automationJson), chain)
: null;
const tail = Math.ceil(chainTailSeconds(chain, parsedAutomation ?? undefined) * sampleRate);
const ctx = new OfflineAudioContext(channels, frames + tail, sampleRate);
// A lookahead limiter delays its output; render that much longer and drop the
// lead-in so the clip stays sample-aligned with its source.
const latency = chainLatencySamples(chain, sampleRate);
const ctx = new OfflineAudioContext(channels, frames + tail + latency, sampleRate);
if (chainNeedsWorklets(chain)) await ensureAudioFxWorklets(ctx);
@@ -116,7 +119,7 @@ async function render(
fx.output.connect(ctx.destination);
source.start();
return toPlanes(await ctx.startRendering());
return toPlanes(await ctx.startRendering(), latency);
}
window.__HF_AUDIO_FX = { render };
+1
View File
@@ -30,6 +30,7 @@
"src/tests",
"src/runtime",
"**/*.test.ts",
"**/*.test-helpers.ts",
"src/parsers/test-utils.ts"
]
}
@@ -289,6 +289,43 @@ describe.skipIf(!HAS_BROWSER)("browser render", () => {
expect(freq).toBeLessThan(220 * 2.3);
}, 180_000);
it("holds a true peak under the ceiling and trims its own lookahead", async () => {
// fs/4 at 45 degrees: samples of +-0.707 whose waveform reaches 1.0. Preceded
// by a quiet impulse so the output position proves the latency was trimmed.
const input = join(dir, "isp-in.wav");
const n = Math.floor(SR * 0.4);
const samples = new Float32Array(n);
samples[100] = 0.1;
for (let i = 4800; i < n; i++) samples[i] = Math.sin((Math.PI / 2) * i + Math.PI / 4);
writeWav(input, samples, SR, 1, true);
const outPath = join(dir, "isp-out.wav");
const ceiling = -6;
await applyAudioFxChain(
input,
{
version: 1,
nodes: [
{
type: "truepeak",
id: "n1",
enabled: true,
params: { ceiling, lookahead: 3, release: 80 },
},
],
},
outPath,
{ trackId: "t" },
);
const out = readWav(outPath).samples;
expect(out.length).toBe(n);
expect(out[100]).toBeCloseTo(0.1, 3);
// Sample peak of the steady part is 0.707 * gain; the gain that holds the
// 1.0 true peak at -6 dBTP leaves it at -9 dBFS.
let peak = 0;
for (let i = 9600; i < n; i++) peak = Math.max(peak, Math.abs(out[i] ?? 0));
expect(db(peak)).toBeCloseTo(-9.03, 0);
}, 180_000);
it("sweeps a filter across the clip when a lane automates it", async () => {
// A 2 kHz tone under a lowpass whose cutoff rises from below it to well
// above: the start should be attenuated and the end should not. This is
+1 -1
View File
@@ -26,7 +26,7 @@
"files": 122
},
"hyperframes-audio": {
"hash": "e815fcede8a676b9",
"hash": "5dce20c2722a7af2",
"files": 8
},
"hyperframes-cli": {
+9 -5
View File
@@ -52,7 +52,7 @@ the first two, on an `<hf-audio-group>` bus (see "One bus for many tracks"):
| `data-fx-carve` | the carve's own settings, so it can be re-derived |
The shipped effect families are gain, EQ (highpass, lowpass, peaking, shelves),
compressor, limiter, gate, saturate, delay, reverb, chorus, phaser, and bitcrush.
compressor, limiter, truepeak (lookahead true-peak limiter), gate, saturate, delay, reverb, chorus, phaser, and bitcrush.
Exact JSON for each, and the rules a lane must satisfy: `references/attributes.md`.
Every effect with its parameters, ranges and units: `references/fx-registry.md`.
@@ -195,10 +195,14 @@ want 1–3 kHz, and taking that from the bed costs the bed far less than turning
the whole thing down costs the mix. A high-pass on a voice is the standard fix
for rumble; a low-pass darkens or muffles deliberately.
**Dynamics** (`gain`, `compressor`, `limiter`, `gate`) decide how a track's level
**Dynamics** (`gain`, `compressor`, `limiter`, `truepeak`, `gate`) decide how a track's level
behaves over time. Compression narrows the distance between loud and quiet so the
quiet parts can come up. A limiter is a ceiling — it does not shape anything, it
guarantees nothing gets past. A gate removes what is below a threshold, which is
guarantees nothing gets past. `limiter` follows the level, so for a delivery
ceiling use `truepeak`, which looks ahead and measures the inter-sample peak. It
holds a 4x estimate at the ceiling, not the waveform itself, so full-band noise can
end up to 1.7 dB over: under a hard delivery limit set the ceiling at least 2 dB
below it. A gate removes what is below a threshold, which is
how you silence room tone between phrases. `gain` is a plain level stage, and it
is what an automation lane rides when a track has to move out of the way.
@@ -440,8 +444,8 @@ and the parameter's own units. Targets are `volume` for the track's level, or
`fx.<nodeId>.<param>` for an effect's knob.
**Only some parameters can be automated, and a lane on the others is silently
inert.** A knob is automatable when a Web Audio `AudioParam` backs it. The four
worklet-based effects — `compressor`, `limiter`, `gate`, `bitcrush` — expose
inert.** A knob is automatable when a Web Audio `AudioParam` backs it. The
worklet-based effects — `compressor`, `limiter`, `truepeak`, `gate`, `bitcrush` — expose
none at all, so no lane on any of their parameters will ever move: to make a
compressor's behaviour change over time, automate a `gain` stage before it
instead. `references/fx-registry.md` marks every parameter.
@@ -107,8 +107,8 @@ escaping.
**A lane on a non-automatable parameter is silently inert.** Automation is
delivered as native `AudioParam` scheduling, so a knob that no `AudioParam` backs
cannot move: worklet processor options, a WaveShaper curve and a convolution
impulse are all set wholesale. `fx-registry.md` marks each parameter; the four
worklet effects (`compressor`, `limiter`, `gate`, `bitcrush`) have none at all.
impulse are all set wholesale. `fx-registry.md` marks each parameter; the
worklet effects (`compressor`, `limiter`, `truepeak`, `gate`, `bitcrush`) have none at all.
## `data-fx-carve` — the carve's settings
@@ -29,6 +29,7 @@ Web Audio spec leaves it unused for them, so a control would have moved nothing.
| `gain` | `gain` −60–12 dB (0) **AUTO** |
| `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) |
| `limiter` | `limit` −24–0 dB (−1) · `attack` 0.1–80 ms (5) · `release` 1–8000 ms (50, log) · `level_out` −24–24 dB (0) |
| `truepeak` | `ceiling` −24–0 dBTP (−1) · `lookahead` 0.5–10 ms (3) · `release` 10–2000 ms (80, log) |
| `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) |
Cuts on `gain` go to −60 dB, boosts stop at +12: it is a level stage for making
@@ -37,6 +38,19 @@ room, and a chain that could add 40 dB would clip long before that was useful.
signal back in (parallel compression). `range` is how far down the gate pulls
when closed — a gate that pulls all the way to silence sounds like a switch.
`limiter` follows the signal's level and has no lookahead, so it does not
guarantee a peak ceiling. For a delivery ceiling use `truepeak`: it estimates the
inter-sample (true) peak at 4x, looks `lookahead` ms ahead, and holds that estimate
at `ceiling` dBTP. It delays its output by `lookahead` plus about 0.3 ms; the
render trims the delay, live preview plays it.
The ceiling is a 4x estimate, so the real true peak can end above it. Against an
ideal 16x interpolation, tones, pink noise and a dense mix ended at most 0.6 dB
over; full-band white noise driven 8 dB or more into the limiter ended 1.2 to 1.7
dB over (median 1.4). A 4x meter such as ffmpeg `ebur128=peak=true` reads that
noise about 0.1 dB over and will not show it. Under a hard delivery limit set
`ceiling` at least 2 dB below it (−3 dBTP for a −1 dBTP limit).
## Nonlinear — changes the waveform's shape
| Effect | Parameter |
@@ -70,7 +84,7 @@ curves, which is what keeps it sample-accurate and identical between preview and
render. A parameter can therefore only be automated if an `AudioParam` backs it.
Three kinds do not:
- **worklet processor options** — `compressor`, `limiter`, `gate` and `bitcrush`
- **worklet processor options** — `compressor`, `limiter`, `truepeak`, `gate` and `bitcrush`
are AudioWorklets configured wholesale, so **none of their parameters are
automatable at all**.
- **a WaveShaper curve** — `saturate`'s `type`, `threshold` and `oversample`