6ec86a8Microphone level meter, input gain, and level check in the recorderJeremy Magland 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.
11import { spawnSync } from 'node:child_process';
12import { MAX_MIC_GAIN, type MicGain } from '../shared/types.ts';
13import { run } from './media.ts';
14import { HttpError } from './store.ts';
16const hasWpctl = () => !spawnSync('wpctl', ['--version']).error;
18// The wpctl target for a browser microphone label.
19async 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}
30async 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}
38export 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}
45export 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}