use super::AudioDevice; use std::process::Command; pub fn enumerate_audio_devices() -> Vec { let output = Command::new("pw-cli") .arg("list-objects") .arg("Node") .output(); match output { Ok(out) => parse_pw_nodes(&String::from_utf8_lossy(&out.stdout)), Err(_) => Vec::new(), } } /// Emits the in-progress node as an `AudioDevice` if it's a complete Audio/* /// node, then resets the accumulators for the next block. Non-audio or /// incomplete blocks are dropped (but still reset). fn push_device(name: &mut String, desc: &mut String, class: &mut String, out: &mut Vec) { if !name.is_empty() && class.starts_with("Audio/") { out.push(AudioDevice { name: name.clone(), description: if desc.is_empty() { name.clone() } else { desc.clone() }, is_input: class == "Audio/Source", }); } name.clear(); desc.clear(); class.clear(); } /// Parses the text of `pw-cli list-objects Node` into the audio devices we care /// about. Each object is a block introduced by an `id N, ...` line; within a /// block we collect `node.name` / `node.description` / `media.class`, and a /// device is emitted at the next `id` (and at EOF) when the class is `Audio/*` /// (`Audio/Source` => input). Description falls back to the node name when /// absent. Returned sorted by description for stable UI display. fn parse_pw_nodes(text: &str) -> Vec { let mut devices = Vec::new(); let mut current_name = String::new(); let mut current_desc = String::new(); let mut current_class = String::new(); for line in text.lines() { let line = line.trim(); if line.starts_with("id ") { push_device(&mut current_name, &mut current_desc, &mut current_class, &mut devices); } else if let Some(val) = line.strip_prefix("node.name = \"") { current_name = val.trim_end_matches('"').to_string(); } else if let Some(val) = line.strip_prefix("node.description = \"") { current_desc = val.trim_end_matches('"').to_string(); } else if let Some(val) = line.strip_prefix("media.class = \"") { current_class = val.trim_end_matches('"').to_string(); } } push_device(&mut current_name, &mut current_desc, &mut current_class, &mut devices); devices.sort_by(|a, b| a.description.cmp(&b.description)); devices } #[cfg(test)] mod tests { use super::*; const SAMPLE_NODES: &str = r#" id 33, type PipeWire:Interface:Node/3 node.name = "alsa_output.builtin" node.description = "Built-in Speakers" media.class = "Audio/Sink" id 34, type PipeWire:Interface:Node/3 node.name = "alsa_input.builtin" node.description = "Built-in Mic" media.class = "Audio/Source" id 35, type PipeWire:Interface:Node/3 node.name = "v4l2.cam" media.class = "Video/Source" id 36, type PipeWire:Interface:Node/3 node.name = "bare.sink" media.class = "Audio/Sink" "#; #[test] fn parses_audio_nodes_sorted_by_description() { let devices = parse_pw_nodes(SAMPLE_NODES); // exactly 3 devices (the Video/Source node is dropped) assert_eq!(devices.len(), 3); // order is by description: ["Built-in Mic", "Built-in Speakers", "bare.sink"] // (uppercase "B" sorts before lowercase "b") assert_eq!(devices[0].description, "Built-in Mic"); assert_eq!(devices[1].description, "Built-in Speakers"); assert_eq!(devices[2].description, "bare.sink"); // the mic device equals: assert_eq!( devices[0], AudioDevice { name: "alsa_input.builtin".into(), description: "Built-in Mic".into(), is_input: true, } ); } #[test] fn source_is_input_sink_is_output() { let devices = parse_pw_nodes(SAMPLE_NODES); // Find devices by name or description to verify is_input let mic = devices.iter().find(|d| d.name == "alsa_input.builtin").unwrap(); let speakers = devices.iter().find(|d| d.name == "alsa_output.builtin").unwrap(); let bare = devices.iter().find(|d| d.name == "bare.sink").unwrap(); assert!(mic.is_input); assert!(!speakers.is_input); assert!(!bare.is_input); } #[test] fn description_falls_back_to_node_name() { let devices = parse_pw_nodes(SAMPLE_NODES); let bare = devices.iter().find(|d| d.name == "bare.sink").unwrap(); assert_eq!(bare.description, "bare.sink"); } #[test] fn non_audio_and_empty_inputs_yield_nothing() { assert!(parse_pw_nodes("").is_empty()); let video_sample = r#" id 35, type PipeWire:Interface:Node/3 node.name = "v4l2.cam" media.class = "Video/Source" "#; assert!(parse_pw_nodes(video_sample).is_empty()); } #[test] fn final_block_is_emitted_at_eof() { let single_sample = r#" id 99, type PipeWire:Interface:Node/3 node.name = "single.device" media.class = "Audio/Sink" "#; let devices = parse_pw_nodes(single_sample); assert_eq!(devices.len(), 1); assert_eq!(devices[0].name, "single.device"); } #[test] fn incomplete_block_without_class_is_dropped() { let incomplete_sample = r#" id 100, type PipeWire:Interface:Node/3 node.name = "incomplete.device" node.description = "Incomplete Device" "#; let devices = parse_pw_nodes(incomplete_sample); assert!(devices.is_empty()); } }