Skip to main content

media/
microphone.rs

1//! Microphone device enumeration (M-MIC.0 / AUT-277) — CLI-pipe pattern.
2//!
3//! Spawns `gst-device-monitor-1.0 Audio/Source` and parses its
4//! human-readable text output into a [`Vec<MicrophoneDevice>`].
5//! Direct sister of [`crate::camera`] (M-CAM.1 / AUT-255).
6//!
7//! ```admonish note title="Differences from `camera`"
8//! Two interesting deltas from the camera enumerator:
9//!
10//! - `gst-device-monitor-1.0 Audio/Source` exposes a real
11//!   `is-default = true|false` line in the `properties:` block on
12//!   macOS. That's authoritative — we use it instead of falling back
13//!   to "first device listed." When *no* device carries
14//!   `is-default = true` (e.g. the property is absent on some
15//!   Linux backends) we degrade to the first-listed heuristic so
16//!   the picker still has a reasonable preselection.
17//! - The first `caps` line carries `rate=` and `channels=` for the
18//!   device's preferred native format. Those are parsed into
19//!   [`MicrophoneDevice::sample_rate_hz`] and
20//!   [`MicrophoneDevice::channels`] respectively. Either field
21//!   degrades to `0` ("unknown") if the parser can't find it —
22//!   downstream code (M-MIC.1's capture pipeline) defaults to
23//!   `48 kHz` / `2 channels` when the value is `0`.
24//! ```
25
26use std::process::{Command, Stdio};
27
28use serde::{Deserialize, Serialize};
29
30/// One attached microphone with a stable ID, a human-readable label,
31/// a default-device flag, and the native channel + sample-rate hint
32/// reported by GStreamer.
33///
34/// `id` is derived from the device label via FNV-1a hashing so the
35/// same mic produces the same ID across reboots even when the OS's
36/// underlying device-id string is non-stable (macOS AVFoundation has
37/// a history of doing this). Matches [`crate::camera::stable_id_for`].
38#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
39pub struct MicrophoneDevice {
40    /// Stable identifier — used by M-MIC.2's "last-used mic"
41    /// persistence and by M-MIC.1's `start_mic_capture(mic_id)` IPC.
42    pub id: String,
43    /// Human-readable label (e.g. `"MacBook Pro Microphone"`,
44    /// `"Shure MV7"`, `"AirPods Pro"`).
45    pub label: String,
46    /// `true` when GStreamer flagged this device as the OS-level
47    /// default. Falls back to "first device in the enumeration"
48    /// when no device carries the `is-default = true` property.
49    pub is_default: bool,
50    /// Native channel count from the first reported `caps` line
51    /// (1 = mono, 2 = stereo). `0` means the parser couldn't extract
52    /// the value — downstream capture defaults to 2.
53    pub channels: u8,
54    /// Native sample rate in Hz (typically `48000` or `44100`).
55    /// `0` means the parser couldn't extract the value — downstream
56    /// capture defaults to 48000. GStreamer's `audioresample` will
57    /// convert as needed for the encoder.
58    pub sample_rate_hz: u32,
59    /// Platform-native device identifier from gst's `unique-id`
60    /// property — what the OS-specific gst element actually wants
61    /// to select the device (M-MIC.3 / AUT-284):
62    ///
63    /// - macOS `osxaudiosrc device-uid=…` — e.g.
64    ///   `"AppleUSBAudioEngine:Insta360:Insta360 Link:100000:3"`.
65    /// - Linux `pulsesrc device=…` — e.g.
66    ///   `"alsa_input.pci-0000_00_1f.3.analog-stereo"`.
67    /// - Windows `wasapisrc device=…` — a `{GUID}` string.
68    ///
69    /// Empty when the device didn't expose a `unique-id` (some
70    /// gst plugin / OS combinations) — callers fall back to
71    /// `autoaudiosrc` (OS default) in that case.
72    pub native_id: String,
73}
74
75/// Enumerate every microphone the OS exposes via
76/// `gst-device-monitor-1.0`.
77///
78/// Returns an empty `Vec` (not an error) if the host has no
79/// microphones or the binary isn't on `PATH` — matches the
80/// [`crate::camera::list_cameras`] convention. Integration tests
81/// should runtime-skip when the returned slice is empty.
82#[must_use]
83pub fn list_microphones() -> Vec<MicrophoneDevice> {
84    let path_env = std::env::var("PATH").unwrap_or_else(|_| "<unset>".to_owned());
85    let output = Command::new("gst-device-monitor-1.0")
86        .args(["Audio/Source"])
87        .stdout(Stdio::piped())
88        // Mirror camera.rs: capture stderr so a permission-denied /
89        // no-mic / missing-binary failure isn't silent. GUI-launched
90        // binaries on macOS sometimes have a sanitised PATH and we
91        // couldn't otherwise tell why the Vec came back empty.
92        .stderr(Stdio::piped())
93        .output();
94    match output {
95        Ok(out) if out.status.success() => {
96            let text = String::from_utf8_lossy(&out.stdout);
97            let devices = parse_device_monitor_output(&text);
98            let stderr = String::from_utf8_lossy(&out.stderr);
99            if devices.is_empty() {
100                tracing::warn!(
101                    stdout_bytes = out.stdout.len(),
102                    stderr_bytes = out.stderr.len(),
103                    %path_env,
104                    "list_microphones: gst-device-monitor exited 0 but parser found 0 mics"
105                );
106                if !text.is_empty() {
107                    tracing::warn!(stdout = %text, "raw gst-device-monitor stdout");
108                }
109                if !stderr.is_empty() {
110                    tracing::warn!(stderr = %stderr, "raw gst-device-monitor stderr");
111                }
112            } else {
113                tracing::info!(
114                    count = devices.len(),
115                    labels = ?devices.iter().map(|d| &d.label).collect::<Vec<_>>(),
116                    "list_microphones: gst-device-monitor returned mics"
117                );
118            }
119            devices
120        }
121        Ok(out) => {
122            tracing::warn!(
123                status = ?out.status,
124                stderr = %String::from_utf8_lossy(&out.stderr),
125                %path_env,
126                "list_microphones: gst-device-monitor exited non-zero"
127            );
128            Vec::new()
129        }
130        Err(err) => {
131            tracing::warn!(
132                ?err,
133                %path_env,
134                "list_microphones: failed to spawn gst-device-monitor-1.0 \
135                 (probably missing from PATH for the launched binary)"
136            );
137            Vec::new()
138        }
139    }
140}
141
142/// Locate the [`MicrophoneDevice`] whose stable id matches `id` by
143/// re-probing the OS via [`list_microphones`]. Used by callers (the
144/// app crate's `start_mic_capture`) to resolve the picker's mic id
145/// back to its native gst device-uid on every session start (M-MIC.3
146/// / AUT-284). Returns `None` when the mic was unplugged between
147/// enumeration and start.
148#[must_use]
149pub fn find_by_id(id: &str) -> Option<MicrophoneDevice> {
150    list_microphones().into_iter().find(|m| m.id == id)
151}
152
153/// Pure-Rust parser for `gst-device-monitor-1.0 Audio/Source` text
154/// output. Split out from [`list_microphones`] so the parser is
155/// testable against captured fixtures without needing gst installed.
156///
157/// Resolution rule for [`MicrophoneDevice::is_default`]:
158/// 1. If any device's `properties:` block contains
159///    `is-default = true`, that device alone wins the flag.
160/// 2. Otherwise the first-listed device is marked default — same
161///    fallback shape as [`crate::camera::parse_device_monitor_output`].
162#[must_use]
163pub fn parse_device_monitor_output(text: &str) -> Vec<MicrophoneDevice> {
164    let blocks = split_into_device_blocks(text);
165    let mut parsed: Vec<ParsedDevice> = blocks
166        .into_iter()
167        .filter_map(|block| parse_one_device_block(&block))
168        .collect();
169
170    let any_explicit_default = parsed.iter().any(|d| d.explicit_default);
171    if !any_explicit_default && let Some(first) = parsed.first_mut() {
172        first.explicit_default = true;
173    }
174
175    parsed
176        .into_iter()
177        .map(|p| MicrophoneDevice {
178            id: stable_id_for(&p.label),
179            label: p.label,
180            is_default: p.explicit_default,
181            channels: p.channels,
182            sample_rate_hz: p.sample_rate_hz,
183            native_id: p.native_id,
184        })
185        .collect()
186}
187
188/// Derive a stable ID for a microphone from its human-readable label
189/// using FNV-1a. Deterministic, dependency-free. Mirrors
190/// [`crate::camera::stable_id_for`] but emits a `mic-` prefix so the
191/// two ID spaces never collide.
192#[must_use]
193pub fn stable_id_for(label: &str) -> String {
194    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
195    for byte in label.bytes() {
196        hash ^= u64::from(byte);
197        hash = hash.wrapping_mul(0x100_0000_01b3);
198    }
199    format!("mic-{hash:016x}")
200}
201
202/// Per-device intermediate parse result. `explicit_default` carries
203/// only the `is-default = true` signal at first; the fallback
204/// "first-listed" rule is applied in [`parse_device_monitor_output`]
205/// after every block has been parsed.
206struct ParsedDevice {
207    label: String,
208    explicit_default: bool,
209    channels: u8,
210    sample_rate_hz: u32,
211    native_id: String,
212}
213
214/// Cut the gst output into one `String` per `Device found:` block.
215/// Anything before the first `Device found:` line (the
216/// `Probing devices...` banner) is discarded.
217fn split_into_device_blocks(text: &str) -> Vec<String> {
218    let mut blocks = Vec::new();
219    let mut current: Option<Vec<&str>> = None;
220    for raw_line in text.lines() {
221        if raw_line.trim().starts_with("Device found:") {
222            if let Some(lines) = current.take() {
223                blocks.push(lines.join("\n"));
224            }
225            current = Some(Vec::new());
226            continue;
227        }
228        if let Some(lines) = current.as_mut() {
229            lines.push(raw_line);
230        }
231    }
232    if let Some(lines) = current.take() {
233        blocks.push(lines.join("\n"));
234    }
235    blocks
236}
237
238fn parse_one_device_block(block: &str) -> Option<ParsedDevice> {
239    let mut label: Option<String> = None;
240    let mut explicit_default = false;
241    let mut channels: u8 = 0;
242    let mut sample_rate_hz: u32 = 0;
243    let mut first_caps_line: Option<String> = None;
244    let mut native_id = String::new();
245
246    for raw_line in block.lines() {
247        let line = raw_line.trim();
248
249        if let Some(rest) = line.strip_prefix("name") {
250            let value = rest.trim_start_matches([' ', '\t', ':']).trim();
251            if label.is_none() && !value.is_empty() {
252                label = Some(value.to_string());
253            }
254            continue;
255        }
256
257        if let Some(rest) = line.strip_prefix("caps") {
258            // First caps line wins — it's the device's preferred
259            // native format. Subsequent lines list every supported
260            // permutation and would muddy the picture.
261            if first_caps_line.is_none() {
262                let value = rest.trim_start_matches([' ', '\t', ':']).trim();
263                if !value.is_empty() {
264                    first_caps_line = Some(value.to_string());
265                }
266            }
267            continue;
268        }
269
270        if let Some(value) = parse_property(line, "is-default")
271            && value.eq_ignore_ascii_case("true")
272        {
273            explicit_default = true;
274            continue;
275        }
276
277        // M-MIC.3 / AUT-284 — `unique-id` is the platform-native
278        // device identifier the OS-specific gst element needs.
279        if native_id.is_empty()
280            && let Some(value) = parse_property(line, "unique-id")
281            && !value.is_empty()
282        {
283            native_id = value.to_string();
284        }
285    }
286
287    if let Some(caps_line) = first_caps_line.as_deref() {
288        if let Some(rate) = extract_caps_int_field(caps_line, "rate") {
289            sample_rate_hz = u32::try_from(rate).unwrap_or(0);
290        }
291        if let Some(ch) = extract_caps_int_field(caps_line, "channels") {
292            channels = u8::try_from(ch).unwrap_or(0);
293        }
294    }
295
296    label.map(|label| ParsedDevice {
297        label,
298        explicit_default,
299        channels,
300        sample_rate_hz,
301        native_id,
302    })
303}
304
305/// Match a `properties:`-block line shaped like `key = value` and
306/// return the trimmed value. gst pads with tabs and a single `=`.
307fn parse_property<'a>(line: &'a str, key: &str) -> Option<&'a str> {
308    let rest = line.strip_prefix(key)?;
309    let trimmed = rest.trim_start();
310    let after_eq = trimmed.strip_prefix('=')?;
311    Some(after_eq.trim())
312}
313
314/// Extract an `int`-valued field from a gst caps string. Caps fields
315/// look like `rate=(int)48000` or `rate=48000`; both shapes appear in
316/// the wild depending on the gst version. Continues past non-matching
317/// tokens (`format=F32LE`, `layout=interleaved`, …) and stops as soon
318/// as it finds the first match, so a trailing `channel-mask=0x…` for
319/// the `channels` query never bleeds in (`strip_prefix("channels")`
320/// fails on `channel-mask`).
321fn extract_caps_int_field(caps: &str, field: &str) -> Option<u64> {
322    for token in caps.split(',') {
323        let trimmed = token.trim();
324        let Some(after_key) = trimmed.strip_prefix(field) else {
325            continue;
326        };
327        // Reject prefix-matches like `channel-mask` when searching for
328        // `channels` — the next char must be either `=` or whitespace.
329        let after_key_trimmed = after_key.trim_start();
330        let Some(after_eq) = after_key_trimmed.strip_prefix('=') else {
331            continue;
332        };
333        let value = after_eq.trim();
334        // Strip optional `(int)` / `(string)` type annotation.
335        let value = value.strip_prefix("(int)").unwrap_or(value).trim();
336        // Stop at the first non-digit so trailing field-list cruft
337        // doesn't bleed in.
338        let digits: String = value.chars().take_while(char::is_ascii_digit).collect();
339        if !digits.is_empty() {
340            return digits.parse::<u64>().ok();
341        }
342    }
343    None
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    /// Real macOS `gst-device-monitor-1.0 Audio/Source` output —
351    /// virtual loopback + USB webcam mic + Bluetooth headset. The
352    /// Bluetooth headset (`MOMENTUM 4`) is the OS default *despite*
353    /// being the third listed, so this fixture exercises the
354    /// `is-default = true` branch versus the first-listed fallback.
355    const MACOS_THREE_MICS: &str = "Probing devices...
356
357
358Device found:
359
360\tname  : LoomAudioDevice
361\tclass : Audio/Source
362\tcaps  : audio/x-raw, format=F32LE, layout=interleaved, rate=48000, channels=2, channel-mask=0x0000000000000003
363\t        audio/x-raw, format={ (string)F64LE, (string)S16LE }, layout=interleaved, rate=48000, channels=2, channel-mask=0x0000000000000003
364\tproperties:
365\t\tis-default = false
366\t\tunique-id = com.loom.desktop.audio-device.device
367\tgst-launch-1.0 osxaudiosrc device=97 ! ...
368
369
370Device found:
371
372\tname  : Insta360 Link
373\tclass : Audio/Source
374\tcaps  : audio/x-raw, format=F32LE, layout=interleaved, rate=48000, channels=1
375\tproperties:
376\t\tis-default = false
377\t\tunique-id = AppleUSBAudioEngine:Insta360:Insta360 Link:100000:3
378\tgst-launch-1.0 osxaudiosrc device=103 ! ...
379
380
381Device found:
382
383\tname  : MOMENTUM 4
384\tclass : Audio/Source
385\tcaps  : audio/x-raw, format=F32LE, layout=interleaved, rate=16000, channels=1
386\tproperties:
387\t\tis-default = true
388\t\tunique-id = 80-C3-BA-87-28-6D:input
389\tgst-launch-1.0 osxaudiosrc device=113 ! ...
390";
391
392    /// Synthetic single-mic output — built-in mic, default flag set,
393    /// stereo 48 kHz. Exercises the common-case happy path.
394    const MACOS_BUILTIN_ONLY: &str = "Probing devices...
395
396
397Device found:
398
399\tname  : MacBook Pro Microphone
400\tclass : Audio/Source
401\tcaps  : audio/x-raw, format=F32LE, layout=interleaved, rate=48000, channels=2
402\tproperties:
403\t\tis-default = true
404\t\tunique-id = BuiltInMicrophoneDevice
405\tgst-launch-1.0 osxaudiosrc device=42 ! ...
406";
407
408    /// Synthetic Linux/Pulse-shaped output that *omits* the
409    /// `is-default` property entirely. Exercises the first-listed
410    /// fallback.
411    const PULSE_NO_DEFAULT_PROPERTY: &str = "Probing devices...
412
413
414Device found:
415
416\tname  : Built-in Audio Analog Stereo
417\tclass : Audio/Source
418\tcaps  : audio/x-raw, format=(string)S16LE, layout=(string)interleaved, rate=(int)44100, channels=(int)2
419\tproperties:
420\t\tdevice.api = pulse
421\tgst-launch-1.0 pulsesrc device=alsa_input.pci ! ...
422
423
424Device found:
425
426\tname  : USB Audio Device Mono
427\tclass : Audio/Source
428\tcaps  : audio/x-raw, format=(string)S16LE, layout=(string)interleaved, rate=(int)48000, channels=(int)1
429\tproperties:
430\t\tdevice.api = pulse
431\tgst-launch-1.0 pulsesrc device=alsa_input.usb ! ...
432";
433
434    #[test]
435    fn parser_extracts_three_macos_mics_with_explicit_default() {
436        let mics = parse_device_monitor_output(MACOS_THREE_MICS);
437        assert_eq!(mics.len(), 3, "{mics:#?}");
438
439        assert_eq!(mics[0].label, "LoomAudioDevice");
440        assert!(!mics[0].is_default);
441        assert_eq!(mics[0].channels, 2);
442        assert_eq!(mics[0].sample_rate_hz, 48_000);
443
444        assert_eq!(mics[1].label, "Insta360 Link");
445        assert!(!mics[1].is_default);
446        assert_eq!(mics[1].channels, 1);
447        assert_eq!(mics[1].sample_rate_hz, 48_000);
448
449        // MOMENTUM 4 is the BLUETOOTH device flagged is-default=true
450        // even though it's listed third — proves we use the explicit
451        // signal, not "first in list."
452        assert_eq!(mics[2].label, "MOMENTUM 4");
453        assert!(mics[2].is_default);
454        assert_eq!(mics[2].channels, 1);
455        assert_eq!(mics[2].sample_rate_hz, 16_000);
456
457        // M-MIC.3 / AUT-284 — native_id round-trips from the
458        // `unique-id` property; the worker uses it to route to
459        // `osxaudiosrc device-uid=…` rather than always opening
460        // the OS default.
461        assert_eq!(mics[0].native_id, "com.loom.desktop.audio-device.device");
462        assert_eq!(
463            mics[1].native_id,
464            "AppleUSBAudioEngine:Insta360:Insta360 Link:100000:3"
465        );
466        assert_eq!(mics[2].native_id, "80-C3-BA-87-28-6D:input");
467    }
468
469    #[test]
470    fn parser_extracts_builtin_only() {
471        let mics = parse_device_monitor_output(MACOS_BUILTIN_ONLY);
472        assert_eq!(mics.len(), 1);
473        assert_eq!(mics[0].label, "MacBook Pro Microphone");
474        assert!(mics[0].is_default);
475        assert_eq!(mics[0].channels, 2);
476        assert_eq!(mics[0].sample_rate_hz, 48_000);
477    }
478
479    #[test]
480    fn parser_falls_back_to_first_listed_when_no_explicit_default() {
481        let mics = parse_device_monitor_output(PULSE_NO_DEFAULT_PROPERTY);
482        assert_eq!(mics.len(), 2);
483        assert_eq!(mics[0].label, "Built-in Audio Analog Stereo");
484        assert!(
485            mics[0].is_default,
486            "no device carries is-default=true, so first-listed wins"
487        );
488        assert_eq!(mics[0].channels, 2);
489        assert_eq!(mics[0].sample_rate_hz, 44_100);
490
491        assert_eq!(mics[1].label, "USB Audio Device Mono");
492        assert!(!mics[1].is_default);
493        assert_eq!(mics[1].channels, 1);
494        assert_eq!(mics[1].sample_rate_hz, 48_000);
495    }
496
497    #[test]
498    fn parser_returns_empty_for_no_inputs() {
499        assert!(parse_device_monitor_output("").is_empty());
500        assert!(parse_device_monitor_output("Probing devices...").is_empty());
501        assert!(parse_device_monitor_output("Probing devices...\n\n").is_empty());
502    }
503
504    #[test]
505    fn parser_handles_caps_without_rate_or_channels() {
506        // Hypothetical loose-form caps line that omits both keys.
507        // Should land channels=0, sample_rate_hz=0 (the documented
508        // "unknown" sentinel) rather than crashing.
509        let text = "Device found:
510
511\tname  : Weird Mic
512\tclass : Audio/Source
513\tcaps  : audio/x-raw, format=F32LE
514\tproperties:
515\t\tis-default = true
516";
517        let mics = parse_device_monitor_output(text);
518        assert_eq!(mics.len(), 1);
519        assert_eq!(mics[0].label, "Weird Mic");
520        assert!(mics[0].is_default);
521        assert_eq!(mics[0].channels, 0);
522        assert_eq!(mics[0].sample_rate_hz, 0);
523    }
524
525    #[test]
526    fn extract_caps_int_field_handles_both_typed_and_untyped_int() {
527        assert_eq!(
528            extract_caps_int_field("rate=(int)48000, channels=(int)2", "rate"),
529            Some(48_000)
530        );
531        assert_eq!(
532            extract_caps_int_field("rate=48000, channels=2", "channels"),
533            Some(2)
534        );
535        // channel-mask field shouldn't pollute channels.
536        assert_eq!(
537            extract_caps_int_field("channels=2, channel-mask=0x0000000000000003", "channels"),
538            Some(2)
539        );
540        assert_eq!(extract_caps_int_field("format=F32LE", "rate"), None);
541    }
542
543    #[test]
544    fn stable_id_is_deterministic_per_label() {
545        assert_eq!(
546            stable_id_for("MacBook Pro Microphone"),
547            stable_id_for("MacBook Pro Microphone")
548        );
549        // Different labels → different IDs.
550        assert_ne!(
551            stable_id_for("MacBook Pro Microphone"),
552            stable_id_for("Shure MV7")
553        );
554    }
555
556    #[test]
557    fn stable_id_prefix_is_mic_not_cam() {
558        // M-MIC.0 explicitly uses a `mic-` prefix so a hypothetical
559        // ID collision with a camera (same label, different kind)
560        // can't happen at the IPC layer.
561        assert!(stable_id_for("MacBook Pro Microphone").starts_with("mic-"));
562        assert_ne!(
563            stable_id_for("Some Device"),
564            crate::camera::stable_id_for("Some Device")
565        );
566    }
567
568    #[test]
569    fn microphone_device_serde_round_trip() {
570        let mic = MicrophoneDevice {
571            id: "mic-feedface".into(),
572            label: "Test Mic".into(),
573            is_default: true,
574            channels: 2,
575            sample_rate_hz: 48_000,
576            native_id: "AppleUSBAudioEngine:Foo:Bar:1234:5".into(),
577        };
578        let json = serde_json::to_string(&mic).unwrap();
579        let parsed: MicrophoneDevice = serde_json::from_str(&json).unwrap();
580        assert_eq!(parsed, mic);
581    }
582
583    #[test]
584    fn parser_pulse_block_without_unique_id_yields_empty_native_id() {
585        // M-MIC.3 / AUT-284 — when gst doesn't expose `unique-id`
586        // (some plugin/OS combos), native_id is empty and the
587        // worker falls back to autoaudiosrc rather than passing an
588        // empty `device-uid=` arg that would crash gst-launch.
589        let mics = parse_device_monitor_output(PULSE_NO_DEFAULT_PROPERTY);
590        assert_eq!(mics.len(), 2);
591        assert_eq!(mics[0].native_id, "");
592        assert_eq!(mics[1].native_id, "");
593    }
594
595    #[test]
596    fn types_are_send_and_sync() {
597        fn assert_send_sync<T: Send + Sync>() {}
598        assert_send_sync::<MicrophoneDevice>();
599    }
600}