feat(lotus-denoise): quality — dry/wet attenuation floor, gate-after-ML, softer DFN
CI / Build embedded bundle (push) Successful in 1m38s
CI / Publish to Gitea npm registry (push) Has been skipped

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 <noreply@anthropic.com>
This commit is contained in:
Lotus CI
2026-07-01 00:16:23 -04:00
co-authored by Claude Opus 4.8
parent 9a4e9bf7da
commit 6ab52d9926
2 changed files with 62 additions and 10 deletions
+8
View File
@@ -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 =>
+54 -10
View File
@@ -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();