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.