The repo never enforced rustfmt, so formatting had drifted broadly. This is a single mechanical `cargo fmt` pass over the whole crate (no behavioral change; lib suite green, 493 passed). Going forward fmt should be enforced (planned CI fmt --check step). Part of the 0.6.1 hygiene pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
187 lines
5.9 KiB
Rust
187 lines
5.9 KiB
Rust
use super::AudioDevice;
|
|
use std::process::Command;
|
|
|
|
pub fn enumerate_audio_devices() -> Vec<AudioDevice> {
|
|
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<AudioDevice>,
|
|
) {
|
|
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<AudioDevice> {
|
|
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());
|
|
}
|
|
}
|