Compare commits
1 Commits
86272b6b08
...
14cfa021c5
| 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 { 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>
|
||||
|
||||
@@ -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