use std::{
cell::{Cell, RefCell},
rc::Rc,
sync::mpsc::{self, RecvTimeoutError},
time::Duration,
};
use pipewire as pw;
use thiserror::Error as ThisError;
const ENUMERATION_TIMEOUT: Duration = Duration::from_secs(5);
const ENUMERATION_SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(500);
#[derive(Debug, ThisError)]
pub enum PipeWireDeviceError {
#[error("pipewire error: {0}")]
PipeWire(String),
#[error("timed out enumerating PipeWire audio nodes")]
EnumerationTimeout,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PipeWireAudioDeviceKind {
Sink,
Source,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PipeWireAudioDevice {
pub id: u32,
pub name: String,
pub description: String,
pub kind: PipeWireAudioDeviceKind,
pub is_default: bool,
}
#[derive(Default)]
struct DefaultNames {
sink: Option<String>,
source: Option<String>,
}
pub(crate) fn list_devices() -> std::result::Result<Vec<PipeWireAudioDevice>, PipeWireDeviceError> {
let (tx, rx) = mpsc::channel();
let (exit_tx, exit_rx) = mpsc::channel();
let (quit_tx, quit_rx) = pw::channel::channel::<Quit>();
let worker = std::thread::Builder::new()
.name("pipewire-enumerate".into())
.spawn(move || {
let _ = tx.send(enumerate_nodes(quit_rx));
let _ = exit_tx.send(());
})
.map_err(|e| PipeWireDeviceError::PipeWire(e.to_string()))?;
await_enumeration(
rx,
exit_rx,
worker,
ENUMERATION_TIMEOUT,
ENUMERATION_SHUTDOWN_TIMEOUT,
move || {
let _ = quit_tx.send(Quit);
},
)
}
fn await_enumeration(
rx: mpsc::Receiver<std::result::Result<Vec<PipeWireAudioDevice>, PipeWireDeviceError>>,
exit: mpsc::Receiver<()>,
worker: std::thread::JoinHandle<()>,
timeout: Duration,
shutdown_timeout: Duration,
stop: impl FnOnce(),
) -> std::result::Result<Vec<PipeWireAudioDevice>, PipeWireDeviceError> {
match rx.recv_timeout(timeout) {
Ok(devices) => {
let _ = worker.join();
devices
}
Err(RecvTimeoutError::Timeout) => {
stop();
if exit.recv_timeout(shutdown_timeout).is_ok() {
let _ = worker.join();
}
Err(PipeWireDeviceError::EnumerationTimeout)
}
Err(RecvTimeoutError::Disconnected) => {
let _ = worker.join();
Err(PipeWireDeviceError::PipeWire(
"the enumeration thread exited without a result".into(),
))
}
}
}
pub(crate) struct Quit;
pub(crate) fn enumerate_nodes(
quit: pw::channel::Receiver<Quit>,
) -> std::result::Result<Vec<PipeWireAudioDevice>, PipeWireDeviceError> {
fn pw_err(error: impl std::fmt::Display) -> PipeWireDeviceError {
PipeWireDeviceError::PipeWire(error.to_string())
}
pw::init();
let mainloop = pw::main_loop::MainLoopRc::new(None).map_err(pw_err)?;
let context = pw::context::ContextRc::new(&mainloop, None).map_err(pw_err)?;
let core = context.connect_rc(None).map_err(pw_err)?;
let registry = core.get_registry_rc().map_err(pw_err)?;
let quit_loop = mainloop.clone();
let _quit = quit.attach(mainloop.loop_(), move |_| quit_loop.quit());
let nodes = Rc::new(RefCell::new(Vec::new()));
let defaults = Rc::new(RefCell::new(DefaultNames::default()));
let metadata = Rc::new(RefCell::new(Vec::<(
pw::metadata::MetadataListener,
pw::metadata::Metadata,
)>::new()));
let _reg_listener = {
let nodes = nodes.clone();
let defaults = defaults.clone();
let metadata = metadata.clone();
let registry_for_bind = registry.clone();
registry
.add_listener_local()
.global(move |global| {
let Some(props) = global.props else { return };
match global.type_ {
pw::types::ObjectType::Node => {
let kind = match props.get("media.class").unwrap_or_default() {
"Audio/Sink" => PipeWireAudioDeviceKind::Sink,
"Audio/Source" => PipeWireAudioDeviceKind::Source,
_ => return,
};
let name = props.get("node.name").unwrap_or_default().to_owned();
let description = props
.get("node.description")
.or_else(|| props.get("node.nick"))
.filter(|d| !d.is_empty())
.unwrap_or(&name)
.to_owned();
nodes.borrow_mut().push(PipeWireAudioDevice {
id: global.id,
name,
description,
kind,
is_default: false,
});
}
pw::types::ObjectType::Metadata
if props.get("metadata.name") == Some("default") =>
{
let Ok(proxy) = registry_for_bind.bind::<pw::metadata::Metadata, _>(global)
else {
return;
};
let listener = proxy
.add_listener_local()
.property({
let defaults = defaults.clone();
move |_, key, _, value| {
let mut defaults = defaults.borrow_mut();
match key {
Some("default.audio.sink") => {
defaults.sink = value.and_then(default_node_name)
}
Some("default.audio.source") => {
defaults.source = value.and_then(default_node_name)
}
_ => {}
}
0
}
})
.register();
metadata.borrow_mut().push((listener, proxy));
}
_ => {}
}
})
.register()
};
let done = Rc::new(Cell::new(false));
let first_pending = core.sync(0).map_err(pw_err)?;
let second_pending = Rc::new(RefCell::new(None));
let sync_error = Rc::new(RefCell::new(None));
let _core_listener = {
let mainloop = mainloop.clone();
let done = done.clone();
let core_for_sync = core.clone();
let second_pending = second_pending.clone();
let sync_error = sync_error.clone();
core.add_listener_local()
.done(move |id, seq| {
if id != pw::core::PW_ID_CORE {
return;
}
if seq == first_pending {
match core_for_sync.sync(0) {
Ok(seq) => *second_pending.borrow_mut() = Some(seq),
Err(error) => {
*sync_error.borrow_mut() = Some(error.to_string());
mainloop.quit();
}
}
} else if second_pending.borrow().as_ref() == Some(&seq) {
done.set(true);
mainloop.quit();
}
})
.register()
};
mainloop.run();
if let Some(error) = sync_error.borrow_mut().take() {
return Err(PipeWireDeviceError::PipeWire(error));
}
if !done.get() {
return Err(PipeWireDeviceError::EnumerationTimeout);
}
let mut nodes = Rc::try_unwrap(nodes)
.map(RefCell::into_inner)
.unwrap_or_else(|shared| shared.borrow().clone());
mark_defaults(&mut nodes, &defaults.borrow());
nodes.sort_by_key(|node| (node.kind == PipeWireAudioDeviceKind::Sink, node.id));
Ok(nodes)
}
fn default_node_name(value: &str) -> Option<String> {
let (_, value) = value.split_once("\"name\"")?;
let value = value.trim_start().strip_prefix(':')?.trim_start();
let value = value.strip_prefix('"')?;
let end = value.find('"')?;
(!value[..end].contains('\\')).then(|| value[..end].to_owned())
}
fn mark_defaults(nodes: &mut [PipeWireAudioDevice], defaults: &DefaultNames) {
for node in nodes {
node.is_default = match node.kind {
PipeWireAudioDeviceKind::Sink => defaults.sink.as_deref() == Some(&node.name),
PipeWireAudioDeviceKind::Source => defaults.source.as_deref() == Some(&node.name),
};
}
}
#[cfg(test)]
mod tests {
use std::time::Instant;
use super::*;
#[test]
fn enumeration_gives_up_at_its_timeout_rather_than_on_the_thread() {
let (_tx, rx) = mpsc::channel();
let (exit_tx, exit_rx) = mpsc::channel();
let (quit_tx, quit_rx) = mpsc::channel();
let (stopped_tx, stopped_rx) = mpsc::channel();
let worker = std::thread::spawn(move || {
let _ = quit_rx.recv();
let _ = exit_tx.send(());
});
let started = Instant::now();
let result = await_enumeration(
rx,
exit_rx,
worker,
Duration::from_millis(150),
Duration::from_millis(150),
move || {
let _ = stopped_tx.send(());
let _ = quit_tx.send(());
},
);
assert!(matches!(
result,
Err(PipeWireDeviceError::EnumerationTimeout)
));
assert!(
started.elapsed() < Duration::from_secs(2),
"the caller waited {:?}, past the timeout it asked for",
started.elapsed()
);
assert!(
stopped_rx.try_recv().is_ok(),
"the loop must be told to stop, or it holds its connection open \
for the life of the process"
);
}
#[test]
fn a_worker_that_ignores_quit_does_not_extend_the_cleanup_deadline() {
let (result_tx, result_rx) = mpsc::channel();
let (exit_tx, exit_rx) = mpsc::channel();
let (release_tx, release_rx) = mpsc::channel();
let (finished_tx, finished_rx) = mpsc::channel();
let worker = std::thread::spawn(move || {
let _result_tx = result_tx;
let _ = release_rx.recv();
let _ = exit_tx.send(());
let _ = finished_tx.send(());
});
let started = Instant::now();
let result = await_enumeration(
result_rx,
exit_rx,
worker,
Duration::from_millis(50),
Duration::from_millis(50),
|| {},
);
assert!(matches!(
result,
Err(PipeWireDeviceError::EnumerationTimeout)
));
assert!(
started.elapsed() < Duration::from_secs(1),
"an unresponsive worker must be detached at the cleanup deadline"
);
release_tx.send(()).expect("the worker is still waiting");
finished_rx
.recv_timeout(Duration::from_secs(1))
.expect("the detached worker can be released after the assertion");
}
#[test]
fn a_finished_enumeration_is_collected_and_joined() {
let (tx, rx) = mpsc::channel();
let (exit_tx, exit_rx) = mpsc::channel();
let worker = std::thread::spawn(move || {
let _ = tx.send(Ok(vec![PipeWireAudioDevice {
id: 1,
name: "node".into(),
description: "Node".into(),
kind: PipeWireAudioDeviceKind::Sink,
is_default: true,
}]));
let _ = exit_tx.send(());
});
let devices = await_enumeration(
rx,
exit_rx,
worker,
Duration::from_secs(5),
Duration::from_millis(150),
|| panic!("a result that arrived in time must not stop the loop early"),
)
.expect("the enumeration succeeded");
assert_eq!(devices.len(), 1);
}
#[test]
fn parses_default_metadata_without_requiring_compact_json() {
assert_eq!(
default_node_name(r#"{ "name" : "alsa_output.pci" }"#).as_deref(),
Some("alsa_output.pci")
);
assert_eq!(default_node_name(r#"{"id": 12}"#), None);
assert_eq!(default_node_name(r#"{"name":"bad\\\"name"}"#), None);
}
#[test]
fn marks_defaults_only_in_the_matching_direction() {
let mut nodes = vec![
PipeWireAudioDevice {
id: 1,
name: "same-name".into(),
description: "Sink".into(),
kind: PipeWireAudioDeviceKind::Sink,
is_default: false,
},
PipeWireAudioDevice {
id: 2,
name: "same-name".into(),
description: "Source".into(),
kind: PipeWireAudioDeviceKind::Source,
is_default: true,
},
];
mark_defaults(
&mut nodes,
&DefaultNames {
sink: Some("same-name".into()),
source: None,
},
);
assert!(nodes[0].is_default);
assert!(!nodes[1].is_default);
}
}