Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 14cfa021c5 |
@@ -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 { useTauriUpdater } from '../../../hooks/useTauriUpdater';
|
||||||
import { useDateFormatItems } from '../../../hooks/useDateFormat';
|
import { useDateFormatItems } from '../../../hooks/useDateFormat';
|
||||||
import { playCallJoinSound } from '../../../utils/callSounds';
|
import { playCallJoinSound } from '../../../utils/callSounds';
|
||||||
|
import { DenoiseTester } from './DenoiseTester';
|
||||||
|
|
||||||
type ThemeSelectorProps = {
|
type ThemeSelectorProps = {
|
||||||
themeNames: Record<string, string>;
|
themeNames: Record<string, string>;
|
||||||
@@ -1203,91 +1204,6 @@ function useKeyBind(setter: (code: string) => void) {
|
|||||||
const keyLabel = (code: string) =>
|
const keyLabel = (code: string) =>
|
||||||
code === 'Space' ? 'Space' : code.replace('Key', '').replace('Digit', '');
|
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() {
|
function Calls() {
|
||||||
const [cameraOnJoin, setCameraOnJoin] = useSetting(settingsAtom, 'cameraOnJoin');
|
const [cameraOnJoin, setCameraOnJoin] = useSetting(settingsAtom, 'cameraOnJoin');
|
||||||
const [callNoiseSuppression, setCallNoiseSuppression] = useSetting(
|
const [callNoiseSuppression, setCallNoiseSuppression] = useSetting(
|
||||||
@@ -1480,11 +1396,28 @@ function Calls() {
|
|||||||
step="1"
|
step="1"
|
||||||
value={callDenoiseGateThreshold}
|
value={callDenoiseGateThreshold}
|
||||||
onChange={(e) => setCallDenoiseGateThreshold(parseInt(e.target.value, 10))}
|
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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<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>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
|
|||||||
@@ -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