import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Box, Button, color, config, Text } from 'folds';
import { DenoiseModelId } from '../../../state/settings';
import { DENOISE_MODELS } from '../../../utils/lotusDenoiseUtils';
import {
DenoiseNode,
buildGateNode,
buildModelNode,
readDb,
sampleRateFor,
} from '../../../utils/denoisePipeline';
const MAX_RECORD_MS = 6000;
// Live monitor mirrors the call's capture (respects the user's native-NS choice).
const MIC_CONSTRAINTS = (nativeNS: boolean): MediaStreamConstraints => ({
audio: {
noiseSuppression: nativeNS,
channelCount: 1,
echoCancellation: true,
autoGainControl: true,
},
});
// Record & compare captures fully RAW audio (no browser noise suppression / AGC
// / echo cancel) so each model's effect on real background noise is audible.
// Capturing with native NS on would pre-clean the clip and make Raw/RNNoise/
// Speex sound identical.
const RAW_CONSTRAINTS: MediaStreamConstraints = {
audio: {
noiseSuppression: false,
echoCancellation: false,
autoGainControl: false,
channelCount: 1,
},
};
/** A -100..0 dBFS bar with optional threshold marker. */
function DbMeter({ label, db, threshold }: { label: string; db: number; threshold?: number }) {
const pct = Math.max(0, Math.min(100, db + 100));
const markerPct = threshold === undefined ? null : Math.max(0, Math.min(100, threshold + 100));
return (
{label}
{db <= -100 ? '−∞ dB' : `${Math.round(db)} dB`}
{markerPct !== null && (
)}
);
}
type DenoiseTesterProps = {
model: DenoiseModelId;
useGate: boolean;
gateThreshold: number;
nativeNS: boolean;
};
export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: DenoiseTesterProps) {
// ── Live loopback monitor ────────────────────────────────────────────────
const liveRef = useRef<{
ctx: AudioContext;
stream: MediaStream;
model: DenoiseNode;
gate: AudioWorkletNode | null;
raf: number;
inAnalyser: AnalyserNode;
outAnalyser: AnalyserNode;
} | null>(null);
const [live, setLive] = useState(false);
const [inDb, setInDb] = useState(-100);
const [outDb, setOutDb] = useState(-100);
const stopLive = useCallback(() => {
const s = liveRef.current;
liveRef.current = null;
if (s) {
cancelAnimationFrame(s.raf);
try {
s.gate?.disconnect();
s.model.dispose();
s.model.node.disconnect();
} catch {
/* noop */
}
s.stream.getTracks().forEach((t) => t.stop());
s.ctx.close().catch(() => undefined);
}
setLive(false);
setInDb(-100);
setOutDb(-100);
}, []);
const startLive = async () => {
try {
const ctx = new AudioContext({ sampleRate: sampleRateFor(model) });
const stream = await navigator.mediaDevices.getUserMedia(MIC_CONSTRAINTS(nativeNS));
const source = ctx.createMediaStreamSource(stream);
const inAnalyser = ctx.createAnalyser();
inAnalyser.fftSize = 1024;
source.connect(inAnalyser);
let head: AudioNode = source;
let gate: AudioWorkletNode | null = null;
if (useGate) {
gate = await buildGateNode(ctx, gateThreshold);
head.connect(gate);
head = gate;
}
const denoise = await buildModelNode(ctx, model);
head.connect(denoise.node);
const outAnalyser = ctx.createAnalyser();
outAnalyser.fftSize = 1024;
denoise.node.connect(outAnalyser);
denoise.node.connect(ctx.destination); // loopback to speakers
const tick = () => {
setInDb(readDb(inAnalyser));
setOutDb(readDb(outAnalyser));
if (liveRef.current) liveRef.current.raf = requestAnimationFrame(tick);
};
liveRef.current = {
ctx,
stream,
model: denoise,
gate,
raf: 0,
inAnalyser,
outAnalyser,
};
setLive(true);
tick();
} catch (e) {
console.error('[denoise-tester] live monitor failed', e);
stopLive();
}
};
// ── Record & compare ─────────────────────────────────────────────────────
const recRef = useRef<{
ctx: AudioContext;
stream: MediaStream;
recorder: MediaRecorder;
chunks: BlobPart[];
raf: number;
analyser: AnalyserNode;
timer: number;
} | null>(null);
const clipRef = useRef(null);
const playRef = useRef<{ ctx: AudioContext; source: AudioBufferSourceNode } | null>(null);
const [recording, setRecording] = useState(false);
const [recDb, setRecDb] = useState(-100);
const [hasClip, setHasClip] = useState(false);
const [playing, setPlaying] = useState(null);
const teardownRecorder = () => {
const r = recRef.current;
recRef.current = null;
if (r) {
cancelAnimationFrame(r.raf);
window.clearTimeout(r.timer);
r.stream.getTracks().forEach((t) => t.stop());
r.ctx.close().catch(() => undefined);
}
};
const stopRecord = useCallback(() => {
const r = recRef.current;
if (r && r.recorder.state !== 'inactive') r.recorder.stop();
setRecording(false);
}, []);
const startRecord = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia(RAW_CONSTRAINTS);
const ctx = new AudioContext();
const source = ctx.createMediaStreamSource(stream);
const analyser = ctx.createAnalyser();
analyser.fftSize = 1024;
source.connect(analyser);
const recorder = new MediaRecorder(stream);
const chunks: BlobPart[] = [];
recorder.ondataavailable = (ev) => {
if (ev.data.size > 0) chunks.push(ev.data);
};
recorder.onstop = async () => {
const blob = new Blob(chunks, { type: recorder.mimeType || 'audio/webm' });
teardownRecorder();
setRecDb(-100);
try {
const decodeCtx = new AudioContext({ sampleRate: 48000 });
clipRef.current = await decodeCtx.decodeAudioData(await blob.arrayBuffer());
decodeCtx.close().catch(() => undefined);
setHasClip(true);
} catch (e) {
console.error('[denoise-tester] decode failed', e);
}
};
const tick = () => {
setRecDb(readDb(analyser));
if (recRef.current) recRef.current.raf = requestAnimationFrame(tick);
};
const timer = window.setTimeout(stopRecord, MAX_RECORD_MS);
recRef.current = { ctx, stream, recorder, chunks, raf: 0, analyser, timer };
recorder.start();
setRecording(true);
tick();
} catch (e) {
console.error('[denoise-tester] record failed', e);
teardownRecorder();
setRecording(false);
}
};
const stopPlayback = useCallback(() => {
const p = playRef.current;
playRef.current = null;
if (p) {
try {
p.source.onended = null;
p.source.stop();
} catch {
/* noop */
}
p.ctx.close().catch(() => undefined);
}
setPlaying(null);
}, []);
const play = async (label: string, playModel: DenoiseModelId | null) => {
stopPlayback();
const clip = clipRef.current;
if (!clip) return;
try {
// bufferSource auto-resamples the 48 kHz clip to the context rate, so DTLN
// gets the 16 kHz it needs while raw/RNNoise/Speex stay at 48 kHz.
const ctx = new AudioContext({ sampleRate: sampleRateFor(playModel ?? 'rnnoise') });
const source = ctx.createBufferSource();
source.buffer = clip;
if (playModel) {
let head: AudioNode = source;
if (useGate) {
const gate = await buildGateNode(ctx, gateThreshold);
head.connect(gate);
head = gate;
}
const denoise = await buildModelNode(ctx, playModel);
head.connect(denoise.node);
denoise.node.connect(ctx.destination);
} else {
source.connect(ctx.destination);
}
source.onended = () => {
if (playRef.current?.ctx === ctx) stopPlayback();
};
playRef.current = { ctx, source };
source.start();
setPlaying(label);
} catch (e) {
console.error('[denoise-tester] playback failed', e);
stopPlayback();
}
};
useEffect(
() => () => {
stopLive();
stopPlayback();
teardownRecorder();
},
[stopLive, stopPlayback],
);
const modelLabel = DENOISE_MODELS.find((m) => m.id === model)?.name ?? model;
return (
{/* Live monitor */}
Live monitor
Hear yourself through {modelLabel}
{useGate ? ' + gate' : ''} in real time. Use headphones to avoid feedback. Watch the In
meter while you speak and while silent — set the gate threshold (orange line) just above
your silent level.
{live && (
)}
{/* Record & compare */}
Record & compare
Record up to {MAX_RECORD_MS / 1000}s of yourself with your usual background noise, then
play the same clip back raw vs through each model to A/B them. Captured fully raw (browser
noise suppression off) so each model's effect is audible; uses the gate when enabled
above.
{recording && Recording…}
{recording && }
{hasClip && !recording && (
{(
[
{ label: 'Raw', model: null },
{ label: 'RNNoise', model: 'rnnoise' },
{ label: 'Speex', model: 'speex' },
{ label: 'DTLN', model: 'dtln' },
{ label: 'DeepFilterNet', model: 'deepfilternet' },
] as const
).map((b) => (
))}
)}
);
}