Microphone level meter, input gain, and level check in the recorder
8 changed files+349−20
README.mdmodified+2−0View file
@@ -36,6 +36,8 @@ The library shows every video with its thumbnail, duration, and description. Dra
3636
3737 **Recording.** Choose any combination of camera, microphone, and screen, then start recording. The screen picker is GNOME's own portal dialog, which lets you choose a monitor or a single window. A countdown precedes the recording, and it can be paused and resumed. The *Teleprompter window* button opens the notes in a separate window with large text, optional auto-scroll, and start/pause/stop controls, so it can sit on a second monitor or outside the captured window. Recorded data is uploaded to the server once per second while recording, so a browser crash loses at most about a second; an interrupted take appears on the video page with a *Recover* button.
3838
39+**Microphone level.** Below the microphone is a level meter in dBFS, with a shaded band from −12 to −6 dB, the target range for the peaks of speech, a line that holds the recent peak, and a *Clipping* warning. With PipeWire, a *Gain* slider sets the microphone's input gain in the system (through `wpctl`, on the node whose description matches the browser's label for the microphone; PipeWire moves the sound card's capture control along with it). *Check level* measures the level for 6 seconds while you speak, reports the speech peaks (the 95th percentile of the peak level over 43 ms blocks) and the background noise (the 10th percentile of the RMS level), and suggests a gain change toward −9 dB, which can be applied with one click. The suggestion converts decibels to wpctl's volume scale, which is only approximately right for hardware controls, so check again after applying it. Export normalizes the loudness anyway, so the aim is mainly to avoid clipping, which cannot be undone, and a level so low that normalization brings up the background noise. *Automatic gain* (off by default) lets the browser adjust the level instead; it is separate from *Noise suppression*, since it would otherwise move the gain that was set by hand.
40+
3941 **Computer audio.** With *Computer audio* checked, Takes also records what the computer plays through its default output (for example, the sound of a demo), in addition to the microphone. Unlike the microphone, this is not captured by the browser: the server records the PipeWire or PulseAudio monitor of the default output with ffmpeg, so it works in any browser and without sharing the screen. The two recordings are aligned by wall-clock time, which the browser and the server share since they run on the same machine. ffmpeg reports when its first sample was captured, the recorder reports when it started, paused, and resumed, and when the take is saved the capture is cut to the same intervals and stored as `system.webm`. In a test with a synthetic capture, the cut points were within 15 ms of where they should be. Computer audio is kept separate from the microphone: it is mixed in during playback and export, with its own level on the *Layout & export* tab, and it is not transcribed. Use headphones when recording both, since otherwise the microphone also picks up the sound from the speakers.
4042
4143 **Editing.** Each video has a timeline made of segments of its takes. A new take is appended to the end of the timeline, and a take can be added again from the *Takes* list. The editor supports splitting at the playhead (S), marking a range (I and O) and cutting it (X), trimming the current segment to the playhead, removing and reordering segments, and undo/redo. The timeline shows the audio waveform, and a zoom slider helps with precise cuts. Editing is non-destructive: the raw takes are never modified.
server/index.tsmodified+11−0View file
@@ -17,6 +17,7 @@ import { appendChunk, createTake, deleteTake, finalizeTake, setThumbnail } from
1717 import { systemCaptureEvent } from './sysaudio.ts';
1818 import { transcribeTake } from './transcribe.ts';
1919 import { cancelMattes, requestMattes, withMatteStatus } from './matte.ts';
20+import { getMicGain, setMicGain } from './micgain.ts';
2021
2122 function parseArgs(argv: string[]) {
2223 const opts = { dir: path.join(os.homedir(), 'Videos', 'takes'), port: 4455, open: true, browser: false };
@@ -88,6 +89,16 @@ app.put('/api/config', json, async (req, res) => {
8889 res.json({ ok: true });
8990 });
9091
92+// Input gain of the microphone with this browser label (see micgain.ts).
93+app.get('/api/mic-gain', async (req, res) => {
94+ res.json(await getMicGain(String(req.query.label ?? '')));
95+});
96+
97+app.put('/api/mic-gain', json, async (req, res) => {
98+ const b = req.body ?? {};
99+ res.json(await setMicGain(String(b.label ?? ''), Number(b.volume)));
100+});
101+
91102 app.get('/api/videos', async (_req, res) => {
92103 res.json(await listVideos());
93104 });
server/micgain.tsadded+53−0View file
@@ -0,0 +1,53 @@
1+// Input gain of a microphone, read and set through PipeWire's wpctl.
2+//
3+// The browser cannot change the input gain of a microphone, but the server runs on the same machine
4+// and can. A microphone is identified by the label the browser gives it, which for PipeWire (through
5+// its PulseAudio layer) is the node description, e.g. "Built-in Audio Analog Stereo"; the browser's
6+// "Default" device is PipeWire's default source. The volume is on wpctl's scale, where 1.0 is 100%
7+// and, as in PulseAudio, a change of a factor k corresponds to about 60·log10(k) dB. For hardware
8+// sources, PipeWire moves the sound card's capture control along with it, and the dB steps are only
9+// approximately those of that scale, so the recorder measures the level again after a change.
10+
11+import { spawnSync } from 'node:child_process';
12+import { MAX_MIC_GAIN, type MicGain } from '../shared/types.ts';
13+import { run } from './media.ts';
14+import { HttpError } from './store.ts';
15+
16+const hasWpctl = () => !spawnSync('wpctl', ['--version']).error;
17+
18+// The wpctl target for a browser microphone label.
19+async function findNode(label: string): Promise<{ node: string; description: string } | null> {
20+ const name = label.replace(/^Default( - )?/, '').trim();
21+ if (!name) return { node: '@DEFAULT_AUDIO_SOURCE@', description: 'Default microphone' };
22+ const { stdout } = await run('pw-dump', []);
23+ for (const o of JSON.parse(stdout) as { id: number; info?: { props?: Record<string, unknown> } }[]) {
24+ const props = o.info?.props ?? {};
25+ if (props['media.class'] === 'Audio/Source' && props['node.description'] === name) return { node: String(o.id), description: name };
26+ }
27+ return null;
28+}
29+
30+async function readVolume(node: string) {
31+ // "Volume: 0.28" or "Volume: 0.28 [MUTED]"
32+ const { stdout } = await run('wpctl', ['get-volume', node]);
33+ const m = stdout.match(/Volume:\s*([\d.]+)/);
34+ if (!m) throw new Error(`Unexpected output from wpctl: ${stdout.trim()}`);
35+ return { volume: parseFloat(m[1]), muted: stdout.includes('[MUTED]') };
36+}
37+
38+export async function getMicGain(label: string): Promise<MicGain> {
39+ if (!hasWpctl()) return { available: false, reason: 'wpctl (PipeWire) was not found' };
40+ const target = await findNode(label);
41+ if (!target) return { available: false, reason: `No PipeWire source is named "${label}"` };
42+ return { available: true, ...target, ...(await readVolume(target.node)) };
43+}
44+
45+export async function setMicGain(label: string, volume: number): Promise<MicGain> {
46+ if (!Number.isFinite(volume)) throw new HttpError(400, 'Invalid volume');
47+ if (!hasWpctl()) throw new HttpError(400, 'wpctl (PipeWire) was not found');
48+ const target = await findNode(label);
49+ if (!target) throw new HttpError(404, `No PipeWire source is named "${label}"`);
50+ const v = Math.max(0, Math.min(MAX_MIC_GAIN, volume));
51+ await run('wpctl', ['set-volume', target.node, v.toFixed(3)]);
52+ return { available: true, ...target, ...(await readVolume(target.node)) };
53+}
shared/types.tsmodified+12−0View file
@@ -199,3 +199,15 @@ export function exportSignature(v: Pick<Video, 'timeline' | 'layout' | 'output'
199199 function round3(x: number) {
200200 return Math.round(x * 1000) / 1000;
201201 }
202+
203+// Input gain of a microphone (see server/micgain.ts).
204+export interface MicGain {
205+ available: boolean; // false without wpctl, or when the microphone is not a PipeWire source
206+ reason?: string;
207+ node?: string; // wpctl target: a node id, or @DEFAULT_AUDIO_SOURCE@
208+ description?: string;
209+ volume?: number; // wpctl scale: 1.0 = 100%; a factor k is about 60·log10(k) dB
210+ muted?: boolean;
211+}
212+
213+export const MAX_MIC_GAIN = 1.5;
src/api.tsmodified+4−1View file
@@ -1,4 +1,4 @@
1-import type { ExportJob, Peaks, PublicConfig, Take, TrackKind, Transcript, Video, VideoSummary } from '../shared/types.ts';
1+import type { ExportJob, MicGain, Peaks, PublicConfig, Take, TrackKind, Transcript, Video, VideoSummary } from '../shared/types.ts';
22
33 async function request<T>(method: string, url: string, body?: unknown): Promise<T> {
44 const headers: Record<string, string> = {};
@@ -35,6 +35,9 @@ export const api = {
3535 config: () => request<PublicConfig>('GET', '/api/config'),
3636 saveConfig: (patch: Record<string, unknown>) => request<{ ok: true }>('PUT', '/api/config', patch),
3737
38+ micGain: (label: string) => request<MicGain>('GET', `/api/mic-gain?label=${encodeURIComponent(label)}`),
39+ setMicGain: (label: string, volume: number) => request<MicGain>('PUT', '/api/mic-gain', { label, volume }),
40+
3841 videos: () => request<VideoSummary[]>('GET', '/api/videos'),
3942 setOrder: (order: string[]) => request<{ ok: true }>('PUT', '/api/order', { order }),
4043 createVideo: (input: { title: string; description: string; notes: string }) => request<Video>('POST', '/api/videos', input),
src/components/MicMeter.tsxmodified+201−11View file
@@ -1,26 +1,102 @@
1-import { useEffect, useRef } from 'react';
1+import { useEffect, useRef, useState } from 'react';
2+import { MAX_MIC_GAIN, type MicGain } from '../../shared/types.ts';
3+import { api } from '../api.ts';
24
3-// Live input level of a microphone stream.
4-export function MicMeter({ stream }: { stream: MediaStream | null }) {
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.
12+
13+const FLOOR_DB = -60;
14+const TARGET_LOW = -12;
15+const TARGET_HIGH = -6;
16+const TARGET = -9;
17+const CLIP = 0.99;
18+const CHECK_SECONDS = 6;
19+const HOLD_MS = 1500;
20+
21+const toDb = (x: number) => (x > 0 ? 20 * Math.log10(x) : -Infinity);
22+const pos = (db: number) => `${Math.max(0, Math.min(1, (db - FLOOR_DB) / -FLOOR_DB)) * 100}%`;
23+const fmtDb = (db: number) => (db === -Infinity || db < FLOOR_DB ? `< ${FLOOR_DB} dB` : `${Math.round(db)} dB`);
24+
25+interface Block {
26+ peak: number;
27+ rms: number;
28+}
29+
30+interface 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+}
36+
37+function 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+}
49+
50+export function MicMeter({ stream, autoGain, disabled }: { stream: MediaStream | null; autoGain: boolean; disabled: boolean }) {
551 const barRef = useRef<HTMLDivElement>(null);
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 ?? '';
63+
664 useEffect(() => {
765 if (!stream || stream.getAudioTracks().length === 0) return;
866 const ctx = new AudioContext();
967 const src = ctx.createMediaStreamSource(stream);
1068 const analyser = ctx.createAnalyser();
11- analyser.fftSize = 1024;
69+ // 2048 samples (43 ms at 48 kHz) per read, so that reads at the display rate see every sample.
70+ analyser.fftSize = 2048;
1271 src.connect(analyser);
1372 const buf = new Float32Array(analyser.fftSize);
1473 let raf = 0;
1574 let level = 0;
75+ let hold = 0;
76+ let holdUntil = 0;
77+ let shown = 0;
1678 const loop = () => {
1779 analyser.getFloatTimeDomainData(buf);
1880 let peak = 0;
19- for (const x of buf) peak = Math.max(peak, Math.abs(x));
20- level = Math.max(peak, level * 0.9);
21- if (barRef.current) {
22- barRef.current.style.width = `${Math.min(100, Math.sqrt(level) * 100)}%`;
23- barRef.current.classList.toggle('hot', level > 0.9);
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));
24100 }
25101 raf = requestAnimationFrame(loop);
26102 };
@@ -30,9 +106,123 @@ export function MicMeter({ stream }: { stream: MediaStream | null }) {
30106 ctx.close();
31107 };
32108 }, [stream]);
109+
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]);
117+
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+ };
126+
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+ };
133+
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+ };
146+
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;
152+
33153 return (
34- <div className="meter" title="Microphone level">
35- <div className="meter-bar" ref={barRef} />
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>
163+
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>}
184+
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>
191+
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>}
36226 </div>
37227 );
38228 }
src/pages/Recorder.tsxmodified+11−5View file
@@ -14,7 +14,8 @@ interface Prefs {
1414 cameraHeight: 720 | 1080;
1515 useMic: boolean;
1616 micId: string;
17- audioProcessing: boolean;
17+ audioProcessing: boolean; // echo cancellation and noise suppression
18+ autoGain: boolean;
1819 useScreen: boolean;
1920 useSystemAudio: boolean;
2021 countdown: number;
@@ -77,6 +78,7 @@ export function Recorder({ videoId }: { videoId: string }) {
7778 useMic: true,
7879 micId: '',
7980 audioProcessing: true,
81+ autoGain: false,
8082 useScreen: true,
8183 useSystemAudio: false,
8284 countdown: cfg?.defaultCountdown ?? 3,
@@ -134,7 +136,7 @@ export function Recorder({ videoId }: { videoId: string }) {
134136 const proc = prefs.audioProcessing;
135137 navigator.mediaDevices
136138 .getUserMedia({
137- audio: { deviceId: prefs.micId ? { exact: prefs.micId } : undefined, echoCancellation: proc, noiseSuppression: proc, autoGainControl: proc },
139+ audio: { deviceId: prefs.micId ? { exact: prefs.micId } : undefined, echoCancellation: proc, noiseSuppression: proc, autoGainControl: prefs.autoGain },
138140 })
139141 .then((s) => {
140142 if (cancelled) return stopStream(s);
@@ -148,7 +150,7 @@ export function Recorder({ videoId }: { videoId: string }) {
148150 stopStream(stream);
149151 setMicStream(null);
150152 };
151- }, [prefs?.useMic, prefs?.micId, prefs?.audioProcessing]);
153+ }, [prefs?.useMic, prefs?.micId, prefs?.audioProcessing, prefs?.autoGain]);
152154
153155 // Screen stream is stopped when leaving the page.
154156 const screenRef = useRef<MediaStream | null>(null);
@@ -398,13 +400,17 @@ export function Recorder({ videoId }: { videoId: string }) {
398400 </option>
399401 ))}
400402 </select>
401- <label className="check small" title="Echo cancellation, noise suppression, and automatic gain">
403+ <label className="check small" title="Echo cancellation and noise suppression">
402404 <input type="checkbox" checked={prefs.audioProcessing} disabled={locked} onChange={(e) => setPrefs({ audioProcessing: e.target.checked })} />
403405 Noise suppression
404406 </label>
407+ <label className="check small" title="Let the browser adjust the input level while you speak, instead of a fixed gain">
408+ <input type="checkbox" checked={prefs.autoGain} disabled={locked} onChange={(e) => setPrefs({ autoGain: e.target.checked })} />
409+ Automatic gain
410+ </label>
405411 </div>
406412 )}
407- {useMic && <MicMeter stream={micStream} />}
413+ {useMic && <MicMeter stream={micStream} autoGain={prefs.autoGain} disabled={locked} />}
408414 </div>
409415
410416 <div className="source">
src/styles.cssmodified+55−3View file
@@ -976,15 +976,37 @@ textarea.notes {
976976 .source-opts select {
977977 max-width: 340px;
978978 }
979-.meter {
979+.mic-tools {
980980 margin-left: 24px;
981- height: 6px;
981+ display: flex;
982+ flex-direction: column;
983+ gap: 6px;
984+ max-width: 420px;
985+}
986+.mic-tools p {
987+ margin: 0;
988+}
989+.meter-row {
990+ display: flex;
991+ align-items: center;
992+ gap: 10px;
993+}
994+.meter {
995+ position: relative;
996+ flex: 1;
997+ height: 8px;
982998 background: var(--bg);
983999 border-radius: 3px;
9841000 overflow: hidden;
985- max-width: 340px;
1001+}
1002+.meter-zone {
1003+ position: absolute;
1004+ top: 0;
1005+ bottom: 0;
1006+ background: rgba(63, 182, 122, 0.25);
9861007 }
9871008 .meter-bar {
1009+ position: relative;
9881010 height: 100%;
9891011 width: 0;
9901012 background: var(--green);
@@ -993,6 +1015,36 @@ textarea.notes {
9931015 .meter-bar.hot {
9941016 background: var(--red);
9951017 }
1018+.meter-hold {
1019+ position: absolute;
1020+ top: 0;
1021+ bottom: 0;
1022+ width: 2px;
1023+ margin-left: -1px;
1024+ background: var(--text);
1025+}
1026+.meter-readout {
1027+ font-size: 12px;
1028+ color: var(--muted);
1029+ min-width: 90px;
1030+ font-variant-numeric: tabular-nums;
1031+}
1032+.gain-row input[type='range'] {
1033+ flex: 1;
1034+}
1035+.gain-value {
1036+ min-width: 40px;
1037+ text-align: right;
1038+ font-variant-numeric: tabular-nums;
1039+}
1040+.check-result {
1041+ display: flex;
1042+ flex-direction: column;
1043+ gap: 4px;
1044+}
1045+.ok-text {
1046+ color: var(--green);
1047+}
9961048 .previews {
9971049 display: grid;
9981050 grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));