6ec86a8Microphone level meter, input gain, and level check in the recorderJeremy Magland 1import { useEffect, useRef, useState } from 'react';
2import { MAX_MIC_GAIN, type MicGain } from '../../shared/types.ts';
3import { api } from '../api.ts';
f3253d0Takes: record, organize, and edit explainer videosJeremy Magland 4
6ec86a8Microphone level meter, input gain, and level check in the recorderJeremy Magland 5// Level meter and gain tools for the microphone, measured on the same stream that is recorded
6// (after noise suppression, when that is on).
7//
8// Levels are in dBFS (0 = the loudest sample the recording can hold). For speech, peaks between
9// -12 and -6 dBFS leave headroom for louder moments without making the background noise loud
10// after export normalizes the loudness. Clipping cannot be undone later; a level that is a little
11// low can.
13const FLOOR_DB = -60;
14const TARGET_LOW = -12;
15const TARGET_HIGH = -6;
16const TARGET = -9;
17const CLIP = 0.99;
18const CHECK_SECONDS = 6;
19const HOLD_MS = 1500;
21const toDb = (x: number) => (x > 0 ? 20 * Math.log10(x) : -Infinity);
22const pos = (db: number) => `${Math.max(0, Math.min(1, (db - FLOOR_DB) / -FLOOR_DB)) * 100}%`;
23const fmtDb = (db: number) => (db === -Infinity || db < FLOOR_DB ? `< ${FLOOR_DB} dB` : `${Math.round(db)} dB`);
25interface Block {
26 peak: number;
27 rms: number;
28}
30interface CheckResult {
31 speechDb: number; // 95th percentile of the block peaks
32 noiseDb: number; // 10th percentile of the block RMS
33 clipped: boolean;
34 change: number; // suggested gain change, dB (0 when the level is fine)
35}
37function assess(blocks: Block[]): CheckResult {
38 const peaks = blocks.map((b) => b.peak).sort((a, b) => a - b);
39 const rms = blocks.map((b) => b.rms).sort((a, b) => a - b);
40 const q = (xs: number[], p: number) => xs[Math.min(xs.length - 1, Math.floor(p * xs.length))] ?? 0;
41 const speechDb = toDb(q(peaks, 0.95));
42 const clipped = peaks.length > 0 && peaks[peaks.length - 1] >= CLIP;
43 let change = TARGET - speechDb;
44 // A clipped level says only that the speech is too loud, not by how much.
45 if (clipped) change = Math.min(change, -6);
46 else if (speechDb >= TARGET_LOW - 1 && speechDb <= TARGET_HIGH + 1) change = 0;
47 return { speechDb, noiseDb: toDb(q(rms, 0.1)), clipped, change: Math.round(change) };
48}
50export function MicMeter({ stream, autoGain, disabled }: { stream: MediaStream | null; autoGain: boolean; disabled: boolean }) {
f3253d0Takes: record, organize, and edit explainer videosJeremy Magland 51 const barRef = useRef<HTMLDivElement>(null);
6ec86a8Microphone level meter, input gain, and level check in the recorderJeremy Magland 52 const holdRef = useRef<HTMLDivElement>(null);
53 const [peakDb, setPeakDb] = useState(-Infinity);
54 const [clippedAt, setClippedAt] = useState(0);
55 const [gain, setGain] = useState<MicGain | null>(null);
56 const [checking, setChecking] = useState(0); // seconds left
57 const [result, setResult] = useState<CheckResult | null>(null);
58 const [error, setError] = useState<string | null>(null);
59 const [dragging, setDragging] = useState<number | null>(null); // slider value not yet applied
60 const applyTimer = useRef(0);
61 const collect = useRef<Block[] | null>(null);
62 const label = stream?.getAudioTracks()[0]?.label ?? '';
65 if (!stream || stream.getAudioTracks().length === 0) return;
66 const ctx = new AudioContext();
67 const src = ctx.createMediaStreamSource(stream);
68 const analyser = ctx.createAnalyser();
6ec86a8Microphone level meter, input gain, and level check in the recorderJeremy Magland 69 // 2048 samples (43 ms at 48 kHz) per read, so that reads at the display rate see every sample.
70 analyser.fftSize = 2048;
72 const buf = new Float32Array(analyser.fftSize);
73 let raf = 0;
74 let level = 0;
6ec86a8Microphone level meter, input gain, and level check in the recorderJeremy Magland 75 let hold = 0;
76 let holdUntil = 0;
77 let shown = 0;
79 analyser.getFloatTimeDomainData(buf);
80 let peak = 0;
6ec86a8Microphone level meter, input gain, and level check in the recorderJeremy Magland 81 let sum = 0;
82 for (const x of buf) {
83 const a = Math.abs(x);
84 if (a > peak) peak = a;
85 sum += x * x;
86 }
87 collect.current?.push({ peak, rms: Math.sqrt(sum / buf.length) });
88 const now = performance.now();
89 level = Math.max(peak, level * 0.85);
90 if (peak >= hold || now > holdUntil) {
91 hold = peak;
92 holdUntil = now + HOLD_MS;
93 }
94 if (peak >= CLIP) setClippedAt(now);
95 if (barRef.current) barRef.current.style.width = pos(toDb(level));
96 if (holdRef.current) holdRef.current.style.left = pos(toDb(hold));
97 if (now - shown > 250) {
98 shown = now;
99 setPeakDb(toDb(hold));
101 raf = requestAnimationFrame(loop);
102 };
103 loop();
104 return () => {
105 cancelAnimationFrame(raf);
106 ctx.close();
107 };
108 }, [stream]);
110 // Current gain of this microphone, when the server can set it.
111 useEffect(() => {
112 setGain(null);
113 setResult(null);
114 if (!stream) return;
115 api.micGain(label).then(setGain).catch((e) => setError(e.message));
116 }, [stream, label]);
118 const applyGain = async (volume: number) => {
119 setError(null);
120 try {
121 setGain(await api.setMicGain(label, volume));
122 } catch (e) {
123 setError((e as Error).message);
124 }
125 };
127 // The slider applies its value once it has rested for a moment, rather than on every step.
128 const slide = (volume: number) => {
129 setDragging(volume);
130 clearTimeout(applyTimer.current);
131 applyTimer.current = window.setTimeout(() => applyGain(volume).then(() => setDragging(null)), 150);
132 };
134 const check = async () => {
135 setResult(null);
136 collect.current = [];
137 for (let n = CHECK_SECONDS; n > 0; n--) {
138 setChecking(n);
139 await new Promise((r) => setTimeout(r, 1000));
140 }
141 setChecking(0);
142 const blocks = collect.current;
143 collect.current = null;
144 setResult(assess(blocks));
145 };
147 const clipping = performance.now() - clippedAt < 2000;
148 const canSet = !!gain?.available && !autoGain && !disabled;
149 const wanted = result && gain?.volume !== undefined ? gain.volume * 10 ** (result.change / 60) : null;
150 const suggested = wanted === null ? null : Math.min(MAX_MIC_GAIN, wanted);
151 const shownGain = dragging ?? gain?.volume ?? 0;
6ec86a8Microphone level meter, input gain, and level check in the recorderJeremy Magland 154 <div className="mic-tools">
155 <div className="meter-row">
156 <div className="meter" title="Microphone level. The shaded band is the target range for the peaks of your speech.">
157 <div className="meter-zone" style={{ left: pos(TARGET_LOW), width: `calc(${pos(TARGET_HIGH)} - ${pos(TARGET_LOW)})` }} />
158 <div className={`meter-bar${clipping ? ' hot' : ''}`} ref={barRef} />
159 <div className="meter-hold" ref={holdRef} />
160 </div>
161 <span className={`meter-readout${clipping ? ' warn-text' : ''}`}>{clipping ? 'Clipping' : `peak ${fmtDb(peakDb)}`}</span>
162 </div>
164 {autoGain ? (
165 <p className="muted small">Automatic gain is on, so the browser adjusts the input level while you speak.</p>
166 ) : gain?.available && gain.volume !== undefined ? (
167 <label className="row gain-row" title="Input gain of the microphone in the system (PipeWire)">
168 <span>Gain</span>
169 <input
170 type="range"
171 min={0}
172 max={MAX_MIC_GAIN * 100}
173 step={1}
174 disabled={!canSet}
175 value={Math.round(shownGain * 100)}
176 onChange={(e) => slide(parseInt(e.target.value) / 100)}
177 />
178 <span className="gain-value">{Math.round(shownGain * 100)}%</span>
179 </label>
180 ) : gain && !gain.available ? (
181 <p className="muted small">The input gain cannot be set from here ({gain.reason}); use the system sound settings.</p>
182 ) : null}
183 {gain?.muted && <p className="warn-text small">This microphone is muted in the system sound settings.</p>}
185 <div className="row">
186 <button className="btn small" disabled={disabled || checking > 0 || !stream} onClick={check} title={`Measures the level for ${CHECK_SECONDS} seconds and suggests a gain`}>
187 {checking > 0 ? `Keep talking… ${checking}` : 'Check level'}
188 </button>
189 {checking > 0 && <span className="muted small">Speak as you will when recording.</span>}
190 </div>
192 {result && (
193 <div className="check-result small">
194 {result.speechDb < -45 ? (
195 <p className="warn-text">Hardly any sound was picked up. Check that the right microphone is selected and not muted.</p>
196 ) : (
197 <>
198 <p>
199 Speech peaks at {fmtDb(result.speechDb)} (target {TARGET_LOW} to {TARGET_HIGH} dB); background noise {fmtDb(result.noiseDb)}.
200 {result.clipped && ' The level clipped, so the recording would be distorted.'}
201 </p>
202 {result.change === 0 ? (
203 <p className="ok-text">The level is good.</p>
204 ) : (
205 <p>
206 {result.change > 0 ? `Raise the gain by about ${result.change} dB.` : `Lower the gain by ${result.clipped ? 'at least' : 'about'} ${-result.change} dB.`}{' '}
207 {canSet && suggested !== null && wanted !== null && (
208 <>
209 <button className="btn small" onClick={() => applyGain(suggested).then(() => setResult(null))}>
210 Set gain to {Math.round(suggested * 100)}%
211 </button>{' '}
212 {wanted > MAX_MIC_GAIN && (
213 <span className="muted">That is the maximum; moving closer to the microphone also helps. </span>
214 )}
215 <span className="muted">Then check again.</span>
216 </>
217 )}
218 {!canSet && !autoGain && <span className="muted">Use the system sound settings.</span>}
219 </p>
220 )}
221 </>
222 )}
223 </div>
224 )}
225 {error && <p className="warn-text small">{error}</p>}
227 );
228}