From 2f5bcf2b910ad9aec647c3fe1cbf0bd6aebf9a5c Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Fri, 30 Oct 2026 18:53:17 -0400 Subject: [PATCH] fix(denoise-tester): play models in both ears; match the call's DeepFilterNet strength - Recorded clips often decode as stereo (Chrome Opus/WebM). The models only process channel 0, and the default channel mode ignores channelCount, so RNNoise/Speex played the cleaned audio in the LEFT EAR ONLY. Downmix to mono before the models (as Lotus Call does, #24) and pin the RNNoise/Speex/gate nodes to explicit mono. Verified in Chromium with a stereo speech clip through RNNoise: before L 16.1 / R 0.0, after L 16.0 / R 15.9. - DeepFilterNet ran at attenuation limit 80 here vs 60 in calls; both muted the background outright, which sounds like the mic cutting out at word ends. Use 25, matching element-call#65. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- .../settings/general/DenoiseTester.tsx | 8 ++++--- src/app/utils/denoisePipeline.ts | 24 ++++++++++++++++++- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/app/features/settings/general/DenoiseTester.tsx b/src/app/features/settings/general/DenoiseTester.tsx index 5f684f1ab..09ac999b5 100644 --- a/src/app/features/settings/general/DenoiseTester.tsx +++ b/src/app/features/settings/general/DenoiseTester.tsx @@ -6,6 +6,7 @@ import { DenoiseNode, buildGateNode, buildModelNode, + monoDownmix, readDb, sampleRateFor, } from '../../../utils/denoisePipeline'; @@ -139,7 +140,7 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi inAnalyser.fftSize = 1024; source.connect(inAnalyser); - let head: AudioNode = source; + let head: AudioNode = source.connect(monoDownmix(ctx)); let gate: AudioWorkletNode | null = null; if (useGate) { gate = await buildGateNode(ctx, gateThreshold); @@ -308,10 +309,11 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi const ctx = new AudioContext({ sampleRate: sampleRateFor(playModel ?? 'rnnoise') }); const source = ctx.createBufferSource(); source.buffer = clip; + const mono = source.connect(monoDownmix(ctx)); let playGate: AudioWorkletNode | null = null; let playModelNode: DenoiseNode | null = null; if (playModel) { - let head: AudioNode = source; + let head: AudioNode = mono; if (useGate) { playGate = await buildGateNode(ctx, gateThreshold); head.connect(playGate); @@ -321,7 +323,7 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi head.connect(playModelNode.node); playModelNode.node.connect(ctx.destination); } else { - source.connect(ctx.destination); + mono.connect(ctx.destination); } // Superseded while the WASM/worklet loaded (another Play, a Stop, or the // panel unmounted)? Tear down this now-orphaned graph instead of storing diff --git a/src/app/utils/denoisePipeline.ts b/src/app/utils/denoisePipeline.ts index ea9129d2f..925180a1b 100644 --- a/src/app/utils/denoisePipeline.ts +++ b/src/app/utils/denoisePipeline.ts @@ -21,6 +21,20 @@ const BASE = `${import.meta.env.BASE_URL.replace(/\/+$/, '')}/public/element-cal */ export const sampleRateFor = (model: DenoiseModelId): number => (model === 'dtln' ? 16000 : 48000); +/** + * A pass-through node that downmixes whatever feeds it to ONE channel. Every + * model only processes channel 0, and a recorded clip often decodes as stereo + * (Chrome's Opus/WebM), so without this RNNoise/Speex played the cleaned audio + * in the left ear only. Lotus Call does the same (#24). A mono output is then + * upmixed to both ears by the destination. + */ +export const monoDownmix = (ctx: BaseAudioContext): GainNode => + new GainNode(ctx, { + channelCount: 1, + channelCountMode: 'explicit', + channelInterpretation: 'speakers', + }); + export type DenoiseNode = { node: AudioWorkletNode; dispose: () => void; @@ -85,7 +99,10 @@ export async function buildModelNode( const mod: any = await import(/* @vite-ignore */ `${BASE}deepfilternet/index.esm.js`); const core = new mod.DeepFilterNet3Core({ sampleRate: sampleRateFor(model), - noiseReductionLevel: 80, + // Same attenuation limit as Lotus Call (dB). Higher values mute the + // background outright, so words drop into dead silence and sound + // clipped at the end. + noiseReductionLevel: 25, assetConfig: { cdnUrl: dfnBase }, }); await core.initialize(); @@ -105,6 +122,8 @@ export async function buildModelNode( const [, wasmBinary] = await Promise.all([addModuleOnce(ctx, cfg.script), fetchWasm(cfg.wasm)]); const node = new AudioWorkletNode(ctx, cfg.proc, { channelCount: 1, + channelCountMode: 'explicit', + channelInterpretation: 'speakers', numberOfInputs: 1, numberOfOutputs: 1, processorOptions: { maxChannels: 1, wasmBinary }, @@ -127,6 +146,9 @@ export async function buildGateNode( ): Promise { await addModuleOnce(ctx, 'noiseGateWorklet.js'); return new AudioWorkletNode(ctx, '@sapphi-red/web-noise-suppressor/noise-gate', { + channelCount: 1, + channelCountMode: 'explicit', + channelInterpretation: 'speakers', processorOptions: { openThreshold: thresholdDb, closeThreshold: thresholdDb - 5,