feat(calls): in-app denoise tester to audition models + calibrate gate
CI / Build & Quality Checks (push) Successful in 10m24s
CI / Trigger Desktop Build (push) Successful in 17s

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:
2026-06-16 17:53:57 -04:00
parent 86272b6b08
commit 14cfa021c5
3 changed files with 508 additions and 87 deletions
@@ -0,0 +1,372 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Box, Button, Text } from 'folds';
import { DenoiseModelId } from '../../../state/settings';
import {
DENOISE_SAMPLE_RATE,
DenoiseNode,
buildGateNode,
buildModelNode,
readDb,
} from '../../../utils/denoisePipeline';
const MAX_RECORD_MS = 6000;
const MIC_CONSTRAINTS = (nativeNS: boolean): MediaStreamConstraints => ({
audio: {
noiseSuppression: nativeNS,
channelCount: 1,
echoCancellation: true,
autoGainControl: true,
},
});
/** 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 (
<Box direction="Column" gap="100">
<Box direction="Row" justifyContent="SpaceBetween">
<Text size="T200">{label}</Text>
<Text size="T200">{db <= -100 ? '−∞ dB' : `${Math.round(db)} dB`}</Text>
</Box>
<Box
style={{
position: 'relative',
height: '12px',
background: 'var(--bg-card)',
border: '1px solid var(--border-color)',
borderRadius: '6px',
overflow: 'hidden',
}}
>
<Box
style={{
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
width: `${pct}%`,
background: 'var(--accent-green)',
transition: 'width 0.05s linear',
}}
/>
{markerPct !== null && (
<Box
style={{
position: 'absolute',
top: 0,
bottom: 0,
left: `${markerPct}%`,
width: '2px',
background: 'var(--accent-orange)',
}}
/>
)}
</Box>
</Box>
);
}
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: DENOISE_SAMPLE_RATE });
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<AudioBuffer | null>(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<string | null>(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(MIC_CONSTRAINTS(nativeNS));
const ctx = new AudioContext({ sampleRate: DENOISE_SAMPLE_RATE });
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: DENOISE_SAMPLE_RATE });
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 {
const ctx = new AudioContext({ sampleRate: DENOISE_SAMPLE_RATE });
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 = model === 'rnnoise' ? 'RNNoise' : model === 'speex' ? 'Speex' : 'DTLN';
return (
<Box direction="Column" gap="400">
{/* Live monitor */}
<Box direction="Column" gap="200">
<Text size="T300">Live monitor</Text>
<Text size="T200" priority="300">
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.
</Text>
<Box direction="Row" gap="200" alignItems="Center">
<Button
size="300"
variant={live ? 'Critical' : 'Secondary'}
outlined
onClick={live ? stopLive : startLive}
>
<Text size="T300">{live ? 'Stop' : 'Start monitor'}</Text>
</Button>
</Box>
{live && (
<Box direction="Column" gap="200">
<DbMeter label="In (raw)" db={inDb} threshold={useGate ? gateThreshold : undefined} />
<DbMeter label={`Out (${modelLabel})`} db={outDb} />
</Box>
)}
</Box>
{/* Record & compare */}
<Box direction="Column" gap="200">
<Text size="T300">Record &amp; compare</Text>
<Text size="T200" priority="300">
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. (Uses the gate when it's
enabled above.)
</Text>
<Box direction="Row" gap="200" alignItems="Center">
<Button
size="300"
variant={recording ? 'Critical' : 'Secondary'}
outlined
onClick={recording ? stopRecord : startRecord}
>
<Text size="T300">{recording ? 'Stop recording' : 'Record clip'}</Text>
</Button>
{recording && <Text size="T200">Recording…</Text>}
</Box>
{recording && <DbMeter label="Recording level" db={recDb} />}
{hasClip && !recording && (
<Box direction="Row" gap="200" wrap="Wrap">
{(
[
{ label: 'Raw', model: null },
{ label: 'RNNoise', model: 'rnnoise' },
{ label: 'Speex', model: 'speex' },
{ label: 'DTLN', model: 'dtln' },
] as const
).map((b) => (
<Button
key={b.label}
size="300"
variant={playing === b.label ? 'Primary' : 'Secondary'}
outlined
onClick={() => (playing === b.label ? stopPlayback() : play(b.label, b.model))}
>
<Text size="T300">
{playing === b.label ? `Stop ${b.label}` : `Play ${b.label}`}
</Text>
</Button>
))}
</Box>
)}
</Box>
</Box>
);
}
+20 -87
View File
@@ -75,6 +75,7 @@ import { SequenceCardStyle } from '../styles.css';
import { useTauriUpdater } from '../../../hooks/useTauriUpdater';
import { useDateFormatItems } from '../../../hooks/useDateFormat';
import { playCallJoinSound } from '../../../utils/callSounds';
import { DenoiseTester } from './DenoiseTester';
type ThemeSelectorProps = {
themeNames: Record<string, string>;
@@ -1203,91 +1204,6 @@ function useKeyBind(setter: (code: string) => void) {
const keyLabel = (code: string) =>
code === 'Space' ? 'Space' : code.replace('Key', '').replace('Digit', '');
function MicMeter() {
const [level, setLevel] = useState(0);
const [active, setActive] = useState(false);
const streamRef = useRef<MediaStream | null>(null);
const ctxRef = useRef<AudioContext | null>(null);
const rafRef = useRef<number | null>(null);
const stop = useCallback(() => {
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
rafRef.current = null;
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
ctxRef.current?.close();
ctxRef.current = null;
setActive(false);
setLevel(0);
}, []);
const start = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
const ctx = new AudioContext();
ctxRef.current = ctx;
const source = ctx.createMediaStreamSource(stream);
const analyser = ctx.createAnalyser();
analyser.fftSize = 256;
source.connect(analyser);
const buffer = new Uint8Array(analyser.frequencyBinCount);
const update = () => {
analyser.getByteFrequencyData(buffer);
let sum = 0;
for (let i = 0; i < buffer.length; i += 1) sum += buffer[i];
setLevel(sum / buffer.length);
rafRef.current = requestAnimationFrame(update);
};
update();
setActive(true);
} catch (e) {
// eslint-disable-next-line no-console
console.error('Mic test failed', e);
}
};
useEffect(() => () => stop(), [stop]);
return (
<Box direction="Column" gap="100" style={{ padding: '8px 0' }}>
<Box direction="Row" gap="200" alignItems="Center">
<Button size="300" variant="Secondary" outlined onClick={active ? stop : start}>
<Text size="T300">{active ? 'Stop Test' : 'Test Microphone'}</Text>
</Button>
<Box
grow="Yes"
style={{
height: '10px',
background: 'var(--lt-bg-card, rgba(0,0,0,0.2))',
borderRadius: '5px',
overflow: 'hidden',
position: 'relative',
border: '1px solid var(--lt-border-color)',
}}
>
<Box
style={{
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
width: `${Math.min(100, (level / 128) * 100)}%`,
background: 'var(--lt-accent-green, #00FF88)',
transition: 'width 0.05s linear',
boxShadow: '0 0 8px var(--lt-accent-green)',
}}
/>
</Box>
</Box>
<Text size="T200" priority="300">
The green bar shows your live volume. Use this to tune the Gate Threshold.
</Text>
</Box>
);
}
function Calls() {
const [cameraOnJoin, setCameraOnJoin] = useSetting(settingsAtom, 'cameraOnJoin');
const [callNoiseSuppression, setCallNoiseSuppression] = useSetting(
@@ -1480,11 +1396,28 @@ function Calls() {
step="1"
value={callDenoiseGateThreshold}
onChange={(e) => setCallDenoiseGateThreshold(parseInt(e.target.value, 10))}
style={{ width: '100%', accentColor: 'var(--lt-accent-orange)' }}
style={{ width: '100%', accentColor: 'var(--accent-orange)' }}
/>
<MicMeter />
</Box>
)}
<Box
direction="Column"
gap="200"
style={{ paddingTop: '8px', borderTop: '1px solid var(--border-color)' }}
>
<Text size="L400">Test &amp; calibrate</Text>
<Text size="T200" priority="300">
Audition the selected model and tune the gate without joining a call. Changes to the
settings above apply the next time you start a monitor or play a clip.
</Text>
<DenoiseTester
model={callDenoiseModel}
useGate={callDenoiseGate}
gateThreshold={callDenoiseGateThreshold}
nativeNS={callDenoiseNativeNS}
/>
</Box>
</Box>
)}
</SequenceCard>