1use std::process::{Command, Stdio};
27
28use serde::{Deserialize, Serialize};
29
30#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
39pub struct MicrophoneDevice {
40 pub id: String,
43 pub label: String,
46 pub is_default: bool,
50 pub channels: u8,
54 pub sample_rate_hz: u32,
59 pub native_id: String,
73}
74
75#[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 .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#[must_use]
149pub fn find_by_id(id: &str) -> Option<MicrophoneDevice> {
150 list_microphones().into_iter().find(|m| m.id == id)
151}
152
153#[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#[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
202struct ParsedDevice {
207 label: String,
208 explicit_default: bool,
209 channels: u8,
210 sample_rate_hz: u32,
211 native_id: String,
212}
213
214fn 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 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 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
305fn 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
314fn 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 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 let value = value.strip_prefix("(int)").unwrap_or(value).trim();
336 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 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 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 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 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 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 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 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 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 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 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}