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