fix(denoise-tester): play models in both ears; match the call's DeepFilterNet strength
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 21s
CI / Build & Quality Checks (pull_request) Successful in 7m3s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Successful in 27m13s

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
This commit is contained in:
Lotus CI
2026-10-30 18:53:17 -04:00
co-authored by Claude Opus 5.5
parent d1a9625b1c
commit 2f5bcf2b91
2 changed files with 28 additions and 4 deletions
@@ -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
+23 -1
View File
@@ -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<AudioWorkletNode> {
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,