Files
peerspeak/src/audio/pw_cli.rs
T
molluskandClaude Opus 4.8 2937e5191a
windows-build / windows-build (push) Has been cancelled
cargo-deny / cargo-deny (pull_request) Has been cancelled
windows-build / windows-build (pull_request) Has been cancelled
Windows port Phase 2: cpal device enumeration
Give the Windows device pickers a real device list (Phase 0/1 left pw_cli
returning nothing off-Linux) and generalize enumeration into a
platform-neutral interface.

- audio/mod.rs: move AudioDevice here (neutral home), gate pw_cli to
  cfg(unix), and re-export enumerate_audio_devices per-platform (pw_cli on
  unix, cpal_impl on windows). Also drop a now-stale "no-op stub" doc note.
- cpal_impl.rs: add enumerate_audio_devices() — iterate the cpal host's
  input + output devices into AudioDevice (name == description == the cpal
  friendly name, which is what resolve() matches target_node against, so a
  saved selection round-trips), sorted by description.
- pw_cli.rs: use super::AudioDevice instead of a local copy; parsing +
  tests unchanged.
- app/mod.rs: one-line import change; the device-picker logic is untouched.

Verified: shipped Linux state green (build --locked, clippy, 316/316,
pw_cli parse tests 6/6); the cpal enumerator compiles against real cpal via
the Linux/ALSA toggle. Runtime device listing on Windows is pending a real
host (M2/M3). WASAPI names are less stable than PipeWire node names, so a
saved device may not always round-trip (falls back to default).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 17:28:12 -04:00

162 lines
5.6 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());
}
}