feat(lotus-denoise): quality — dry/wet attenuation floor, gate-after-ML, softer DFN
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:
co-authored by
Claude Opus 4.8
parent
9a4e9bf7da
commit
6ab52d9926
@@ -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 =>
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user