PageSourceSearch

https://snich.ai/audio-worklets/snich-leveler-processor.js

js snich.ai collected 2026-10-05 15:08:21 UTC 14,309 bytes, 349 lines download raw bytes

1// SnichVoiceLeveler as an AudioWorklet — the WebAudio-chain twin of
2// utils/voiceLeveler.mjs. Used on the RNNoise / DSP-gate paths (iOS Safari,
3// Firefox, desktop without Insertable Streams) so those tracks land on the
4// same loudness target as the worker (ONNX) path, and per voice in the
5// broadcast mixer (utils/audioMixer.ts) so the program is level no matter
6// which denoiser / audio profile each participant runs.
7//
8// AudioWorkletGlobalScope cannot import modules, so the core block between the
9// SNICH_VOICE_LEVELER_CORE markers is a textual copy of the one in
10// utils/voiceLeveler.mjs. scripts/audio-denoise/validate-voice-leveler.mjs
11// fails the build checks if the two copies drift apart.
12
13/* SNICH_VOICE_LEVELER_CORE_START */
14const VOICE_LEVELER_DEFAULTS = {
15  // Integrated speech loudness every voice is steered to. -16 LUFS is the
16  // streaming/podcast voice norm and matches what Chrome's native AGC gives
17  // participants who run without the ML denoiser, so enabling noise
18  // suppression no longer makes a voice quieter than the others.
19  targetLufs: -16,
20  // Maps the speech-only 10 ms block average onto the R128 integrated
21  // loudness of the processed OUTPUT: R128 400 ms blocks also contain
22  // inter-word gaps, and the compressor/limiter shave ~1 dB off peaks.
23  // Calibrated on speech with scripts/audio-denoise/measure-voice-loudness.mjs.
24  loudnessCalibrationDb: 2.7,
25  adaptive: true,
26  maxBoostDb: 30,
27  maxCutDb: -20,
28  // Speech-level estimator: energy average over accepted speech blocks, with
29  // this time constant (seconds of speech, pauses do not count).
30  levelWindowSec: 3,
31  riseDbPerSec: 10,
32  fallDbPerSec: 24,
33  // First seconds of speech: converge fast so a new speaker is not quiet for
34  // their whole first sentence.
35  acquireSec: 2,
36  acquireRiseDbPerSec: 30,
37  blockMs: 10,
38  // A 10 ms block is speech when its K-weighted level clears both gates.
39  absoluteGateDb: -62,
40  speechAboveFloorDb: 10,
41  noiseFloorInitDb: -75,
42  noiseFloorRiseDbPerSec: 2,
43  noiseFloorFallCoef: 0.15,
44  // Gentle: only the loudest syllables are compressed, the limiter below
45  // catches what is left.
46  compThresholdDb: -6,
47  compRatio: 2.5,
48  compKneeDb: 8,
49  compAttackMs: 3,
50  compReleaseMs: 140,
51  limiterCeilingDb: -1.5,
52  limiterLookaheadMs: 1.5,
53  limiterReleaseMs: 90,
54  hardCeiling: 0.9,
55};
56
57const LEVELER_DB_TO_LIN = (db) => Math.pow(10, db / 20);
58const LEVELER_LIN_TO_DB = (lin) => 20 * Math.log10(lin > 1e-12 ? lin : 1e-12);
59
60// RBJ biquads with the BS.1770 K-weighting parameters (pyloudnorm's
61// derivation), valid at any sample rate.
62const levelerKWeighting = (rate) => {
63  const shelf = (() => {
64    const gainDb = 3.999843853973347;
65    const q = 0.7071752369554196;
66    const w0 = (2 * Math.PI * 1681.974450955533) / rate;
67    const a = Math.pow(10, gainDb / 40);
68    const alpha = Math.sin(w0) / (2 * q);
69    const cos = Math.cos(w0);
70    const sqrtA = Math.sqrt(a);
71    const a0 = (a + 1) - (a - 1) * cos + 2 * sqrtA * alpha;
72    return {
73      b0: (a * ((a + 1) + (a - 1) * cos + 2 * sqrtA * alpha)) / a0,
74      b1: (-2 * a * ((a - 1) + (a + 1) * cos)) / a0,
75      b2: (a * ((a + 1) + (a - 1) * cos - 2 * sqrtA * alpha)) / a0,
76      a1: (2 * ((a - 1) - (a + 1) * cos)) / a0,
77      a2: ((a + 1) - (a - 1) * cos - 2 * sqrtA * alpha) / a0,
78    };
79  })();
80  const highPass = (() => {
81    const q = 0.5003270373238773;
82    const w0 = (2 * Math.PI * 38.13547087602444) / rate;
83    const alpha = Math.sin(w0) / (2 * q);
84    const cos = Math.cos(w0);
85    const a0 = 1 + alpha;
86    return {
87      b0: ((1 + cos) / 2) / a0,
88      b1: (-(1 + cos)) / a0,
89      b2: ((1 + cos) / 2) / a0,
90      a1: (-2 * cos) / a0,
91      a2: (1 - alpha) / a0,
92    };
93  })();
94  return { shelf, highPass };
95};
96
97class VoiceLevelerCore {
98  constructor(sampleRate, options = {}) {
99    const opts = { ...VOICE_LEVELER_DEFAULTS, ...options };
100    this.opts = opts;
101    const base = Number(options.baseGain);
102    this.baseGain = Number.isFinite(base) ? Math.min(2, Math.max(0.25, base)) : 1;
103    this.minGainDb = opts.maxCutDb;
104    this.maxGainDb = opts.maxBoostDb;
105    // Adaptive gain relative to baseGain (dB); total gain = baseGain * 10^(gainDb/20).
106    this.gainDb = 0;
107    this.gainLin = 1;
108    this.gainStep = 0;
109    this.speechEnergy = 0;
110    this.speechBlocks = 0;
111    this.noiseFloorDb = opts.noiseFloorInitDb;
112    this.blockEnergy = 0;
113    this.blockFill = 0;
114    this.lastBlockSpeech = false;
115    this.lastFrameRms = 0;
116    this.compEnv = 0;
117    this.compGain = 1;
118    this.limiterGain = 1;
119    this.k1 = [0, 0, 0, 0];
120    this.k2 = [0, 0, 0, 0];
121    this.sampleRate = 0;
122    this.setSampleRate(sampleRate);
123  }
124
125  // Recomputes rate-dependent coefficients; adaptive state (gain, loudness
126  // estimate, noise floor) survives so a sample-rate switch on the mobile
127  // resample path does not restart leveling. The lookahead line is re-sized.
128  setSampleRate(sampleRate) {
129    const rate = Number.isFinite(sampleRate) && sampleRate > 0 ? sampleRate : 48000;
130    if (rate === this.sampleRate) return;
131    const opts = this.opts;
132    this.sampleRate = rate;
133    this.k = levelerKWeighting(rate);
134    this.k1 = [0, 0, 0, 0];
135    this.k2 = [0, 0, 0, 0];
136    this.blockSize = Math.max(16, Math.round((rate * opts.blockMs) / 1000));
137    this.blockSec = this.blockSize / rate;
138    this.blockEnergy = 0;
139    this.blockFill = 0;
140    this.compAttack = 1 - Math.exp(-1 / (rate * (opts.compAttackMs / 1000)));
141    this.compRelease = 1 - Math.exp(-1 / (rate * (opts.compReleaseMs / 1000)));
142    this.compThresholdLin = LEVELER_DB_TO_LIN(opts.compThresholdDb - opts.compKneeDb / 2);
143    this.limiterCeiling = LEVELER_DB_TO_LIN(opts.limiterCeilingDb);
144    this.lookahead = Math.max(1, Math.round((rate * opts.limiterLookaheadMs) / 1000));
145    // Smoothed limiter gain reaches ~95% of a new reduction within the lookahead.
146    this.limiterAttack = 1 - Math.exp(-3 / this.lookahead);
147    this.limiterRelease = 1 - Math.exp(-1 / (rate * (opts.limiterReleaseMs / 1000)));
148    // Delay line holds the signal and the gain each sample needs; the monotonic
149    // deque yields the minimum required gain over [t - lookahead, t].
150    this.delayLine = new Float32Array(this.lookahead);
151    this.delayRequired = new Float32Array(this.lookahead).fill(1);
152    this.delayPos = 0;
153    this.dequeCap = this.lookahead + 2;
154    this.dequeValue = new Float32Array(this.dequeCap);
155    this.dequeIndex = new Float64Array(this.dequeCap);
156    this.dequeHead = 0;
157    this.dequeTail = 0;
158    this.sampleIndex = 0;
159    this.latency = this.lookahead;
160  }
161
162  get gain() {
163    return this.baseGain * this.gainLin;
164  }
165
166  get noiseFloor() {
167    return LEVELER_DB_TO_LIN(this.noiseFloorDb);
168  }
169
170  // Integrated speech loudness of the INPUT (LUFS-like), or null before any speech.
171  get speechLoudness() {
172    if (this.speechBlocks === 0) return null;
173    return -0.691 + 10 * Math.log10(this.speechEnergy > 1e-12 ? this.speechEnergy : 1e-12) - this.opts.loudnessCalibrationDb;
174  }
175
176  endBlock() {
177    const opts = this.opts;
178    const energy = this.blockEnergy / this.blockFill;
179    this.blockEnergy = 0;
180    this.blockFill = 0;
181    const blockDb = -0.691 + 10 * Math.log10(energy > 1e-12 ? energy : 1e-12);
182
183    const speech = blockDb > opts.absoluteGateDb && blockDb > this.noiseFloorDb + opts.speechAboveFloorDb;
184    this.lastBlockSpeech = speech;
185    if (blockDb < this.noiseFloorDb) {
186      this.noiseFloorDb += opts.noiseFloorFallCoef * (blockDb - this.noiseFloorDb);
187    } else {
188      this.noiseFloorDb = Math.min(blockDb, this.noiseFloorDb + opts.noiseFloorRiseDbPerSec * this.blockSec);
189    }
190    if (this.noiseFloorDb < -100) this.noiseFloorDb = -100;
191    if (this.noiseFloorDb > -35) this.noiseFloorDb = -35;
192
193    let nextGainDb = this.gainDb;
194    if (speech && opts.adaptive) {
195      this.speechBlocks += 1;
196      // Cumulative mean at first (fast, unbiased), exponential afterwards.
197      const alpha = Math.max(this.blockSec / opts.levelWindowSec, 1 / this.speechBlocks);
198      this.speechEnergy += alpha * (energy - this.speechEnergy);
199      const desiredTotalDb = opts.targetLufs - this.speechLoudness;
200      const baseDb = LEVELER_LIN_TO_DB(this.baseGain);
201      let desiredDb = desiredTotalDb - baseDb;
202      if (desiredDb + baseDb > this.maxGainDb) desiredDb = this.maxGainDb - baseDb;
203      if (desiredDb + baseDb < this.minGainDb) desiredDb = this.minGainDb - baseDb;
204      const acquiring = this.speechBlocks * this.blockSec < opts.acquireSec;
205      const rise = (acquiring ? opts.acquireRiseDbPerSec : opts.riseDbPerSec) * this.blockSec;
206      const fall = opts.fallDbPerSec * this.blockSec;
207      if (desiredDb > this.gainDb) nextGainDb = Math.min(desiredDb, this.gainDb + rise);
208      else nextGainDb = Math.max(desiredDb, this.gainDb - fall);
209    }
210    this.gainDb = nextGainDb;
211    // Ramp to the new gain across the next block (no zipper noise).
212    this.gainStep = (LEVELER_DB_TO_LIN(nextGainDb) - this.gainLin) / this.blockSize;
213  }
214
215  // Levels `samples` (mono Float32Array) in place and returns them. Output is
216  // delayed by `latency` samples (limiter lookahead).
217  process(samples) {
218    const k = this.k;
219    const k1 = this.k1;
220    const k2 = this.k2;
221    const hardCeiling = this.opts.hardCeiling;
222    const compThresholdLin = this.compThresholdLin;
223    const lookahead = this.lookahead;
224    const cap = this.dequeCap;
225    let sumSquares = 0;
226    for (let i = 0; i < samples.length; i += 1) {
227      const x = samples[i];
228      sumSquares += x * x;
229
230      // K-weighted sidechain (loudness of the input, pre-gain).
231      const s1 = k.shelf.b0 * x + k.shelf.b1 * k1[0] + k.shelf.b2 * k1[1] - k.shelf.a1 * k1[2] - k.shelf.a2 * k1[3];
232      k1[1] = k1[0]; k1[0] = x; k1[3] = k1[2]; k1[2] = s1;
233      const s2 = k.highPass.b0 * s1 + k.highPass.b1 * k2[0] + k.highPass.b2 * k2[1] - k.highPass.a1 * k2[2] - k.highPass.a2 * k2[3];
234      k2[1] = k2[0]; k2[0] = s1; k2[3] = k2[2]; k2[2] = s2;
235      this.blockEnergy += s2 * s2;
236      this.blockFill += 1;
237
238      // AGC gain (ramped per sample toward the block target).
239      this.gainLin += this.gainStep;
240      let y = x * this.baseGain * this.gainLin;
241
242      // Soft-knee compressor on a peak envelope.
243      const magnitude = y < 0 ? -y : y;
244      this.compEnv += (magnitude > this.compEnv ? this.compAttack : this.compRelease) * (magnitude - this.compEnv);
245      if (this.compEnv > compThresholdLin) {
246        const over = LEVELER_LIN_TO_DB(this.compEnv) - this.opts.compThresholdDb;
247        const knee = this.opts.compKneeDb;
248        const slope = 1 / this.opts.compRatio - 1;
249        const reductionDb = over >= knee / 2
250          ? slope * over
251          : (slope * (over + knee / 2) * (over + knee / 2)) / (2 * knee);
252        this.compGain = LEVELER_DB_TO_LIN(reductionDb);
253      } else {
254        this.compGain = 1;
255      }
256      y *= this.compGain;
257
258      // Lookahead limiter: sliding minimum of the gain each sample needs.
259      const peak = y < 0 ? -y : y;
260      const required = peak > this.limiterCeiling ? this.limiterCeiling / peak : 1;
261      const index = this.sampleIndex;
262      while (this.dequeTail > this.dequeHead && this.dequeValue[(this.dequeTail - 1) % cap] >= required) {
263        this.dequeTail -= 1;
264      }
265      this.dequeValue[this.dequeTail % cap] = required;
266      this.dequeIndex[this.dequeTail % cap] = index;
267      this.dequeTail += 1;
268      while (this.dequeIndex[this.dequeHead % cap] < index - lookahead) this.dequeHead += 1;
269      const windowMin = this.dequeValue[this.dequeHead % cap];
270      this.limiterGain += (windowMin < this.limiterGain ? this.limiterAttack : this.limiterRelease) * (windowMin - this.limiterGain);
271      this.sampleIndex = index + 1;
272
273      const delayed = this.delayLine[this.delayPos];
274      const delayedRequired = this.delayRequired[this.delayPos];
275      this.delayLine[this.delayPos] = y;
276      this.delayRequired[this.delayPos] = required;
277      this.delayPos = this.delayPos + 1 === lookahead ? 0 : this.delayPos + 1;
278      // The smoothed gain almost always already covers the peak; min() makes
279      // the ceiling a guarantee rather than a tendency.
280      const out = delayed * (this.limiterGain < delayedRequired ? this.limiterGain : delayedRequired);
281      samples[i] = out > hardCeiling ? hardCeiling : out < -hardCeiling ? -hardCeiling : out;
282
283      if (this.blockFill >= this.blockSize) this.endBlock();
284    }
285    this.lastFrameRms = samples.length > 0 ? Math.sqrt(sumSquares / samples.length) : 0;
286    return samples;
287  }
288}
289/* SNICH_VOICE_LEVELER_CORE_END */
290
291class SnichLevelerProcessor extends AudioWorkletProcessor {
292  static get parameterDescriptors() {
293    return [
294      {
295        name: 'baseGain',
296        defaultValue: 1,
297        minValue: 0.25,
298        maxValue: 2,
299        automationRate: 'k-rate',
300      },
301    ];
302  }
303
304  // processorOptions.leveler carries per-use overrides of
305  // VOICE_LEVELER_DEFAULTS (e.g. the broadcast mixer's per-voice profile).
306  constructor(options) {
307    super();
308    const overrides = options && options.processorOptions && options.processorOptions.leveler;
309    this.leveler = new VoiceLevelerCore(sampleRate, overrides && typeof overrides === 'object' ? overrides : {});
310    this.frame = 0;
311  }
312
313  process(inputs, outputs, parameters) {
314    const input = inputs[0];
315    const output = outputs[0];
316    if (!input || !output || input.length === 0 || !input[0]) {
317      return true;
318    }
319
320    const baseGain = parameters.baseGain.length > 0 ? parameters.baseGain[0] : 1;
321    if (baseGain !== this.leveler.baseGain) {
322      this.leveler.baseGain = Math.min(2, Math.max(0.25, baseGain));
323    }
324
325    // Level channel 0 in place, then mirror to every output channel (chain is
326    // effectively mono: mic -> denoiser -> leveler -> compressor).
327    const processed = output[0];
328    processed.set(input[0]);
329    this.leveler.process(processed);
330    for (let ch = 1; ch < output.length; ch += 1) {
331      output[ch].set(processed);
332    }
333
334    this.frame += 1;
335    if (this.frame % 120 === 0) {
336      this.port.postMessage({
337        type: 'metrics',
338        gain: this.leveler.gain,
339        rms: this.leveler.lastFrameRms,
340        noiseFloor: this.leveler.noiseFloor,
341        limiterGain: this.leveler.limiterGain,
342      });
343    }
344
345    return true;
346  }
347}
348
349registerProcessor('snich-leveler-processor', SnichLevelerProcessor);

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.