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";
|
: "rnnoise";
|
||||||
|
|
||||||
const rawThreshold = lotusParam("lotusGateThreshold");
|
const rawThreshold = lotusParam("lotusGateThreshold");
|
||||||
|
const rawFloor = lotusParam("lotusDenoiseFloor");
|
||||||
const config: LotusDenoiseConfig = {
|
const config: LotusDenoiseConfig = {
|
||||||
model,
|
model,
|
||||||
assetBase: safeAssetBase(lotusParam("lotusDenoiseBase")),
|
assetBase: safeAssetBase(lotusParam("lotusDenoiseBase")),
|
||||||
@@ -83,6 +84,13 @@ export function startLotusDenoise(vm: CallViewModel): () => void {
|
|||||||
rawThreshold !== null && Number.isFinite(Number(rawThreshold))
|
rawThreshold !== null && Number.isFinite(Number(rawThreshold))
|
||||||
? Number(rawThreshold)
|
? Number(rawThreshold)
|
||||||
: -45,
|
: -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 =>
|
const micOf = (room: LivekitRoom): LocalAudioTrack | undefined =>
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ export interface LotusDenoiseConfig {
|
|||||||
assetBase: string;
|
assetBase: string;
|
||||||
gate: boolean;
|
gate: boolean;
|
||||||
gateThreshold: number;
|
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`
|
// Flat sapphi worklets (RNNoise/Speex): each registers a processor under `name`
|
||||||
@@ -241,8 +248,17 @@ export class LotusDenoiseProcessor
|
|||||||
const dest = ctx.createMediaStreamDestination();
|
const dest = ctx.createMediaStreamDestination();
|
||||||
const nodes: AudioNode[] = [];
|
const nodes: AudioNode[] = [];
|
||||||
const disposes: (() => void)[] = [];
|
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) {
|
if (this.config.gate) {
|
||||||
const gate = new AudioWorkletNode(ctx, GATE.name, {
|
const gate = new AudioWorkletNode(ctx, GATE.name, {
|
||||||
processorOptions: {
|
processorOptions: {
|
||||||
@@ -252,18 +268,43 @@ export class LotusDenoiseProcessor
|
|||||||
maxChannels: 1,
|
maxChannels: 1,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
head.connect(gate);
|
wetHead.connect(gate);
|
||||||
head = gate;
|
wetHead = gate;
|
||||||
nodes.push(gate);
|
nodes.push(gate);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ml = await this.buildMlNode(ctx);
|
// Only mix a dry floor for the LOW-LATENCY flat models (RNNoise/Speex).
|
||||||
head.connect(ml.node);
|
// DTLN/DeepFilterNet add tens of ms of algorithmic latency, so summing an
|
||||||
ml.node.connect(dest);
|
// undelayed dry copy would comb-filter the voice — for those we rely on the
|
||||||
nodes.push(ml.node);
|
// model's own level (e.g. DFN noiseReductionLevel) instead. RNNoise is also
|
||||||
if (ml.dispose) disposes.push(ml.dispose);
|
// 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] };
|
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 mod = await import(/* @vite-ignore */ `${base}deepfilternet/index.esm.js`);
|
||||||
const core = new mod.DeepFilterNet3Core({
|
const core = new mod.DeepFilterNet3Core({
|
||||||
sampleRate: 48_000,
|
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 },
|
assetConfig: { cdnUrl: dfnBase },
|
||||||
});
|
});
|
||||||
await core.initialize();
|
await core.initialize();
|
||||||
|
|||||||
Reference in New Issue
Block a user