Files
feuerwerk/src/core/soundfx/dynamics.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

158 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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]!;
}
}