From 6ab52d99267a48b9c62bb395222cefd7ac38b239 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Wed, 1 Jul 2026 00:16:23 -0400 Subject: [PATCH] =?UTF-8?q?feat(lotus-denoise):=20quality=20=E2=80=94=20dr?= =?UTF-8?q?y/wet=20attenuation=20floor,=20gate-after-ML,=20softer=20DFN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track-B audio-quality changes to reduce the "robotic/underwater" artifact. - Dry/wet attenuation floor (default 0.15 ≈ -16 dB) blends a little of the raw mic under the denoised signal so suppression can't fully collapse the noise floor between words (the main cause of the RNNoise "underwater"/pumping sound). Applied ONLY to the low-latency flat models (RNNoise/Speex); DTLN/DFN add algorithmic latency that would comb-filter an undelayed dry mix, so they rely on their own level instead. Tunable via `lotusDenoiseFloor`. - Noise gate now runs AFTER the ML model, not before — gating the raw signal fed hard-zeroed frames into the model and tuned the threshold on pre-denoise levels. - DeepFilterNet 3 noiseReductionLevel 80 -> 60: full strength was the main "over-processed" contributor; 60 keeps voice natural. Defaults are conservative and tunable; final values are meant to be dialed in with real-call A/B listening. Co-Authored-By: Claude Opus 4.8 --- src/lotus/lotusDenoise.ts | 8 ++++ src/lotus/lotusDenoiseProcessor.ts | 64 +++++++++++++++++++++++++----- 2 files changed, 62 insertions(+), 10 deletions(-) diff --git a/src/lotus/lotusDenoise.ts b/src/lotus/lotusDenoise.ts index 752e1271..bb70761e 100644 --- a/src/lotus/lotusDenoise.ts +++ b/src/lotus/lotusDenoise.ts @@ -73,6 +73,7 @@ export function startLotusDenoise(vm: CallViewModel): () => void { : "rnnoise"; const rawThreshold = lotusParam("lotusGateThreshold"); + const rawFloor = lotusParam("lotusDenoiseFloor"); const config: LotusDenoiseConfig = { model, assetBase: safeAssetBase(lotusParam("lotusDenoiseBase")), @@ -83,6 +84,13 @@ export function startLotusDenoise(vm: CallViewModel): () => void { rawThreshold !== null && Number.isFinite(Number(rawThreshold)) ? Number(rawThreshold) : -45, + // Dry/wet attenuation floor. Default 0.15 (~-16 dB) tames the + // over-suppression "underwater"/pumping artifact; host can tune via + // `lotusDenoiseFloor` (0 = full suppression, no floor). + floor: + rawFloor !== null && Number.isFinite(Number(rawFloor)) + ? Math.min(0.5, Math.max(0, Number(rawFloor))) + : 0.15, }; const micOf = (room: LivekitRoom): LocalAudioTrack | undefined => diff --git a/src/lotus/lotusDenoiseProcessor.ts b/src/lotus/lotusDenoiseProcessor.ts index ace2631b..314f3446 100644 --- a/src/lotus/lotusDenoiseProcessor.ts +++ b/src/lotus/lotusDenoiseProcessor.ts @@ -24,6 +24,13 @@ export interface LotusDenoiseConfig { assetBase: string; gate: boolean; gateThreshold: number; + /** + * Attenuation floor as a dry/wet mix: the fraction (0..~0.3) of the ORIGINAL + * mic blended back under the denoised signal so full suppression never fully + * collapses the noise floor — this is what kills the "underwater"/pumping + * artifact. 0.15 ≈ a -16 dB floor. 0 = full suppression (no floor). + */ + floor: number; } // Flat sapphi worklets (RNNoise/Speex): each registers a processor under `name` @@ -241,8 +248,17 @@ export class LotusDenoiseProcessor const dest = ctx.createMediaStreamDestination(); const nodes: AudioNode[] = []; const disposes: (() => void)[] = []; - let head: AudioNode = source; + // Wet (denoised) path: source → ml → [gate] → wetGain. + const ml = await this.buildMlNode(ctx); + source.connect(ml.node); + nodes.push(ml.node); + if (ml.dispose) disposes.push(ml.dispose); + let wetHead: AudioNode = ml.node; + + // Gate AFTER the ML model, not before: gating the raw noisy signal fed hard + // zeroed frames into the model (discontinuities it must fight) and made the + // threshold operate on pre-denoise levels. Gate the residual instead. if (this.config.gate) { const gate = new AudioWorkletNode(ctx, GATE.name, { processorOptions: { @@ -252,18 +268,43 @@ export class LotusDenoiseProcessor maxChannels: 1, }, }); - head.connect(gate); - head = gate; + wetHead.connect(gate); + wetHead = gate; nodes.push(gate); } - const ml = await this.buildMlNode(ctx); - head.connect(ml.node); - ml.node.connect(dest); - nodes.push(ml.node); - if (ml.dispose) disposes.push(ml.dispose); + // Only mix a dry floor for the LOW-LATENCY flat models (RNNoise/Speex). + // DTLN/DeepFilterNet add tens of ms of algorithmic latency, so summing an + // undelayed dry copy would comb-filter the voice — for those we rely on the + // model's own level (e.g. DFN noiseReductionLevel) instead. RNNoise is also + // where the "robotic/underwater" reports come from, so this targets it. + const lowLatency = + this.config.model === "rnnoise" || this.config.model === "speex"; + const floor = lowLatency ? Math.min(0.5, Math.max(0, this.config.floor)) : 0; + if (floor > 0) { + // Dry/wet mix: blend a small amount of the ORIGINAL mic under the + // denoised signal so suppression can't fully collapse the noise floor + // (kills the "underwater"/pumping artifact). During speech (denoised ≈ + // original) the two sum back to ~unity; in noise-only gaps the output + // floors at `floor` × original instead of digital silence. + const wetGain = ctx.createGain(); + wetGain.gain.value = 1 - floor; + wetHead.connect(wetGain); + wetGain.connect(dest); + nodes.push(wetGain); - logger.info(`[lotus] denoise processor active (${this.config.model})`); + const dryGain = ctx.createGain(); + dryGain.gain.value = floor; + source.connect(dryGain); + dryGain.connect(dest); + nodes.push(dryGain); + } else { + wetHead.connect(dest); + } + + logger.info( + `[lotus] denoise processor active (${this.config.model}, floor=${floor})`, + ); return { source, nodes, disposes, track: dest.stream.getAudioTracks()[0] }; } @@ -285,7 +326,10 @@ export class LotusDenoiseProcessor const mod = await import(/* @vite-ignore */ `${base}deepfilternet/index.esm.js`); const core = new mod.DeepFilterNet3Core({ sampleRate: 48_000, - noiseReductionLevel: 80, + // 60, not 80: full-strength suppression is the main source of the + // "over-processed" character; a lower level keeps voice natural while + // the dry/wet floor handles the noise tail. + noiseReductionLevel: 60, assetConfig: { cdnUrl: dfnBase }, }); await core.initialize();