feat(lotus-denoise): AGC off for ML tier + init/build leak hardening
AEC/AGC audit fix + two hardening items from the engine review. - Add an `autoGainControl` capture param (UrlParams -> CallViewModel -> ConnectionFactory audioCaptureDefaults), mirroring echoCancellation/ noiseSuppression. Defaults true (unchanged); the host sets it false only for the ML tier so the browser's auto gain control doesn't fight the in-source ML denoiser (pumping). Echo cancellation stays on. Tests cover the URL parse and the audioCaptureDefaults wiring. - L1: init() now closes the owned AudioContext on a build failure (was orphaned; browsers cap live contexts, so repeated failures could exhaust them). - L2: buildGraph() disposes its partially-built nodes on failure (disposeGraph previously only cleaned the prior graph). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
6ab52d9926
commit
940d71da92
@@ -158,9 +158,17 @@ export class LotusDenoiseProcessor
|
||||
public constructor(private readonly config: LotusDenoiseConfig) {}
|
||||
|
||||
public async init(_opts: AudioProcessorOptions): Promise<void> {
|
||||
await this.ensureContext();
|
||||
this.graph = await this.buildGraph(_opts.track);
|
||||
this.processedTrack = this.graph.track;
|
||||
try {
|
||||
await this.ensureContext();
|
||||
this.graph = await this.buildGraph(_opts.track);
|
||||
this.processedTrack = this.graph.track;
|
||||
} catch (e) {
|
||||
// Don't orphan the owned context if graph construction fails (browsers
|
||||
// cap live AudioContexts, so repeated failed inits could exhaust them).
|
||||
// The caller degrades to the raw mic; we just release our resources.
|
||||
await this.closeContext();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async restart(opts: AudioProcessorOptions): Promise<void> {
|
||||
@@ -249,63 +257,78 @@ export class LotusDenoiseProcessor
|
||||
const nodes: AudioNode[] = [];
|
||||
const disposes: (() => void)[] = [];
|
||||
|
||||
// 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;
|
||||
try {
|
||||
// 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: {
|
||||
openThreshold: this.config.gateThreshold,
|
||||
closeThreshold: this.config.gateThreshold - 5,
|
||||
holdMs: 150,
|
||||
maxChannels: 1,
|
||||
},
|
||||
// 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.
|
||||
if (this.config.gate) {
|
||||
const gate = new AudioWorkletNode(ctx, GATE.name, {
|
||||
processorOptions: {
|
||||
openThreshold: this.config.gateThreshold,
|
||||
closeThreshold: this.config.gateThreshold - 5,
|
||||
holdMs: 150,
|
||||
maxChannels: 1,
|
||||
},
|
||||
});
|
||||
wetHead.connect(gate);
|
||||
wetHead = gate;
|
||||
nodes.push(gate);
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
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] };
|
||||
} catch (e) {
|
||||
// A node constructor / model load can throw mid-build; clean up the
|
||||
// partially-built graph so it doesn't leak (init/restart still fall back
|
||||
// to the raw mic on the rejection).
|
||||
this.disposeGraph({
|
||||
source,
|
||||
nodes,
|
||||
disposes,
|
||||
track: dest.stream.getAudioTracks()[0],
|
||||
});
|
||||
wetHead.connect(gate);
|
||||
wetHead = gate;
|
||||
nodes.push(gate);
|
||||
throw e;
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
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] };
|
||||
}
|
||||
|
||||
private async buildMlNode(ctx: AudioContext): Promise<MlNode> {
|
||||
|
||||
Reference in New Issue
Block a user