// Input gain of a microphone, read and set through PipeWire's wpctl. // // The browser cannot change the input gain of a microphone, but the server runs on the same machine // and can. A microphone is identified by the label the browser gives it, which for PipeWire (through // its PulseAudio layer) is the node description, e.g. "Built-in Audio Analog Stereo"; the browser's // "Default" device is PipeWire's default source. The volume is on wpctl's scale, where 1.0 is 100% // and, as in PulseAudio, a change of a factor k corresponds to about 60·log10(k) dB. For hardware // sources, PipeWire moves the sound card's capture control along with it, and the dB steps are only // approximately those of that scale, so the recorder measures the level again after a change. import { spawnSync } from 'node:child_process'; import { MAX_MIC_GAIN, type MicGain } from '../shared/types.ts'; import { run } from './media.ts'; import { HttpError } from './store.ts'; const hasWpctl = () => !spawnSync('wpctl', ['--version']).error; // The wpctl target for a browser microphone label. async function findNode(label: string): Promise<{ node: string; description: string } | null> { const name = label.replace(/^Default( - )?/, '').trim(); if (!name) return { node: '@DEFAULT_AUDIO_SOURCE@', description: 'Default microphone' }; const { stdout } = await run('pw-dump', []); for (const o of JSON.parse(stdout) as { id: number; info?: { props?: Record } }[]) { const props = o.info?.props ?? {}; if (props['media.class'] === 'Audio/Source' && props['node.description'] === name) return { node: String(o.id), description: name }; } return null; } async function readVolume(node: string) { // "Volume: 0.28" or "Volume: 0.28 [MUTED]" const { stdout } = await run('wpctl', ['get-volume', node]); const m = stdout.match(/Volume:\s*([\d.]+)/); if (!m) throw new Error(`Unexpected output from wpctl: ${stdout.trim()}`); return { volume: parseFloat(m[1]), muted: stdout.includes('[MUTED]') }; } export async function getMicGain(label: string): Promise { if (!hasWpctl()) return { available: false, reason: 'wpctl (PipeWire) was not found' }; const target = await findNode(label); if (!target) return { available: false, reason: `No PipeWire source is named "${label}"` }; return { available: true, ...target, ...(await readVolume(target.node)) }; } export async function setMicGain(label: string, volume: number): Promise { if (!Number.isFinite(volume)) throw new HttpError(400, 'Invalid volume'); if (!hasWpctl()) throw new HttpError(400, 'wpctl (PipeWire) was not found'); const target = await findNode(label); if (!target) throw new HttpError(404, `No PipeWire source is named "${label}"`); const v = Math.max(0, Math.min(MAX_MIC_GAIN, volume)); await run('wpctl', ['set-volume', target.node, v.toFixed(3)]); return { available: true, ...target, ...(await readVolume(target.node)) }; }