A sound FX could not control dynamics. A Compressor squeezes the loudness together, and a Limiter keeps every peak below a line. Both work on a voice and on a bus, where they tame everything at once. - Compressor: DynamicsCompressorNode with Threshold, Ratio, Knee, Attack and Release, then Makeup and Auto-gain from 0 to 1. After the node the sound gets (1 - w) * makeup + w * A dB. A is what the curve takes off at 0 dBFS. - The node adds 0.6 of A by itself, as the spec says. That gain is taken back out, so 0 really means no makeup. - The knee shape is left to the browser by the spec. dynamics.ts ports Chromium's curve. It gives the gain Chrome was measured to add at four settings to three places. - Limiter: a class on the FX worklet with Threshold, Ceiling, Release and Auto-gain. It looks 5 ms ahead: the smallest gain of the window ahead, averaged over the window, then a release. No sample passes the lower of threshold and ceiling. Auto-gain raises it toward the ceiling. - Deviation from the plan in the issue: the Limiter has a Threshold as well. Without it Auto-gain has nothing to measure against. - Latency is made up on voices now too. A voice starts as much earlier as its stages are late (Compressor 6 ms, Limiter 5 ms). The other way of a Mix and the other layers of a note are held back to the latest, and a bus counts it into its latency. - A voice with a Limiter counts as ringing, so the worklet is stopped only after what it still holds is out. Tests: - Unit dynamics (new) 8, soundfx 49 (3 new), sound-library 100 (4 new), green. - Counter-checks, each turns tests red: no look-ahead in the Limiter (3), no taking back of the node's gain (1), a voice not started earlier (1), no stage latency on a bus (1), no hold-back in a Mix (1). - e2e 34 (melektaus-atx): ALL PASSED, 78 checks (1 new). Chrome offline: Compressor 288 samples late, Limiter 240. Full scale through -12 dB 4:1 hard knee: +0.19 dB with full auto-gain, -8.81 dB without. A sine at 2 through the Limiter at -6 dB peaks at -6.00 dB, with auto-gain 1 at -1.00 dB. - The gate runs once on the last commit of this series. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
123 lines
5.3 KiB
TypeScript
123 lines
5.3 KiB
TypeScript
/**
|
|
* Compressor and Limiter (#326): the curve of a DynamicsCompressorNode as Chromium draws it, the
|
|
* gain it adds by itself, and the look-ahead Limiter of the FX worklet.
|
|
*/
|
|
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
compressorAutoGain,
|
|
compressorCurve,
|
|
compressorMakeup,
|
|
Limiter,
|
|
limiterDelay,
|
|
type LimiterNumbers,
|
|
} from '../../src/core/soundfx/dynamics';
|
|
|
|
/** What the node adds by itself: the 0.6th power of what full auto-gain would be. */
|
|
const ownMakeup = (threshold: number, knee: number, ratio: number) =>
|
|
10 ** ((0.6 * compressorAutoGain(threshold, knee, ratio)) / 20);
|
|
|
|
describe('the curve of a Compressor', () => {
|
|
it('adds the gain Chrome was measured to add below the threshold (2026-10-07)', () => {
|
|
// A 1 kHz sine far below the threshold, rendered offline: what comes out over what went in.
|
|
expect(ownMakeup(-24, 30, 12)).toBeCloseTo(1.52412, 3);
|
|
expect(ownMakeup(-12, 0, 4)).toBeCloseTo(1.86206, 3);
|
|
expect(ownMakeup(-20, 10, 2)).toBeCloseTo(1.67503, 3);
|
|
expect(ownMakeup(0, 0, 4)).toBeCloseTo(1, 4);
|
|
});
|
|
|
|
it('is the identity below the threshold and falls by 1/ratio in dB above the knee', () => {
|
|
const curve = compressorCurve(-12, 6, 4);
|
|
expect(curve(0.1)).toBeCloseTo(0.1, 9);
|
|
const db = (x: number) => 20 * Math.log10(curve(x));
|
|
// From -3 dB to 0 dB in, 3 dB more in is 0.75 dB more out.
|
|
expect(db(1) - db(10 ** (-3 / 20))).toBeCloseTo(0.75, 3);
|
|
// A hard knee is the straight line from the threshold on: 0 dB in is -9 dB out.
|
|
expect(compressorAutoGain(-12, 0, 4)).toBeCloseTo(9, 3);
|
|
});
|
|
|
|
it('blends makeup and auto-gain, less what the node adds by itself', () => {
|
|
const numbers = { threshold: -12, knee: 0, ratio: 4 };
|
|
const db = (makeup: number, autoGain: number) =>
|
|
20 * Math.log10(compressorMakeup({ ...numbers, makeup, autoGain }));
|
|
expect(db(0, 0)).toBeCloseTo(-5.4, 3);
|
|
expect(db(0, 1)).toBeCloseTo(3.6, 3);
|
|
expect(db(6, 0.5)).toBeCloseTo(3 + 4.5 - 5.4, 3);
|
|
expect(db(0, 7)).toBeCloseTo(3.6, 3);
|
|
});
|
|
});
|
|
|
|
describe('a Limiter', () => {
|
|
const RATE = 48000;
|
|
const numbers = (threshold: number, ceiling: number, autoGain = 0, release = 0.05): LimiterNumbers => ({
|
|
threshold: new Float32Array([threshold]),
|
|
ceiling: new Float32Array([ceiling]),
|
|
release: new Float32Array([release]),
|
|
autoGain: new Float32Array([autoGain]),
|
|
});
|
|
/** Runs `sound` through in blocks of 128, as the worklet does; gives back what came out. */
|
|
const run = (limiter: Limiter, sound: Float32Array[], set: LimiterNumbers) => {
|
|
const out = sound.map((channel) => new Float32Array(channel.length));
|
|
for (let from = 0; from < sound[0]!.length; from += 128) {
|
|
const to = Math.min(from + 128, sound[0]!.length);
|
|
limiter.render(
|
|
sound.map((c) => c.subarray(from, to)),
|
|
out.map((c) => c.subarray(from, to)),
|
|
set,
|
|
);
|
|
}
|
|
return out;
|
|
};
|
|
const sine = (amplitude: number, seconds: number, hz = 1000) =>
|
|
Float32Array.from(
|
|
{ length: RATE * seconds },
|
|
(_, i) => Math.sin((2 * Math.PI * hz * i) / RATE) * amplitude,
|
|
);
|
|
const peak = (data: Float32Array, from = 0) => Math.max(...Array.from(data.subarray(from), Math.abs));
|
|
|
|
it('hands a quiet sound on as it is, limiterDelay samples late', () => {
|
|
const delay = limiterDelay(RATE);
|
|
expect(delay).toBe(240);
|
|
const impulse = new Float32Array(1000);
|
|
impulse[10] = 0.25;
|
|
const [out] = run(new Limiter(RATE), [impulse], numbers(-6, -1));
|
|
expect(out![10 + delay]).toBeCloseTo(0.25, 6);
|
|
expect(peak(out!)).toBeCloseTo(0.25, 6);
|
|
});
|
|
|
|
it('never lets a sample past the lower of threshold and ceiling, also a sudden peak', () => {
|
|
const loud = sine(2, 0.2);
|
|
loud[5000] = 8;
|
|
const [out] = run(new Limiter(RATE), [loud], numbers(-6, -1, 0, 0.01));
|
|
expect(peak(out!)).toBeLessThanOrEqual(10 ** (-6 / 20) + 1e-6);
|
|
// A steady loud sine sits right at the limit once the gain has come down.
|
|
expect(peak(out!, RATE * 0.15)).toBeGreaterThan(10 ** (-6 / 20) * 0.98);
|
|
const [low] = run(new Limiter(RATE), [sine(2, 0.2)], numbers(-1, -6));
|
|
expect(peak(low!)).toBeLessThanOrEqual(10 ** (-6 / 20) + 1e-6);
|
|
});
|
|
|
|
it('raises the limited sound towards the ceiling by its auto-gain, never past it', () => {
|
|
const limited = (autoGain: number) =>
|
|
peak(run(new Limiter(RATE), [sine(2, 0.2)], numbers(-12, -1, autoGain))[0]!, RATE * 0.1);
|
|
expect(limited(1)).toBeLessThanOrEqual(10 ** (-1 / 20) + 1e-6);
|
|
expect(limited(1)).toBeGreaterThan(10 ** (-1 / 20) * 0.98);
|
|
expect(20 * Math.log10(limited(0.5))).toBeCloseTo(-6.5, 1);
|
|
});
|
|
|
|
it('turns both sides down together, by the louder one', () => {
|
|
const [left, right] = run(new Limiter(RATE), [sine(2, 0.1), sine(0.5, 0.1)], numbers(-6, 0));
|
|
expect(peak(left!, RATE * 0.05)).toBeCloseTo(10 ** (-6 / 20), 2);
|
|
expect(peak(right!, RATE * 0.05)).toBeCloseTo(10 ** (-6 / 20) / 4, 2);
|
|
});
|
|
|
|
it('comes back up in its release once the peak is over', () => {
|
|
const sound = sine(2, 0.4);
|
|
sound.fill(0, RATE * 0.1);
|
|
sound.set(sine(0.1, 0.3), RATE * 0.1);
|
|
const fast = run(new Limiter(RATE), [sound], numbers(-6, 0, 0, 0.01))[0]!;
|
|
const slow = run(new Limiter(RATE), [sound], numbers(-6, 0, 0, 0.5))[0]!;
|
|
const after = RATE * 0.2;
|
|
expect(peak(fast, after)).toBeCloseTo(0.1, 3);
|
|
expect(peak(slow, after)).toBeLessThan(0.09);
|
|
});
|
|
});
|