From 34d9272790dfb4ac072862a2d6f63cfdfd085cd0 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Thu, 2 Jul 2026 00:19:16 -0400 Subject: [PATCH] feat(call): denoise asset smoke check at ML-tier call start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HEAD-checks the copied denoise worklet/wasm/model assets for the selected model and console.warns a single line listing anything missing — a silent asset skew between the EC fork's expectations and vite's copied files would otherwise disable noise suppression with no signal. Fire-and-forget; never blocks call setup. Co-Authored-By: Claude Opus 4.8 --- src/app/plugins/call/CallEmbed.ts | 7 +++ src/app/plugins/call/denoiseSmokeCheck.ts | 63 +++++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 src/app/plugins/call/denoiseSmokeCheck.ts diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts index a59b17881..8b2c6f588 100644 --- a/src/app/plugins/call/CallEmbed.ts +++ b/src/app/plugins/call/CallEmbed.ts @@ -27,6 +27,7 @@ import { } from './types'; import { CallControl } from './CallControl'; import { CallControlState } from './CallControlState'; +import { verifyDenoiseAssets } from './denoiseSmokeCheck'; // Maximum time to wait for the embedded Element Call iframe to progress from // initial load to a ready/joined state. If it hasn't by then, we assume the @@ -205,6 +206,12 @@ export class CallEmbed { params.append('lotusModel', denoiseModel); params.append('lotusGate', denoiseGate.toString()); params.append('lotusGateThreshold', denoiseGateThreshold.toString()); + + // [lotus] Fire-and-forget: confirm the fork's ML-denoise assets are + // actually served under public/element-call/denoise/ (they're copied by + // vite.config.js at build time). Warns once if the copy step regressed; + // never blocks call start. + verifyDenoiseAssets(denoiseModel).catch(() => undefined); } if (CallEmbed.startingCall(intent)) { diff --git a/src/app/plugins/call/denoiseSmokeCheck.ts b/src/app/plugins/call/denoiseSmokeCheck.ts new file mode 100644 index 000000000..821ad989e --- /dev/null +++ b/src/app/plugins/call/denoiseSmokeCheck.ts @@ -0,0 +1,63 @@ +import { trimTrailingSlash } from '../../utils/common'; + +// Denoise assets copied into public/element-call/denoise/ by vite.config.js's +// lotusDenoise() plugin. The filenames here MUST match what that plugin writes +// (and what the fork's TrackProcessor fetches at runtime). Grouped per model so +// the smoke-check only probes what the active call will actually load. +const DENOISE_ASSETS: Record = { + rnnoise: ['rnnoiseWorklet.js', 'rnnoise.wasm', 'rnnoise_simd.wasm'], + speex: ['speexWorklet.js', 'speex.wasm'], + dtln: ['workadventure/audio-worklet.js'], + deepfilternet: [ + 'deepfilternet/index.esm.js', + 'deepfilternet/v2/pkg/df_bg.wasm', + 'deepfilternet/v2/models/DeepFilterNet3_onnx.tar.gz', + ], +}; + +// The noise-gate worklet is a shared asset the build ships for every model +// (loaded when the gate is enabled), so probe it regardless of the model. +const SHARED_ASSETS: readonly string[] = ['noiseGateWorklet.js']; + +/** + * Fire-and-forget smoke-check for the ML-denoise asset contract. + * + * The fork's in-source denoiser (lotusDenoiseSource) loads its worklet/wasm/ESM + * from `public/element-call/denoise/` at runtime; if the build's asset copy + * step regressed, those fetches 404 and denoise silently degrades to a raw mic. + * This HEAD-fetches the critical assets for the selected model and emits a + * single console.warn listing any that are missing. No UI, no throw — purely a + * developer/operator breadcrumb. + * + * @param model the selected denoise model (defaults to rnnoise) + * @returns true if every probed asset responded OK, false otherwise + */ +export async function verifyDenoiseAssets(model = 'rnnoise'): Promise { + const base = new URL( + `${trimTrailingSlash(import.meta.env.BASE_URL)}/public/element-call/denoise/`, + window.location.origin, + ); + const names = [...(DENOISE_ASSETS[model] ?? DENOISE_ASSETS.rnnoise), ...SHARED_ASSETS]; + + const results = await Promise.all( + names.map(async (name): Promise => { + try { + const res = await fetch(new URL(name, base).href, { method: 'HEAD' }); + return res.ok ? null : name; + } catch { + return name; + } + }), + ); + + const missing = results.filter((n): n is string => n !== null); + if (missing.length > 0) { + console.warn( + `[lotus-denoise] ML denoise assets missing under ${base.href} (model="${model}"): ${missing.join( + ', ', + )} — the in-source denoiser will fall back to a raw mic. Check vite.config.js lotusDenoise().`, + ); + return false; + } + return true; +}