117 lines
3.8 KiB
TypeScript
117 lines
3.8 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/`;
|
||
|
|
|
||
|
|
/** RNNoise/Speex/DTLN all assume mono 48 kHz, matching the call pipeline. */
|
||
|
|
export const DENOISE_SAMPLE_RATE = 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() };
|
||
|
|
}
|
||
|
|
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;
|
||
|
|
}
|