feat(calls): in-app denoise tester to audition models + calibrate gate
The previous "Test Microphone" meter only showed a raw 0-100% level bar — it
never ran the gate or any model, and its scale wasn't dBFS, so it couldn't tell
you which threshold to pick or let you hear the models solo. Replace it with a
real tester that reuses the shipped worklets (/public/element-call/denoise/) in
a main-app AudioContext, mirroring the call pipeline (source -> gate -> model).
- denoisePipeline.ts: shared loader for the RNNoise/Speex flat worklets and the
DTLN @workadventure helper, the noise gate, and a dBFS RMS meter helper.
- DenoiseTester.tsx:
- Live monitor: hear yourself through the selected model (+gate) in real time
(headphones) with In/Out dBFS meters and a threshold marker on the In meter
so the gate value is meaningful to calibrate.
- Record & compare: capture a short clip, then A/B the same audio Raw vs
RNNoise vs Speex vs DTLN.
- Wire it into the ML settings block; remove the old raw-only MicMeter. Use real
TDS tokens (--accent-*, --border-color, --bg-card) instead of the invented
--lt-* names + hardcoded hex the old meter used.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user