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,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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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>
|
||||
|
||||
Reference in New Issue
Block a user