Files
feuerwerk/tests/core/dynamics.test.ts
T
adminandClaude Opus 5.5 a645026568 Tame the loudness with a Compressor and a Limiter node (#326)
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>
2026-10-07 20:57:22 +02:00

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);
});
});