Files
cinny/src/app/utils/denoisePipeline.ts
T
jared 04b56ffacd feat(denoise): add self-hosted DeepFilterNet 3 ML noise-suppression model
Integrate DeepFilterNet 3 (deepfilternet3-noise-filter@1.2.1) as a new
client-side denoise model id 'deepfilternet', mirroring the DTLN pattern.

The npm package ships only an ESM whose AudioWorklet processor + wasm-bindgen
glue are inlined as a string (loaded via a Blob URL — no CDN for the worklet).
Its only runtime fetches are a single-threaded df_bg.wasm and an ONNX model
tarball, which previously loaded from an external CDN. We now VENDOR both
(build/denoise-vendor/deepfilternet/v2/...) and self-host them under
denoise/deepfilternet/, overriding the package's cdnUrl so nothing hits the
upstream CDN — keeping it self-hosted / Tauri-CSP safe.

The wasm is single-threaded (no SharedArrayBuffer / atomics / imported shared
memory), so it needs no COOP/COEP cross-origin isolation and runs fine in EC's
non-isolated iframe. Runs at 48 kHz fullband. Any init/runtime failure falls
back to the raw mic, like the other models.

- vite.config.js: copy ESM + vendored wasm/model into the EC denoise dir with a
  required-asset guard that aborts the build if any entry is missing.
- build/lotus-denoise.js: 'deepfilternet' branch — dynamic-import the ESM, build
  a DeepFilterNet3Core pointed at the self-hosted base, await init, return the
  worklet node; 48 kHz; raw-mic fail-safe preserved.
- denoisePipeline.ts: 'deepfilternet' branch for the in-app tester + sampleRate.
- settings.ts: add 'deepfilternet' to DenoiseModelId + getSettings whitelist.
- lotusDenoiseUtils.ts: add the comparison-chart row.
- General.tsx: add the "DeepFilterNet 3 (beta)" dropdown option.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 19:57:08 -04:00

148 lines
5.1 KiB
TypeScript

/**
* Shared client-side denoise pipeline for the in-app model tester.
*
* The same RNNoise/Speex/DTLN worklets that the Element Call shim
* (build/lotus-denoise.js) injects are shipped under
* /public/element-call/denoise/. Here we load them into a normal main-app
* AudioContext so users can audition the models and calibrate the noise gate
* without joining a real call. The graph mirrors the shim:
* source -> [noise gate] -> model -> output
*/
import { DenoiseModelId } from '../state/settings';
// Mirror CallEmbed's widget-base resolution so assets resolve under any base.
const BASE = `${import.meta.env.BASE_URL.replace(/\/+$/, '')}/public/element-call/denoise/`;
/**
* Required AudioContext sample rate per model. RNNoise/Speex (sapphi) and
* DeepFilterNet 3 are 48 kHz. DTLN (@workadventure) targets 16 kHz and does NOT
* resample internally — running it at 48 kHz produces robotic/choppy/quiet
* output, so its whole graph must run in a 16 kHz context.
*/
export const sampleRateFor = (model: DenoiseModelId): number => (model === 'dtln' ? 16000 : 48000);
export type DenoiseNode = {
node: AudioWorkletNode;
dispose: () => void;
};
const wasmCache: Record<string, Promise<ArrayBuffer>> = {};
function fetchWasm(file: string): Promise<ArrayBuffer> {
if (!wasmCache[file]) {
wasmCache[file] = fetch(BASE + file).then((r) => {
if (!r.ok) throw new Error(`denoise asset ${file} unavailable (${r.status})`);
return r.arrayBuffer();
});
}
return wasmCache[file];
}
// addModule throws if the same module URL is added twice to one context.
const addedModules = new WeakMap<BaseAudioContext, Set<string>>();
async function addModuleOnce(ctx: BaseAudioContext, script: string): Promise<void> {
let set = addedModules.get(ctx);
if (!set) {
set = new Set();
addedModules.set(ctx, set);
}
if (set.has(script)) return;
await ctx.audioWorklet.addModule(BASE + script);
set.add(script);
}
const SAPPHI: Record<'rnnoise' | 'speex', { proc: string; script: string; wasm: string }> = {
rnnoise: {
proc: '@sapphi-red/web-noise-suppressor/rnnoise',
script: 'rnnoiseWorklet.js',
wasm: 'rnnoise.wasm',
},
speex: {
proc: '@sapphi-red/web-noise-suppressor/speex',
script: 'speexWorklet.js',
wasm: 'speex.wasm',
},
};
/** Build the model denoise node. RNNoise/Speex are flat sapphi worklets; DTLN
* uses @workadventure's self-resolving ES-module helper. */
export async function buildModelNode(
ctx: BaseAudioContext,
model: DenoiseModelId,
): Promise<DenoiseNode> {
if (model === 'dtln') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mod: any = await import(/* @vite-ignore */ `${BASE}workadventure/audio-worklet.js`);
const handle = await mod.createNoiseSuppressionAudioWorklet(ctx, { bypassUntilReady: true });
return { node: handle.node, dispose: () => handle.dispose() };
}
if (model === 'deepfilternet') {
// deepfilternet3-noise-filter ESM: inlines its worklet/wasm-bindgen glue and
// fetches only df_bg.wasm + the ONNX model, which we self-host under
// deepfilternet/v2/... Override its cdnUrl to our absolute base so nothing
// hits the upstream CDN. DeepFilterNet3Core builds the worklet node directly.
const dfnBase = new URL(`${BASE}deepfilternet`, window.location.href).href;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mod: any = await import(/* @vite-ignore */ `${BASE}deepfilternet/index.esm.js`);
const core = new mod.DeepFilterNet3Core({
sampleRate: sampleRateFor(model),
noiseReductionLevel: 80,
assetConfig: { cdnUrl: dfnBase },
});
await core.initialize();
const node: AudioWorkletNode = await core.createAudioWorkletNode(ctx);
return {
node,
dispose: () => {
try {
core.destroy();
} catch {
/* noop */
}
},
};
}
const cfg = SAPPHI[model];
const [, wasmBinary] = await Promise.all([addModuleOnce(ctx, cfg.script), fetchWasm(cfg.wasm)]);
const node = new AudioWorkletNode(ctx, cfg.proc, {
channelCount: 1,
numberOfInputs: 1,
numberOfOutputs: 1,
processorOptions: { maxChannels: 1, wasmBinary },
});
return {
node,
dispose: () => {
try {
node.port.postMessage('destroy');
} catch {
/* noop */
}
},
};
}
export async function buildGateNode(
ctx: BaseAudioContext,
thresholdDb: number,
): Promise<AudioWorkletNode> {
await addModuleOnce(ctx, 'noiseGateWorklet.js');
return new AudioWorkletNode(ctx, '@sapphi-red/web-noise-suppressor/noise-gate', {
processorOptions: {
openThreshold: thresholdDb,
closeThreshold: thresholdDb - 5,
holdMs: 150,
maxChannels: 1,
},
});
}
/** RMS level of an analyser as dBFS, clamped to [-100, 0]. */
export function readDb(analyser: AnalyserNode): number {
const buf = new Float32Array(analyser.fftSize);
analyser.getFloatTimeDomainData(buf);
let sum = 0;
for (let i = 0; i < buf.length; i += 1) sum += buf[i] * buf[i];
const rms = Math.sqrt(sum / buf.length);
return rms > 0 ? Math.max(-100, 20 * Math.log10(rms)) : -100;
}