import { useEffect, useRef, useState } from 'react'; import { MAX_MIC_GAIN, type MicGain } from '../../shared/types.ts'; import { api } from '../api.ts'; // Level meter and gain tools for the microphone, measured on the same stream that is recorded // (after noise suppression, when that is on). // // Levels are in dBFS (0 = the loudest sample the recording can hold). For speech, peaks between // -12 and -6 dBFS leave headroom for louder moments without making the background noise loud // after export normalizes the loudness. Clipping cannot be undone later; a level that is a little // low can. const FLOOR_DB = -60; const TARGET_LOW = -12; const TARGET_HIGH = -6; const TARGET = -9; const CLIP = 0.99; const CHECK_SECONDS = 6; const HOLD_MS = 1500; const toDb = (x: number) => (x > 0 ? 20 * Math.log10(x) : -Infinity); const pos = (db: number) => `${Math.max(0, Math.min(1, (db - FLOOR_DB) / -FLOOR_DB)) * 100}%`; const fmtDb = (db: number) => (db === -Infinity || db < FLOOR_DB ? `< ${FLOOR_DB} dB` : `${Math.round(db)} dB`); interface Block { peak: number; rms: number; } interface CheckResult { speechDb: number; // 95th percentile of the block peaks noiseDb: number; // 10th percentile of the block RMS clipped: boolean; change: number; // suggested gain change, dB (0 when the level is fine) } function assess(blocks: Block[]): CheckResult { const peaks = blocks.map((b) => b.peak).sort((a, b) => a - b); const rms = blocks.map((b) => b.rms).sort((a, b) => a - b); const q = (xs: number[], p: number) => xs[Math.min(xs.length - 1, Math.floor(p * xs.length))] ?? 0; const speechDb = toDb(q(peaks, 0.95)); const clipped = peaks.length > 0 && peaks[peaks.length - 1] >= CLIP; let change = TARGET - speechDb; // A clipped level says only that the speech is too loud, not by how much. if (clipped) change = Math.min(change, -6); else if (speechDb >= TARGET_LOW - 1 && speechDb <= TARGET_HIGH + 1) change = 0; return { speechDb, noiseDb: toDb(q(rms, 0.1)), clipped, change: Math.round(change) }; } export function MicMeter({ stream, autoGain, disabled }: { stream: MediaStream | null; autoGain: boolean; disabled: boolean }) { const barRef = useRef(null); const holdRef = useRef(null); const [peakDb, setPeakDb] = useState(-Infinity); const [clippedAt, setClippedAt] = useState(0); const [gain, setGain] = useState(null); const [checking, setChecking] = useState(0); // seconds left const [result, setResult] = useState(null); const [error, setError] = useState(null); const [dragging, setDragging] = useState(null); // slider value not yet applied const applyTimer = useRef(0); const collect = useRef(null); const label = stream?.getAudioTracks()[0]?.label ?? ''; useEffect(() => { if (!stream || stream.getAudioTracks().length === 0) return; const ctx = new AudioContext(); const src = ctx.createMediaStreamSource(stream); const analyser = ctx.createAnalyser(); // 2048 samples (43 ms at 48 kHz) per read, so that reads at the display rate see every sample. analyser.fftSize = 2048; src.connect(analyser); const buf = new Float32Array(analyser.fftSize); let raf = 0; let level = 0; let hold = 0; let holdUntil = 0; let shown = 0; const loop = () => { analyser.getFloatTimeDomainData(buf); let peak = 0; let sum = 0; for (const x of buf) { const a = Math.abs(x); if (a > peak) peak = a; sum += x * x; } collect.current?.push({ peak, rms: Math.sqrt(sum / buf.length) }); const now = performance.now(); level = Math.max(peak, level * 0.85); if (peak >= hold || now > holdUntil) { hold = peak; holdUntil = now + HOLD_MS; } if (peak >= CLIP) setClippedAt(now); if (barRef.current) barRef.current.style.width = pos(toDb(level)); if (holdRef.current) holdRef.current.style.left = pos(toDb(hold)); if (now - shown > 250) { shown = now; setPeakDb(toDb(hold)); } raf = requestAnimationFrame(loop); }; loop(); return () => { cancelAnimationFrame(raf); ctx.close(); }; }, [stream]); // Current gain of this microphone, when the server can set it. useEffect(() => { setGain(null); setResult(null); if (!stream) return; api.micGain(label).then(setGain).catch((e) => setError(e.message)); }, [stream, label]); const applyGain = async (volume: number) => { setError(null); try { setGain(await api.setMicGain(label, volume)); } catch (e) { setError((e as Error).message); } }; // The slider applies its value once it has rested for a moment, rather than on every step. const slide = (volume: number) => { setDragging(volume); clearTimeout(applyTimer.current); applyTimer.current = window.setTimeout(() => applyGain(volume).then(() => setDragging(null)), 150); }; const check = async () => { setResult(null); collect.current = []; for (let n = CHECK_SECONDS; n > 0; n--) { setChecking(n); await new Promise((r) => setTimeout(r, 1000)); } setChecking(0); const blocks = collect.current; collect.current = null; setResult(assess(blocks)); }; const clipping = performance.now() - clippedAt < 2000; const canSet = !!gain?.available && !autoGain && !disabled; const wanted = result && gain?.volume !== undefined ? gain.volume * 10 ** (result.change / 60) : null; const suggested = wanted === null ? null : Math.min(MAX_MIC_GAIN, wanted); const shownGain = dragging ?? gain?.volume ?? 0; return (
{clipping ? 'Clipping' : `peak ${fmtDb(peakDb)}`}
{autoGain ? (

Automatic gain is on, so the browser adjusts the input level while you speak.

) : gain?.available && gain.volume !== undefined ? ( ) : gain && !gain.available ? (

The input gain cannot be set from here ({gain.reason}); use the system sound settings.

) : null} {gain?.muted &&

This microphone is muted in the system sound settings.

}
{checking > 0 && Speak as you will when recording.}
{result && (
{result.speechDb < -45 ? (

Hardly any sound was picked up. Check that the right microphone is selected and not muted.

) : ( <>

Speech peaks at {fmtDb(result.speechDb)} (target {TARGET_LOW} to {TARGET_HIGH} dB); background noise {fmtDb(result.noiseDb)}. {result.clipped && ' The level clipped, so the recording would be distorted.'}

{result.change === 0 ? (

The level is good.

) : (

{result.change > 0 ? `Raise the gain by about ${result.change} dB.` : `Lower the gain by ${result.clipped ? 'at least' : 'about'} ${-result.change} dB.`}{' '} {canSet && suggested !== null && wanted !== null && ( <> {' '} {wanted > MAX_MIC_GAIN && ( That is the maximum; moving closer to the microphone also helps. )} Then check again. )} {!canSet && !autoGain && Use the system sound settings.}

)} )}
)} {error &&

{error}

}
); }