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>
158 lines
6.9 KiB
TypeScript
158 lines
6.9 KiB
TypeScript
/**
|
||
* Compressor and Limiter (#326). Web Audio's DynamicsCompressorNode does the compressing; what it
|
||
* leaves to the user agent is the shape of its knee and a gain it adds on its own. Both are taken
|
||
* from Chromium here, so the gain a Compressor adds after the node can be worked out. The Limiter
|
||
* runs on the FX worklet and is a plain class, so it is tested in Node.
|
||
*/
|
||
|
||
const dbToLinear = (db: number) => 10 ** (db / 20);
|
||
const linearToDb = (value: number) => (value <= 0 ? -1000 : 20 * Math.log10(value));
|
||
|
||
/** How late a DynamicsCompressorNode hands its sound on: the spec's fixed look-ahead. */
|
||
export const COMPRESSOR_LATENCY = 0.006;
|
||
|
||
/**
|
||
* The static curve of a DynamicsCompressorNode as Chromium draws it: the identity up to the
|
||
* threshold, an exponential knee whose slope reaches 1/ratio at threshold + knee, and a straight
|
||
* line in dB after it. Gives the level that comes out of a steady one going in, both linear.
|
||
*/
|
||
export function compressorCurve(threshold: number, knee: number, ratio: number): (x: number) => number {
|
||
const linearThreshold = dbToLinear(threshold);
|
||
const slope = 1 / Math.max(1, ratio);
|
||
const kneeCurve = (x: number, k: number) =>
|
||
x < linearThreshold ? x : linearThreshold + (1 - Math.exp(-k * (x - linearThreshold))) / k;
|
||
const slopeAt = (x: number, k: number) => {
|
||
if (x < linearThreshold) return 1;
|
||
const x2 = x * 1.001;
|
||
return (linearToDb(kneeCurve(x2, k)) - linearToDb(kneeCurve(x, k))) / (linearToDb(x2) - linearToDb(x));
|
||
};
|
||
const kneeThresholdDb = threshold + knee;
|
||
const kneeThreshold = dbToLinear(kneeThresholdDb);
|
||
let low = 0.1;
|
||
let high = 10000;
|
||
let k = 5;
|
||
for (let i = 0; i < 15; i++) {
|
||
if (slopeAt(kneeThreshold, k) < slope) high = k;
|
||
else low = k;
|
||
k = Math.sqrt(low * high);
|
||
}
|
||
const kneeEndDb = linearToDb(kneeCurve(kneeThreshold, k));
|
||
return (x) =>
|
||
x < kneeThreshold ? kneeCurve(x, k) : dbToLinear(kneeEndDb + slope * (linearToDb(x) - kneeThresholdDb));
|
||
}
|
||
|
||
/** How much quieter a Compressor makes a sound at full scale, in dB: what full auto-gain gives back. */
|
||
export function compressorAutoGain(threshold: number, knee: number, ratio: number): number {
|
||
return -linearToDb(compressorCurve(threshold, knee, ratio)(1));
|
||
}
|
||
|
||
/**
|
||
* The gain after a Compressor, as a factor: `makeup` dB and the auto-gain blended by `autoGain`
|
||
* (0 none, 1 all of it), less the 0.6 of the auto-gain the node adds by itself.
|
||
*/
|
||
export function compressorMakeup(
|
||
numbers: Record<'threshold' | 'knee' | 'ratio' | 'makeup' | 'autoGain', number>,
|
||
): number {
|
||
const auto = compressorAutoGain(numbers.threshold, numbers.knee, numbers.ratio);
|
||
const w = Math.max(0, Math.min(1, numbers.autoGain));
|
||
return dbToLinear((1 - w) * numbers.makeup + w * auto - 0.6 * auto);
|
||
}
|
||
|
||
/** How far a Limiter looks ahead: a peak is met by a gain that has come down over this long. */
|
||
export const LIMITER_LOOKAHEAD = 0.005;
|
||
|
||
/** Samples a Limiter holds the sound back at this rate. */
|
||
export function limiterDelay(sampleRate: number): number {
|
||
return Math.max(1, Math.round(LIMITER_LOOKAHEAD * sampleRate));
|
||
}
|
||
|
||
/** Seconds a Limiter is late at this rate. */
|
||
export function limiterLatency(sampleRate: number): number {
|
||
return limiterDelay(sampleRate) / sampleRate;
|
||
}
|
||
|
||
/** The numbers of a Limiter as the worklet hands them in: one value, or one per sample. */
|
||
export type LimiterNumbers = Record<'threshold' | 'ceiling' | 'release' | 'autoGain', Float32Array>;
|
||
|
||
const at = (values: Float32Array, i: number) => (values.length > 1 ? values[i]! : values[0]!);
|
||
|
||
/**
|
||
* A look-ahead limiter: the sound is held back `limiterDelay` samples, and the gain for every one
|
||
* is the smallest any sample in the window ahead needs, averaged over that window – so it comes
|
||
* down smoothly and is low enough by the time the peak is heard. Nothing ever goes above the lower
|
||
* of threshold and ceiling. The gain then comes back up in `release` seconds, and auto-gain raises
|
||
* the result towards the ceiling.
|
||
*/
|
||
export class Limiter {
|
||
private readonly delay: number;
|
||
private readonly window: number;
|
||
/** The sound held back, per channel. */
|
||
private readonly held: Float32Array[] = [];
|
||
/** The gain each sample of the window needs, and the smallest from each on, as a monotonic queue. */
|
||
private readonly needed: Float64Array;
|
||
private readonly queue: Float64Array;
|
||
private head = 0;
|
||
private tail = 0;
|
||
/** The smallest gains of the last `window` samples, and their sum. */
|
||
private readonly smallest: Float64Array;
|
||
private sum: number;
|
||
private gain = 1;
|
||
private n = 0;
|
||
|
||
constructor(private readonly sampleRate: number) {
|
||
this.delay = limiterDelay(sampleRate);
|
||
this.window = this.delay + 1;
|
||
this.needed = new Float64Array(this.window);
|
||
this.queue = new Float64Array(this.window + 1);
|
||
this.smallest = new Float64Array(this.window).fill(1);
|
||
this.sum = this.window;
|
||
}
|
||
|
||
render(inputs: readonly Float32Array[], outputs: Float32Array[], numbers: LimiterNumbers): void {
|
||
const length = outputs[0]?.length ?? 0;
|
||
while (this.held.length < outputs.length) this.held.push(new Float32Array(this.delay));
|
||
for (let i = 0; i < length; i++) {
|
||
const thresholdDb = at(numbers.threshold, i);
|
||
const ceilingDb = at(numbers.ceiling, i);
|
||
const limitDb = Math.min(thresholdDb, ceilingDb);
|
||
const limit = dbToLinear(limitDb);
|
||
let peak = 0;
|
||
for (const channel of inputs) peak = Math.max(peak, Math.abs(channel[i] ?? 0));
|
||
const need = peak > limit ? limit / peak : 1;
|
||
const smallest = this.windowMin(need);
|
||
const slot = this.n % this.window;
|
||
this.sum += smallest - this.smallest[slot]!;
|
||
this.smallest[slot] = smallest;
|
||
const average = this.sum / this.window;
|
||
const release = at(numbers.release, i);
|
||
const back = release > 0 ? 1 - Math.exp(-1 / (release * this.sampleRate)) : 1;
|
||
this.gain = average < this.gain ? average : this.gain + (average - this.gain) * back;
|
||
const w = Math.max(0, Math.min(1, at(numbers.autoGain, i)));
|
||
const level = this.gain * dbToLinear(w * (ceilingDb - limitDb));
|
||
const from = this.n % this.delay;
|
||
outputs.forEach((out, c) => {
|
||
const line = this.held[c]!;
|
||
out[i] = line[from]! * level;
|
||
line[from] = inputs[c]?.[i] ?? inputs[0]?.[i] ?? 0;
|
||
});
|
||
this.n += 1;
|
||
}
|
||
}
|
||
|
||
/** Puts the gain this sample needs into the window and gives the smallest one in it. */
|
||
private windowMin(need: number): number {
|
||
const slot = this.n % this.window;
|
||
this.needed[slot] = need;
|
||
const size = this.queue.length;
|
||
while (this.tail !== this.head) {
|
||
const last = (this.tail - 1 + size) % size;
|
||
if (this.needed[this.queue[last]! % this.window]! < need) break;
|
||
this.tail = last;
|
||
}
|
||
this.queue[this.tail] = this.n;
|
||
this.tail = (this.tail + 1) % size;
|
||
while (this.queue[this.head]! <= this.n - this.window) this.head = (this.head + 1) % size;
|
||
return this.needed[this.queue[this.head]! % this.window]!;
|
||
}
|
||
}
|