#![cfg(target_os = "linux")]
#![warn(missing_docs)]
use std::collections::VecDeque;
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use flexaudio_core::backend::{CaptureBackend, RawSink};
use flexaudio_core::clock::monotonic_now_ns;
use flexaudio_core::types::{DeviceEvent, DeviceInfo, Error, ProcessMode, Result, SourceKind};
use flexaudio_core::{ErrorContext, Event, Operation};
mod discovery;
use discovery::EnumerationFailure;
mod owner;
use owner::{
finish_worker, poll_backend_event, push_backend_event, rollback_worker, BackendEvents,
};
mod watcher_queue;
use watcher_queue::{lock_events, transition_default, WatchEventQueue, WatchEvents};
use pipewire as pw;
use pw::spa;
use pw::{properties::properties, stream::StreamFlags};
use spa::param::format::{MediaSubtype, MediaType};
use spa::param::format_utils;
use spa::pod::Pod;
const NATIVE_RATE: u32 = 48_000;
const NATIVE_CHANNELS: u16 = 2;
const MAX_WATCH_EVENTS: usize = 1024;
const ENUMERATE_DEADLINE_MS: u128 = 2_000;
const NEGOTIATE_DEADLINE_MS: u128 = 2_000;
struct Readiness {
tx: mpsc::Sender<std::result::Result<(), String>>,
sent: std::cell::Cell<bool>,
}
impl Readiness {
fn report_ready(&self) {
if !self.sent.replace(true) {
let _ = self.tx.send(Ok(()));
}
}
fn report_failure(&self, msg: String) {
if !self.sent.replace(true) {
let _ = self.tx.send(Err(msg));
}
}
fn is_reported(&self) -> bool {
self.sent.get()
}
}
fn pw_init_once() {
use std::sync::Once;
static PW_INIT: Once = Once::new();
PW_INIT.call_once(|| {
pw::init();
});
}
mod processes;
pub use processes::list_processes;
pub struct PwSystemBackend {
exclude_self: bool,
exclude_pids: Vec<u32>,
device_id: Option<String>,
running: Arc<AtomicBool>,
stop_tx: Option<pw::channel::Sender<Terminate>>,
handle: Option<JoinHandle<()>>,
shutdown: Option<Result<()>>,
events: BackendEvents,
}
struct Terminate;
impl PwSystemBackend {
pub fn new(exclude_self: bool, device_id: Option<String>) -> Self {
Self {
exclude_self,
exclude_pids: Vec::new(),
device_id,
running: Arc::new(AtomicBool::new(false)),
stop_tx: None,
handle: None,
shutdown: None,
events: Arc::new(Mutex::new(VecDeque::new())),
}
}
pub fn exclude_self(&self) -> bool {
self.exclude_self
}
pub fn with_exclude_pids(mut self, pids: Vec<u32>) -> Self {
self.exclude_pids = pids;
self
}
pub fn exclude_pids(&self) -> &[u32] {
&self.exclude_pids
}
}
impl Default for PwSystemBackend {
fn default() -> Self {
Self::new(false, None)
}
}
impl CaptureBackend for PwSystemBackend {
fn native_format(&self) -> (u32, u16) {
(NATIVE_RATE, NATIVE_CHANNELS)
}
fn start(&mut self, sink: RawSink) -> Result<()> {
if self.running.load(Ordering::SeqCst) {
return Ok(());
}
let excluded =
effective_exclusion(self.exclude_self, &self.exclude_pids, std::process::id());
let fan_in = !excluded.is_empty();
let device_id = self.device_id.clone();
if !fan_in {
if let Some(id) = device_id.as_deref() {
let devs = list_devices()?;
if !devs
.iter()
.any(|device| device.is_loopback && device.id == id)
{
return Err(Error::DeviceNotFound);
}
}
}
let (stop_tx, stop_rx) = pw::channel::channel::<Terminate>();
let (ready_tx, ready_rx) = mpsc::channel::<std::result::Result<(), String>>();
let running = self.running.clone();
running.store(true, Ordering::SeqCst);
let generation_events: BackendEvents = Arc::new(Mutex::new(VecDeque::new()));
let events_for_thread = generation_events.clone();
let handle = thread::Builder::new()
.name(
if fan_in {
"flexaudio-pw-system-excl"
} else {
"flexaudio-pw-system"
}
.into(),
)
.spawn(move || {
if fan_in {
run_pw_process_loop(
PidSelect::Exclude(excluded),
sink,
stop_rx,
&ready_tx,
events_for_thread,
);
} else {
run_pw_loop(device_id, sink, stop_rx, &ready_tx, events_for_thread);
}
})
.map_err(|e| {
running.store(false, Ordering::SeqCst);
Error::Backend(format!("spawn pipewire thread: {e}"))
.with_context(ErrorContext::new(Operation::Start))
})?;
match ready_rx.recv() {
Ok(Ok(())) => {
self.events = generation_events;
self.shutdown = None;
self.stop_tx = Some(stop_tx);
self.handle = Some(handle);
Ok(())
}
Ok(Err(msg)) => {
running.store(false, Ordering::SeqCst);
Err(rollback_worker(
owner::startup_error(&generation_events, msg),
handle,
))
}
Err(_) => {
running.store(false, Ordering::SeqCst);
Err(rollback_worker(
Error::Backend(
"pipewire setup thread terminated before signaling readiness".into(),
)
.with_context(ErrorContext::new(Operation::Start)),
handle,
))
}
}
}
fn stop(&mut self) {
let _ = self.stop_checked();
}
fn stop_checked(&mut self) -> Result<()> {
self.running.store(false, Ordering::SeqCst);
if let Some(tx) = self.stop_tx.take() {
let _ = tx.send(Terminate);
}
finish_worker(&mut self.handle, &mut self.shutdown, &self.events)
}
fn poll_event(&mut self) -> Option<Event> {
poll_backend_event(&self.events)
}
}
impl Drop for PwSystemBackend {
fn drop(&mut self) {
self.stop();
}
}
pub struct PwProcessBackend {
target_pid: u32,
mode: ProcessMode,
running: Arc<AtomicBool>,
stop_tx: Option<pw::channel::Sender<Terminate>>,
handle: Option<JoinHandle<()>>,
shutdown: Option<Result<()>>,
events: BackendEvents,
}
impl PwProcessBackend {
pub fn new(target_pid: u32, mode: ProcessMode) -> Self {
Self {
target_pid,
mode,
running: Arc::new(AtomicBool::new(false)),
stop_tx: None,
handle: None,
shutdown: None,
events: Arc::new(Mutex::new(VecDeque::new())),
}
}
pub fn target_pid(&self) -> u32 {
self.target_pid
}
pub fn mode(&self) -> ProcessMode {
self.mode
}
}
impl CaptureBackend for PwProcessBackend {
fn native_format(&self) -> (u32, u16) {
(NATIVE_RATE, NATIVE_CHANNELS)
}
fn start(&mut self, sink: RawSink) -> Result<()> {
if self.running.load(Ordering::SeqCst) {
return Ok(());
}
let select = match self.mode {
ProcessMode::Include => PidSelect::Include(self.target_pid),
ProcessMode::Exclude => {
PidSelect::Exclude(std::collections::HashSet::from([self.target_pid]))
}
};
let (stop_tx, stop_rx) = pw::channel::channel::<Terminate>();
let (ready_tx, ready_rx) = mpsc::channel::<std::result::Result<(), String>>();
let running = self.running.clone();
running.store(true, Ordering::SeqCst);
let generation_events: BackendEvents = Arc::new(Mutex::new(VecDeque::new()));
let events_for_thread = generation_events.clone();
let handle = thread::Builder::new()
.name("flexaudio-pw-process".into())
.spawn(move || {
run_pw_process_loop(select, sink, stop_rx, &ready_tx, events_for_thread);
})
.map_err(|e| {
running.store(false, Ordering::SeqCst);
Error::Backend(format!("spawn pipewire process thread: {e}"))
.with_context(ErrorContext::new(Operation::Start))
})?;
match ready_rx.recv() {
Ok(Ok(())) => {
self.events = generation_events;
self.shutdown = None;
self.stop_tx = Some(stop_tx);
self.handle = Some(handle);
Ok(())
}
Ok(Err(msg)) => {
running.store(false, Ordering::SeqCst);
Err(rollback_worker(
owner::startup_error(&generation_events, msg),
handle,
))
}
Err(_) => {
running.store(false, Ordering::SeqCst);
Err(rollback_worker(
Error::Backend(
"pipewire process setup thread terminated before signaling readiness"
.into(),
)
.with_context(ErrorContext::new(Operation::Start)),
handle,
))
}
}
}
fn stop(&mut self) {
let _ = self.stop_checked();
}
fn stop_checked(&mut self) -> Result<()> {
self.running.store(false, Ordering::SeqCst);
if let Some(tx) = self.stop_tx.take() {
let _ = tx.send(Terminate);
}
finish_worker(&mut self.handle, &mut self.shutdown, &self.events)
}
fn poll_event(&mut self) -> Option<Event> {
poll_backend_event(&self.events)
}
}
impl Drop for PwProcessBackend {
fn drop(&mut self) {
self.stop();
}
}
fn run_pw_process_loop(
select: PidSelect,
sink: RawSink,
stop_rx: pw::channel::Receiver<Terminate>,
ready_tx: &mpsc::Sender<std::result::Result<(), String>>,
events: BackendEvents,
) {
let (main_loop, _keep) = match setup_pw_process(select, sink, events.clone()) {
Ok(t) => t,
Err(msg) => {
let _ = ready_tx.send(Err(msg));
return;
}
};
let main_loop_for_quit = main_loop.clone();
let _attached = stop_rx.attach(main_loop.loop_(), move |_terminate| {
main_loop_for_quit.quit();
});
if ready_tx.send(Ok(())).is_err() {
return;
}
main_loop.run();
}
type BoundNode = (pw::node::Node, pw::node::NodeListener);
#[allow(clippy::type_complexity)]
struct ProcessKeep {
_stream: pw::stream::StreamRc,
_listener: pw::stream::StreamListener<UserData>,
_registry: pw::registry::RegistryRc,
_registry_listener: pw::registry::Listener,
_links: std::rc::Rc<std::cell::RefCell<std::collections::HashMap<u32, Vec<pw::link::Link>>>>,
_core: pw::core::CoreRc,
_bound_nodes: std::rc::Rc<std::cell::RefCell<std::collections::HashMap<u32, BoundNode>>>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
struct ClientEntry {
pid: Option<u32>,
pulse_proxied: bool,
}
impl ClientEntry {
fn from_props(app_pid: Option<&str>, sec_pid: Option<&str>, api: Option<&str>) -> Self {
Self {
pid: pid_from_props(app_pid, sec_pid),
pulse_proxied: is_pulse_proxied(api),
}
}
}
fn is_pulse_proxied(api: Option<&str>) -> bool {
api == Some("pipewire-pulse")
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
struct NodeEntry {
owning_client_id: Option<u32>,
app_pid: Option<u32>,
app_pid_from_info: bool,
pulse_proxied: bool,
info_seen: bool,
props_seen: bool,
n_output_ports: Option<u32>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct PortEntry {
node_id: u32,
direction: String,
channel: String,
}
fn pair_ports(out_ports: &[(u32, String)], in_ports: &[(u32, String)]) -> Vec<(u32, u32)> {
let mut pairs: Vec<(u32, u32)> = Vec::new();
let mut used_in: Vec<bool> = vec![false; in_ports.len()];
for (out_id, out_ch) in out_ports {
if out_ch.is_empty() {
continue;
}
if let Some(idx) = in_ports
.iter()
.enumerate()
.position(|(i, (_in_id, in_ch))| !used_in[i] && in_ch == out_ch)
{
used_in[idx] = true;
pairs.push((*out_id, in_ports[idx].0));
}
}
if out_ports.len() == 1 {
let (out_id, _out_ch) = &out_ports[0];
for (i, _in_port) in in_ports.iter().enumerate() {
if !used_in[i] {
used_in[i] = true;
pairs.push((*out_id, in_ports[i].0));
}
}
return pairs;
}
let mut paired_out: Vec<u32> = pairs.iter().map(|(o, _)| *o).collect();
for (out_id, _out_ch) in out_ports {
if paired_out.contains(out_id) {
continue;
}
if let Some(idx) = used_in.iter().position(|used| !*used) {
used_in[idx] = true;
paired_out.push(*out_id);
pairs.push((*out_id, in_ports[idx].0));
}
}
pairs
}
fn link_plan_is_complete(
expected_out: Option<u32>, out_ports_len: usize,
in_ports_len: usize,
pairs_len: usize,
capture_channels: usize, ) -> bool {
in_ports_len == capture_channels
&& expected_out.is_some_and(|n| n > 0 && n <= 2 && out_ports_len == n as usize)
&& pairs_len == capture_channels
}
fn resolve_node_pid(
entry: &NodeEntry,
client_pid: &std::collections::HashMap<u32, ClientEntry>,
) -> Option<u32> {
let client = entry.owning_client_id.and_then(|id| client_pid.get(&id));
if node_is_pulse_proxied(entry, client_pid) {
return entry.app_pid.filter(|_| entry.app_pid_from_info);
}
entry
.app_pid
.or_else(|| client.and_then(|client| client.pid))
}
fn node_is_pulse_proxied(
entry: &NodeEntry,
client_pid: &std::collections::HashMap<u32, ClientEntry>,
) -> bool {
entry.pulse_proxied
|| entry
.owning_client_id
.and_then(|id| client_pid.get(&id))
.is_some_and(|client| client.pulse_proxied)
}
fn update_node_info(
entry: &mut NodeEntry,
props_changed: bool,
props: Option<(Option<&str>, Option<&str>)>,
client_pid: &std::collections::HashMap<u32, ClientEntry>,
) -> bool {
let previous = *entry;
entry.info_seen = true;
if props_changed {
entry.props_seen |= props.is_some();
let (app_pid, api) = props.unwrap_or_default();
entry.pulse_proxied |= is_pulse_proxied(api);
let app_pid = pid_from_props(app_pid, None);
entry.app_pid_from_info = app_pid.is_some();
if app_pid.is_some() || node_is_pulse_proxied(entry, client_pid) {
entry.app_pid = app_pid;
}
}
*entry != previous
}
pub(crate) fn pid_from_props(app_process_id: Option<&str>, sec_pid: Option<&str>) -> Option<u32> {
let parse = |s: Option<&str>| s.and_then(|s| s.parse::<u32>().ok()).filter(|p| *p != 0);
parse(app_process_id).or_else(|| parse(sec_pid))
}
fn exclude_decidable(
entry: &NodeEntry,
client_pid: &std::collections::HashMap<u32, ClientEntry>,
) -> bool {
entry.props_seen && resolve_node_pid(entry, client_pid).is_some()
}
fn capture_node_name(key: &str) -> String {
format!("flexaudio-capture-{key}")
}
fn effective_exclusion(
exclude_self: bool,
exclude_pids: &[u32],
self_pid: u32,
) -> std::collections::HashSet<u32> {
let mut excluded: std::collections::HashSet<u32> = exclude_pids.iter().copied().collect();
if exclude_self {
excluded.insert(self_pid);
}
excluded
}
#[derive(Clone, PartialEq, Eq)]
enum PidSelect {
Include(u32),
Exclude(std::collections::HashSet<u32>),
}
impl PidSelect {
fn selects_node(
&self,
entry: &NodeEntry,
client_pid: &std::collections::HashMap<u32, ClientEntry>,
) -> bool {
if matches!(self, Self::Exclude(_)) && !exclude_decidable(entry, client_pid) {
return false;
}
self.selects(resolve_node_pid(entry, client_pid))
}
fn is_subject_pid(&self, pid: u32) -> bool {
match self {
PidSelect::Include(p) => *p == pid,
PidSelect::Exclude(set) => set.contains(&pid),
}
}
fn selects(&self, resolved: Option<u32>) -> bool {
match (self, resolved) {
(PidSelect::Include(p), Some(r)) => *p == r,
(PidSelect::Exclude(set), Some(r)) => !set.contains(&r),
(_, None) => false,
}
}
fn node_key(&self) -> String {
match self {
PidSelect::Include(p) => p.to_string(),
PidSelect::Exclude(set) => format!("excl-{}", set.iter().min().copied().unwrap_or(0)),
}
}
}
#[allow(clippy::type_complexity)]
fn setup_pw_process(
select: PidSelect,
sink: RawSink,
events: BackendEvents,
) -> std::result::Result<(pw::main_loop::MainLoopRc, ProcessKeep), String> {
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::rc::Rc;
pw_init_once();
let main_loop = pw::main_loop::MainLoopRc::new(None)
.map_err(|e| format!("create pipewire main loop failed: {e}"))?;
let context = pw::context::ContextRc::new(&main_loop, None)
.map_err(|e| format!("create pipewire context failed: {e}"))?;
let core = context
.connect_rc(None)
.map_err(|e| format!("connect to pipewire daemon failed (is PipeWire running?): {e}"))?;
let registry = core
.get_registry_rc()
.map_err(|e| format!("get pipewire registry failed: {e}"))?;
let node_name = capture_node_name(&select.node_key());
let props = properties! {
*pw::keys::MEDIA_TYPE => "Audio",
*pw::keys::MEDIA_CATEGORY => "Capture",
*pw::keys::MEDIA_CLASS => "Stream/Input/Audio",
*pw::keys::MEDIA_ROLE => "Music",
*pw::keys::NODE_NAME => node_name.as_str(),
};
let stream = pw::stream::StreamRc::new(core.clone(), "flexaudio-process-capture", props)
.map_err(|e| format!("create pipewire capture stream failed: {e}"))?;
let user_data = UserData {
format: spa::param::audio::AudioInfoRaw::new(),
sink,
scratch: Default::default(),
events: events.clone(),
readiness: None,
};
let listener = add_capture_listener(&stream, user_data, &main_loop)?;
{
let values = build_format_pod_bytes()?;
let pod = Pod::from_bytes(&values)
.ok_or_else(|| "build audio format pod from bytes failed".to_string())?;
let mut params = [pod];
stream
.connect(
spa::utils::Direction::Input,
None,
StreamFlags::MAP_BUFFERS | StreamFlags::RT_PROCESS,
&mut params,
)
.map_err(|e| format!("connect pipewire capture stream failed: {e}"))?;
}
let self_node_id: Rc<Cell<Option<u32>>> = Rc::new(Cell::new(None));
let nodes: Rc<RefCell<HashMap<u32, NodeEntry>>> = Rc::new(RefCell::new(HashMap::new()));
let client_pid: Rc<RefCell<HashMap<u32, ClientEntry>>> = Rc::new(RefCell::new(HashMap::new()));
let target_client_ids: Rc<RefCell<std::collections::HashSet<u32>>> =
Rc::new(RefCell::new(std::collections::HashSet::new()));
let ports: Rc<RefCell<HashMap<u32, PortEntry>>> = Rc::new(RefCell::new(HashMap::new()));
let linked: Rc<RefCell<HashMap<u32, Vec<pw::link::Link>>>> =
Rc::new(RefCell::new(HashMap::new()));
let bound_nodes: Rc<RefCell<HashMap<u32, BoundNode>>> = Rc::new(RefCell::new(HashMap::new()));
#[allow(clippy::too_many_arguments)]
fn try_link(
core: &pw::core::CoreRc,
stream: &pw::stream::StreamRc,
select: &PidSelect,
self_node_id: &Cell<Option<u32>>,
nodes: &RefCell<HashMap<u32, NodeEntry>>,
client_pid: &RefCell<HashMap<u32, ClientEntry>>,
ports: &RefCell<HashMap<u32, PortEntry>>,
linked: &RefCell<HashMap<u32, Vec<pw::link::Link>>>,
events: &BackendEvents,
main_loop: &pw::main_loop::MainLoopRc,
) -> Result<()> {
let result = (|| -> Result<()> {
{
let nodes = nodes.borrow();
let client_pid = client_pid.borrow();
linked.borrow_mut().retain(|id, _| {
nodes
.get(id)
.is_some_and(|entry| select.selects_node(entry, &client_pid))
});
}
let sid = stream.node_id();
if sid != 0 && sid != pw::constants::ID_ANY {
self_node_id.set(Some(sid));
}
let Some(self_nid) = self_node_id.get() else {
return Ok(());
};
let targets: Vec<u32> = {
let nodes = nodes.borrow();
let client_pid = client_pid.borrow();
let linked = linked.borrow();
nodes
.iter()
.filter(|(id, entry)| {
if linked.contains_key(id) {
return false;
}
select.selects_node(entry, &client_pid)
})
.map(|(&id, _)| id)
.collect()
};
if targets.is_empty() {
return Ok(());
}
let in_ports: Vec<(u32, String)> = {
let ports = ports.borrow();
ports
.iter()
.filter(|(_pid, p)| p.node_id == self_nid && p.direction == "in")
.map(|(&pid, p)| (pid, p.channel.clone()))
.collect()
};
if in_ports.is_empty() {
return Ok(());
}
for target_node_id in targets {
let out_ports: Vec<(u32, String)> = {
let ports = ports.borrow();
ports
.iter()
.filter(|(_pid, p)| p.node_id == target_node_id && p.direction == "out")
.map(|(&pid, p)| (pid, p.channel.clone()))
.collect()
};
if out_ports.is_empty() {
continue;
}
let pairs = pair_ports(&out_ports, &in_ports);
let expected_out: Option<u32> = nodes
.borrow()
.get(&target_node_id)
.and_then(|entry| entry.n_output_ports);
if expected_out.is_some_and(|count| count > 2) || out_ports.len() > 2 {
return Err(Error::UnsupportedFormat(
"pipewire input supports at most two channels".into(),
)
.with_context(ErrorContext::new(Operation::Link)));
}
if !link_plan_is_complete(
expected_out,
out_ports.len(),
in_ports.len(),
pairs.len(),
NATIVE_CHANNELS as usize,
) {
continue;
}
let stereo_ports = |ports: &[(u32, String)]| {
ports.len() == 2
&& ports.iter().filter(|(_, channel)| channel == "FL").count() == 1
&& ports.iter().filter(|(_, channel)| channel == "FR").count() == 1
};
if !stereo_ports(&in_ports) || (out_ports.len() == 2 && !stereo_ports(&out_ports)) {
return Err(Error::UnsupportedFormat(
"pipewire channel routing is unknown or unsupported".into(),
)
.with_context(ErrorContext::new(Operation::Link)));
}
let want = pairs.len();
let mut created: Vec<pw::link::Link> = Vec::with_capacity(want);
for (out_port_id, in_port_id) in pairs {
let link_props = properties! {
*pw::keys::LINK_OUTPUT_NODE => target_node_id.to_string(),
*pw::keys::LINK_OUTPUT_PORT => out_port_id.to_string(),
*pw::keys::LINK_INPUT_NODE => self_nid.to_string(),
*pw::keys::LINK_INPUT_PORT => in_port_id.to_string(),
};
let link = core
.create_object::<pw::link::Link>("link-factory", &link_props)
.map_err(|error| {
Error::Backend(format!("pipewire link creation failed: {error}"))
.with_context(ErrorContext::new(Operation::Link))
})?;
created.push(link);
}
linked.borrow_mut().insert(target_node_id, created);
}
Ok(())
})();
if let Err(error) = &result {
linked.borrow_mut().clear();
push_backend_event(
events,
Event::TerminalError {
error: error.clone(),
},
);
main_loop.quit();
}
result
}
let select_for_global = select.clone();
let select_for_remove = select.clone();
let events_for_global = events.clone();
let loop_for_global = main_loop.clone();
let core_for_global = core.clone();
let stream_for_global = stream.clone();
let self_node_for_global = self_node_id.clone();
let nodes_for_global = nodes.clone();
let client_pid_for_global = client_pid.clone();
let target_client_for_global = target_client_ids.clone();
let ports_for_global = ports.clone();
let linked_for_global = linked.clone();
let registry_for_global = registry.clone();
let bound_for_global = bound_nodes.clone();
let events_for_remove = events.clone();
let loop_for_remove = main_loop.clone();
let core_for_remove = core.clone();
let stream_for_remove = stream.clone();
let self_node_for_remove = self_node_id.clone();
let nodes_for_remove = nodes.clone();
let client_pid_for_remove = client_pid.clone();
let target_client_for_remove = target_client_ids.clone();
let ports_for_remove = ports.clone();
let linked_for_remove = linked.clone();
let bound_for_remove = bound_nodes.clone();
let _registry_listener = registry
.add_listener_local()
.global(move |global| {
let _ = catch_unwind(AssertUnwindSafe(|| {
let Some(props) = global.props else {
return;
};
match global.type_ {
pw::types::ObjectType::Client => {
let client = ClientEntry::from_props(
props.get(*pw::keys::APP_PROCESS_ID),
props.get(*pw::keys::SEC_PID),
props.get(*pw::keys::CLIENT_API),
);
client_pid_for_global.borrow_mut().insert(global.id, client);
if client
.pid
.is_some_and(|pid| select_for_global.is_subject_pid(pid))
{
target_client_for_global.borrow_mut().insert(global.id);
}
}
pw::types::ObjectType::Node => {
let media_class = props.get(*pw::keys::MEDIA_CLASS).unwrap_or("");
if media_class != "Stream/Output/Audio" {
return;
}
let owning_client_id = props
.get(*pw::keys::CLIENT_ID)
.and_then(|s| s.parse::<u32>().ok());
let app_pid = pid_from_props(props.get(*pw::keys::APP_PROCESS_ID), None);
nodes_for_global.borrow_mut().insert(
global.id,
NodeEntry {
owning_client_id,
app_pid,
app_pid_from_info: false,
pulse_proxied: is_pulse_proxied(props.get(*pw::keys::CLIENT_API)),
info_seen: false,
props_seen: false,
n_output_ports: None,
},
);
let bound: std::result::Result<pw::node::Node, _> =
registry_for_global.bind(global);
if let Ok(node) = bound {
let node_id = global.id;
let events_for_info = events_for_global.clone();
let loop_for_info = loop_for_global.clone();
let nodes_for_info = nodes_for_global.clone();
let client_pid_for_info = client_pid_for_global.clone();
let ports_for_info = ports_for_global.clone();
let linked_for_info = linked_for_global.clone();
let self_node_for_info = self_node_for_global.clone();
let core_for_info = core_for_global.clone();
let stream_for_info = stream_for_global.clone();
let select_for_info = select.clone(); let listener = node
.add_listener_local()
.info(move |info| {
let _ = catch_unwind(AssertUnwindSafe(|| {
let props_changed = info
.change_mask()
.contains(pw::node::NodeChangeMask::PROPS);
let props = info.props().map(|props| {
(
props.get(*pw::keys::APP_PROCESS_ID),
props.get(*pw::keys::CLIENT_API),
)
});
let n_out = info.n_output_ports();
let update = {
let mut nodes = nodes_for_info.borrow_mut();
let Some(entry) = nodes.get_mut(&node_id) else {
return;
};
let changed = update_node_info(
entry,
props_changed,
props,
&client_pid_for_info.borrow(),
);
let n_out_changed = entry.n_output_ports != Some(n_out);
if n_out_changed {
entry.n_output_ports = Some(n_out);
}
(changed || n_out_changed).then_some(n_out_changed)
};
let Some(n_out_changed) = update else {
return;
};
if n_out_changed {
linked_for_info.borrow_mut().remove(&node_id);
}
let _ = try_link(
&core_for_info,
&stream_for_info,
&select_for_info,
&self_node_for_info,
&nodes_for_info,
&client_pid_for_info,
&ports_for_info,
&linked_for_info,
&events_for_info,
&loop_for_info,
);
}));
})
.register();
bound_for_global
.borrow_mut()
.insert(node_id, (node, listener));
}
}
pw::types::ObjectType::Port => {
let Some(node_id) = props
.get(*pw::keys::NODE_ID)
.and_then(|s| s.parse::<u32>().ok())
else {
return;
};
let direction = props
.get(*pw::keys::PORT_DIRECTION)
.unwrap_or("")
.to_string();
if direction != "out" && direction != "in" {
return;
}
let channel = props
.get(*pw::keys::AUDIO_CHANNEL)
.unwrap_or("")
.to_string();
ports_for_global.borrow_mut().insert(
global.id,
PortEntry {
node_id,
direction,
channel,
},
);
}
_ => return,
}
let _ = try_link(
&core_for_global,
&stream_for_global,
&select_for_global,
&self_node_for_global,
&nodes_for_global,
&client_pid_for_global,
&ports_for_global,
&linked_for_global,
&events_for_global,
&loop_for_global,
);
}));
})
.global_remove(move |id| {
let _ = catch_unwind(AssertUnwindSafe(|| {
let mut relink_needed = false;
let was_linked_node = linked_for_remove.borrow().contains_key(&id);
let was_target_client = target_client_for_remove.borrow().contains(&id);
let was_self_node = self_node_for_remove.get() == Some(id);
let (linked_out_owner, was_self_in_port): (Option<u32>, bool) = {
let ports = ports_for_remove.borrow();
let owner = ports.get(&id).and_then(|p| {
if p.direction == "out"
&& linked_for_remove.borrow().contains_key(&p.node_id)
{
Some(p.node_id)
} else {
None
}
});
let self_in = if let Some(self_nid) = self_node_for_remove.get() {
ports
.get(&id)
.map(|p| p.node_id == self_nid && p.direction == "in")
.unwrap_or(false)
} else {
false
};
(owner, self_in)
};
if was_self_node
|| was_self_in_port
|| (was_target_client && matches!(select_for_remove, PidSelect::Include(_)))
{
linked_for_remove.borrow_mut().clear();
relink_needed = true;
} else {
if was_linked_node {
linked_for_remove.borrow_mut().remove(&id);
relink_needed = true;
}
if let Some(owner) = linked_out_owner {
linked_for_remove.borrow_mut().remove(&owner);
relink_needed = true;
}
}
if was_target_client {
target_client_for_remove.borrow_mut().remove(&id);
}
if was_self_node {
self_node_for_remove.set(None);
}
nodes_for_remove.borrow_mut().remove(&id);
client_pid_for_remove.borrow_mut().remove(&id);
ports_for_remove.borrow_mut().remove(&id);
bound_for_remove.borrow_mut().remove(&id);
if relink_needed {
let _ = try_link(
&core_for_remove,
&stream_for_remove,
&select_for_remove,
&self_node_for_remove,
&nodes_for_remove,
&client_pid_for_remove,
&ports_for_remove,
&linked_for_remove,
&events_for_remove,
&loop_for_remove,
);
}
}));
})
.register();
Ok((
main_loop,
ProcessKeep {
_stream: stream,
_listener: listener,
_registry: registry,
_registry_listener,
_links: linked,
_core: core,
_bound_nodes: bound_nodes,
},
))
}
struct UserData {
format: spa::param::audio::AudioInfoRaw,
sink: RawSink,
scratch: std::rc::Rc<std::cell::RefCell<Vec<f32>>>,
events: BackendEvents,
readiness: Option<std::rc::Rc<Readiness>>,
}
fn add_capture_listener(
stream: &pw::stream::StreamRc,
user_data: UserData,
main_loop: &pw::main_loop::MainLoopRc,
) -> std::result::Result<pw::stream::StreamListener<UserData>, String> {
let scratch = user_data.scratch.clone();
scratch.borrow_mut().reserve(PROC_SCRATCH_CAP);
let diagnostics = user_data.sink.diagnostics();
let loop_for_format = main_loop.clone();
stream
.add_local_listener_with_user_data(user_data)
.param_changed(move |_stream, user_data, id, param| {
let _ = catch_unwind(AssertUnwindSafe(|| {
if id != pw::spa::param::ParamType::Format.as_raw() {
return;
}
let Some(param) = param else {
user_data.format = spa::param::audio::AudioInfoRaw::new();
return;
};
let mut format = spa::param::audio::AudioInfoRaw::new();
if format.parse(param).is_ok() && format.channels() > 2 {
push_backend_event(
&user_data.events,
Event::TerminalError {
error: Error::UnsupportedFormat(
"pipewire input supports at most two channels".into(),
)
.with_context(ErrorContext::new(Operation::Start)),
},
);
if let Some(readiness) = &user_data.readiness {
readiness.report_failure("unsupported pipewire channel count".into());
}
loop_for_format.quit();
return;
}
let mut format = spa::param::audio::AudioInfoRaw::new();
let accepted = matches!(
format_utils::parse_format(param),
Ok((MediaType::Audio, MediaSubtype::Raw))
) && format.parse(param).is_ok()
&& format.format() == spa::param::audio::AudioFormat::F32LE
&& format.channels() != 0
&& format.channels() == u32::from(user_data.sink.native_channels())
&& format.rate() != 0;
if !accepted {
if let Some(readiness) = &user_data.readiness {
if !readiness.is_reported() {
readiness.report_failure("unacceptable pipewire capture format".into());
loop_for_format.quit();
}
}
return;
}
user_data.format = format;
if let Some(readiness) = &user_data.readiness {
readiness.report_ready();
}
}));
})
.process(move |stream, user_data| {
let result = catch_unwind(AssertUnwindSafe(|| {
let Some(mut buffer) = stream.dequeue_buffer() else {
return;
};
let datas = buffer.datas_mut();
if datas.is_empty() {
return;
}
let data = &mut datas[0];
let chunk = data.chunk();
let size = chunk.size() as usize;
let offset = chunk.offset() as usize;
let stride = chunk.stride();
if chunk
.flags()
.contains(pw::spa::buffer::ChunkFlags::CORRUPTED)
{
diagnostics.record_corrupt_buffer(None);
return;
}
if size == 0 {
return;
}
let Some(bytes) = data.data() else {
return;
};
if bytes.is_empty() {
return;
}
let offset = offset % bytes.len();
let size = size.min(bytes.len());
let Some(end) = offset.checked_add(size).filter(|end| *end <= bytes.len()) else {
return;
};
let valid = &bytes[offset..end];
let channels = user_data.format.channels() as usize;
if channels == 0 {
return;
}
let frame_bytes = channels * std::mem::size_of::<f32>();
if stride > 0 && (stride as usize) < frame_bytes {
diagnostics.record_malformed_buffer(None);
return;
}
let step = if stride > 0 {
stride as usize
} else {
frame_bytes
};
let n_frames = if valid.len() >= frame_bytes {
1 + (valid.len() - frame_bytes) / step
} else {
0
};
let n_floats = n_frames * channels;
if n_frames == 0 {
return;
}
{
let Ok(mut scratch) = scratch.try_borrow_mut() else {
diagnostics.record_callback_rejected(None);
return;
};
if n_floats > scratch.capacity() {
diagnostics.record_malformed_buffer(None);
return;
}
scratch.clear();
for frame in 0..n_frames {
let base = frame * step;
for i in 0..channels {
let b = base + i * 4;
let v = f32::from_le_bytes([
valid[b],
valid[b + 1],
valid[b + 2],
valid[b + 3],
]);
scratch.push(v);
}
}
user_data.sink.push(&scratch, monotonic_now_ns());
}
}));
if result.is_err() {
diagnostics.record_callback_rejected(None);
}
})
.register()
.map_err(|e| format!("register pipewire stream listener failed: {e}"))
}
fn build_format_pod_bytes() -> std::result::Result<Vec<u8>, String> {
let mut audio_info = spa::param::audio::AudioInfoRaw::new();
audio_info.set_format(spa::param::audio::AudioFormat::F32LE);
audio_info.set_rate(NATIVE_RATE);
audio_info.set_channels(NATIVE_CHANNELS as u32);
let obj = pw::spa::pod::Object {
type_: pw::spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
id: pw::spa::param::ParamType::EnumFormat.as_raw(),
properties: audio_info.into(),
};
let values: Vec<u8> = pw::spa::pod::serialize::PodSerializer::serialize(
std::io::Cursor::new(Vec::new()),
&pw::spa::pod::Value::Object(obj),
)
.map_err(|e| format!("serialize audio format pod failed: {e}"))?
.0
.into_inner();
Ok(values)
}
fn run_pw_loop(
device_id: Option<String>,
sink: RawSink,
stop_rx: pw::channel::Receiver<Terminate>,
ready_tx: &mpsc::Sender<std::result::Result<(), String>>,
events: BackendEvents,
) {
let readiness = std::rc::Rc::new(Readiness {
tx: ready_tx.clone(),
sent: std::cell::Cell::new(false),
});
let (main_loop, _stream, _listener) = match setup_pw(device_id, sink, readiness.clone(), events)
{
Ok(t) => t,
Err(msg) => {
let _ = ready_tx.send(Err(msg));
return;
}
};
let main_loop_for_quit = main_loop.clone();
let _attached = stop_rx.attach(main_loop.loop_(), move |_terminate| {
main_loop_for_quit.quit();
});
let readiness_for_timer = readiness.clone();
let main_loop_for_timeout = main_loop.clone();
let _timer = main_loop.loop_().add_timer(move |_expirations| {
if !readiness_for_timer.is_reported() {
readiness_for_timer.report_failure("pipewire format negotiation timed out".into());
main_loop_for_timeout.quit();
}
});
if let Err(e) = _timer
.update_timer(
Some(std::time::Duration::from_millis(
NEGOTIATE_DEADLINE_MS as u64,
)),
None,
)
.into_result()
{
readiness.report_failure(format!("arm pipewire negotiation deadline failed: {e}"));
main_loop.quit();
return;
}
main_loop.run();
}
#[allow(clippy::type_complexity)]
fn setup_pw(
device_id: Option<String>,
sink: RawSink,
readiness: std::rc::Rc<Readiness>,
events: BackendEvents,
) -> std::result::Result<
(
pw::main_loop::MainLoopRc,
pw::stream::StreamRc,
pw::stream::StreamListener<UserData>,
),
String,
> {
pw_init_once();
let main_loop = pw::main_loop::MainLoopRc::new(None)
.map_err(|e| format!("create pipewire main loop failed: {e}"))?;
let context = pw::context::ContextRc::new(&main_loop, None)
.map_err(|e| format!("create pipewire context failed: {e}"))?;
let core = context
.connect_rc(None)
.map_err(|e| format!("connect to pipewire daemon failed (is PipeWire running?): {e}"))?;
let mut props = properties! {
*pw::keys::MEDIA_TYPE => "Audio",
*pw::keys::MEDIA_CATEGORY => "Capture",
*pw::keys::MEDIA_CLASS => "Stream/Input/Audio",
*pw::keys::MEDIA_ROLE => "Music",
};
props.insert(*pw::keys::STREAM_CAPTURE_SINK, "true");
if let Some(id) = device_id {
props.insert("target.object", id);
}
let stream = pw::stream::StreamRc::new(core, "flexaudio-system-capture", props)
.map_err(|e| format!("create pipewire capture stream failed: {e}"))?;
let user_data = UserData {
format: spa::param::audio::AudioInfoRaw::new(),
sink,
scratch: Default::default(),
events: events.clone(),
readiness: Some(readiness),
};
let listener = add_capture_listener(&stream, user_data, &main_loop)?;
let values = build_format_pod_bytes()?;
let pod = Pod::from_bytes(&values)
.ok_or_else(|| "build audio format pod from bytes failed".to_string())?;
let mut params = [pod];
stream
.connect(
spa::utils::Direction::Input,
None,
StreamFlags::AUTOCONNECT | StreamFlags::MAP_BUFFERS | StreamFlags::RT_PROCESS,
&mut params,
)
.map_err(|e| format!("connect pipewire capture stream failed: {e}"))?;
Ok((main_loop, stream, listener))
}
const PROC_SCRATCH_CAP: usize = (NATIVE_RATE as usize) * (NATIVE_CHANNELS as usize);
struct NodeRecord {
node_name: String,
description: String,
media_class: String,
rate: Option<u32>,
channels: Option<u16>,
}
#[derive(Default)]
struct EnumState {
nodes: Vec<NodeRecord>,
default_sink: Option<String>,
default_source: Option<String>,
}
fn enumeration_properties<'a>(
object_type: &pw::types::ObjectType,
props: Option<&'a spa::utils::dict::DictRef>,
) -> std::result::Result<Option<&'a spa::utils::dict::DictRef>, EnumerationFailure> {
if props.is_none() && *object_type == pw::types::ObjectType::Node {
Err(EnumerationFailure::Identity)
} else {
Ok(props)
}
}
pub fn list_devices() -> Result<Vec<DeviceInfo>> {
enumerate_pw().map_err(|message| {
Error::Backend(message).with_context(ErrorContext::new(Operation::Enumerate))
})
}
fn enumerate_pw() -> std::result::Result<Vec<DeviceInfo>, String> {
use std::cell::RefCell;
use std::rc::Rc;
pw_init_once();
let main_loop = pw::main_loop::MainLoopRc::new(None)
.map_err(|e| format!("create pipewire main loop failed: {e}"))?;
let context = pw::context::ContextRc::new(&main_loop, None)
.map_err(|e| format!("create pipewire context failed: {e}"))?;
let core = context
.connect_rc(None)
.map_err(|e| format!("connect to pipewire daemon failed (is PipeWire running?): {e}"))?;
let registry = core
.get_registry_rc()
.map_err(|e| format!("get pipewire registry failed: {e}"))?;
let failure = Rc::new(std::cell::Cell::new(None::<EnumerationFailure>));
let state = Rc::new(RefCell::new(EnumState::default()));
type MetaKeep = (Box<dyn pw::proxy::ProxyT>, Box<dyn pw::proxy::Listener>);
let meta_keep: Rc<RefCell<Vec<MetaKeep>>> = Rc::new(RefCell::new(Vec::new()));
let failure_for_global = failure.clone();
let state_for_global = state.clone();
let registry_for_global = registry.clone();
let meta_keep_for_global = meta_keep.clone();
let _reg_listener = registry
.add_listener_local()
.global(move |global| {
let result = catch_unwind(AssertUnwindSafe(|| {
let props = match enumeration_properties(&global.type_, global.props) {
Ok(Some(props)) => props,
Ok(None) => return,
Err(failure) => {
failure_for_global.set(Some(failure));
return;
}
};
match global.type_ {
pw::types::ObjectType::Node => {
let media_class = props.get(*pw::keys::MEDIA_CLASS).unwrap_or("");
if media_class != "Audio/Sink" && media_class != "Audio/Source" {
return;
}
let node_name = props.get(*pw::keys::NODE_NAME).unwrap_or("");
if node_name.is_empty() {
failure_for_global.set(Some(EnumerationFailure::Identity));
return;
}
let description = props
.get(*pw::keys::NODE_DESCRIPTION)
.filter(|s| !s.is_empty())
.unwrap_or(node_name);
let rate = props.get("audio.rate").and_then(|s| s.parse::<u32>().ok());
let channels = props
.get(*pw::keys::AUDIO_CHANNELS)
.and_then(|s| s.parse::<u16>().ok());
if props.get("audio.rate").is_some() && rate.is_none_or(|rate| rate == 0)
|| props.get(*pw::keys::AUDIO_CHANNELS).is_some()
&& channels.is_none_or(|channels| channels == 0)
{
failure_for_global.set(Some(EnumerationFailure::Format));
return;
}
state_for_global.borrow_mut().nodes.push(NodeRecord {
node_name: node_name.to_string(),
description: description.to_string(),
media_class: media_class.to_string(),
rate,
channels,
});
}
pw::types::ObjectType::Metadata => {
let meta_name = props.get("metadata.name").unwrap_or("");
if meta_name != "default" {
return;
}
let metadata: pw::metadata::Metadata =
match registry_for_global.bind(global) {
Ok(m) => m,
Err(_) => {
failure_for_global.set(Some(EnumerationFailure::Metadata));
return;
}
};
let failure_for_meta = failure_for_global.clone();
let state_for_meta = state_for_global.clone();
let listener = metadata
.add_listener_local()
.property(move |_subject, key, _type, value| {
if catch_unwind(AssertUnwindSafe(|| {
if let (Some(key), Some(value)) = (key, value) {
if key == "default.audio.sink" {
state_for_meta.borrow_mut().default_sink =
extract_json_name(value);
} else if key == "default.audio.source" {
state_for_meta.borrow_mut().default_source =
extract_json_name(value);
}
}
}))
.is_err()
{
failure_for_meta.set(Some(EnumerationFailure::Callback));
}
0
})
.register();
meta_keep_for_global
.borrow_mut()
.push((Box::new(metadata), Box::new(listener)));
}
_ => {}
}
}));
if result.is_err() {
failure_for_global.set(Some(EnumerationFailure::Callback));
}
})
.register();
let done = Rc::new(std::cell::Cell::new(false));
let aborted = Rc::new(std::cell::Cell::new(false));
let stage = Rc::new(std::cell::Cell::new(0u8));
let pending1 = core
.sync(0)
.map_err(|e| format!("pipewire sync failed: {e}"))?;
let pending1 = Rc::new(std::cell::Cell::new(pending1.seq()));
let failure_for_cb = failure.clone();
let done_for_cb = done.clone();
let aborted_for_cb = aborted.clone();
let stage_for_cb = stage.clone();
let pending1_for_cb = pending1.clone();
let loop_for_cb = main_loop.clone();
let failure_for_core = failure.clone();
let aborted_for_core = aborted.clone();
let loop_for_core = main_loop.clone();
let core_weak = core.downgrade();
let _core_listener = core
.add_listener_local()
.done(move |id, seq| {
if id != pw::core::PW_ID_CORE {
return;
}
let seq = seq.seq();
match stage_for_cb.get() {
0 if seq == pending1_for_cb.get() => {
stage_for_cb.set(1);
let Some(core) = core_weak.upgrade() else {
failure_for_cb.set(Some(EnumerationFailure::CoreGone));
aborted_for_cb.set(true);
loop_for_cb.quit();
return;
};
match core.sync(0) {
Ok(p) => pending1_for_cb.set(p.seq()),
Err(_) => {
failure_for_cb.set(Some(EnumerationFailure::SecondSync));
aborted_for_cb.set(true);
loop_for_cb.quit();
}
}
}
1 if seq == pending1_for_cb.get() => {
done_for_cb.set(true);
loop_for_cb.quit();
}
_ => {}
}
})
.register();
let _error_listener = core
.add_listener_local()
.error(move |_id, _seq, _code, _message| {
failure_for_core.set(Some(EnumerationFailure::CoreError));
aborted_for_core.set(true);
loop_for_core.quit();
})
.register();
let deadline = std::time::Instant::now();
while !done.get() && !aborted.get() {
let elapsed_ms = deadline.elapsed().as_millis();
if elapsed_ms >= ENUMERATE_DEADLINE_MS {
break;
}
let remaining =
std::time::Duration::from_millis((ENUMERATE_DEADLINE_MS - elapsed_ms) as u64);
if main_loop
.loop_()
.iterate(pw::loop_::Timeout::Finite(remaining))
< 0
{
failure.set(Some(EnumerationFailure::Iterate));
aborted.set(true);
}
}
if let Some(failure) = failure.get() {
return Err(failure.message().into());
}
if !done.get() || aborted.get() {
return Err(EnumerationFailure::Deadline.message().into());
}
let state = state.borrow();
let mut out = Vec::with_capacity(state.nodes.len());
for n in &state.nodes {
let is_loopback = n.media_class == "Audio/Sink";
let source_kind = if is_loopback {
SourceKind::SystemLoopback
} else {
SourceKind::Mic
};
let is_default = if is_loopback {
state.default_sink.as_deref() == Some(n.node_name.as_str())
} else {
state.default_source.as_deref() == Some(n.node_name.as_str())
};
out.push(DeviceInfo {
id: n.node_name.clone(),
name: n.description.clone(),
source_kind,
sample_rate: n.rate.unwrap_or(NATIVE_RATE),
channels: n.channels.unwrap_or(NATIVE_CHANNELS),
is_loopback,
is_default,
});
}
Ok(out)
}
fn extract_json_name(value: &str) -> Option<String> {
let after_key = value.split("\"name\"").nth(1)?;
let after_colon = after_key.split(':').nth(1)?;
let start = after_colon.find('"')? + 1;
let rest = &after_colon[start..];
let end = rest.find('"')?;
let name = &rest[..end];
if name.is_empty() {
None
} else {
Some(name.to_string())
}
}
pub struct PwDeviceWatcher {
events: WatchEventQueue,
running: Arc<AtomicBool>,
stop_tx: Option<pw::channel::Sender<Terminate>>,
handle: Option<JoinHandle<()>>,
}
impl PwDeviceWatcher {
pub fn start() -> Result<Self> {
let events: WatchEventQueue = Arc::new(Mutex::new(WatchEvents::default()));
let (stop_tx, stop_rx) = pw::channel::channel::<Terminate>();
let (ready_tx, ready_rx) = mpsc::channel::<std::result::Result<(), String>>();
let running = Arc::new(AtomicBool::new(true));
let events_for_thread = events.clone();
let handle = thread::Builder::new()
.name("flexaudio-pw-watch".into())
.spawn(move || {
run_watch_loop(events_for_thread, stop_rx, &ready_tx);
})
.map_err(|e| Error::Backend(format!("spawn pipewire watch thread: {e}")))?;
match ready_rx.recv() {
Ok(Ok(())) => Ok(Self {
events,
running,
stop_tx: Some(stop_tx),
handle: Some(handle),
}),
Ok(Err(msg)) => {
running.store(false, Ordering::SeqCst);
Err(rollback_worker(
Error::Backend(msg).with_context(ErrorContext::new(Operation::Start)),
handle,
))
}
Err(_) => {
running.store(false, Ordering::SeqCst);
Err(rollback_worker(
Error::Backend(
"pipewire watch thread terminated before signaling readiness".into(),
)
.with_context(ErrorContext::new(Operation::Start)),
handle,
))
}
}
}
pub fn poll_event(&mut self) -> Option<DeviceEvent> {
lock_events(&self.events).poll()
}
pub fn stop(&mut self) {
if !self.running.swap(false, Ordering::SeqCst) {
if let Some(h) = self.handle.take() {
let _ = h.join();
}
self.stop_tx = None;
return;
}
if let Some(tx) = self.stop_tx.take() {
let _ = tx.send(Terminate);
}
if let Some(h) = self.handle.take() {
let _ = h.join();
}
}
}
impl Drop for PwDeviceWatcher {
fn drop(&mut self) {
self.stop();
}
}
#[derive(Default)]
struct WatchState {
by_global_id: std::collections::HashMap<u32, DeviceInfo>,
initial_scan_done: bool,
default_sink: Option<String>,
default_source: Option<String>,
}
fn run_watch_loop(
events: WatchEventQueue,
stop_rx: pw::channel::Receiver<Terminate>,
ready_tx: &mpsc::Sender<std::result::Result<(), String>>,
) {
let (main_loop, _core, _registry, _listeners) = match setup_watch(events) {
Ok(t) => t,
Err(msg) => {
let _ = ready_tx.send(Err(msg));
return;
}
};
let main_loop_for_quit = main_loop.clone();
let _attached = stop_rx.attach(main_loop.loop_(), move |_terminate| {
main_loop_for_quit.quit();
});
if ready_tx.send(Ok(())).is_err() {
return;
}
main_loop.run();
}
#[allow(clippy::type_complexity)]
type WatchKeep = (
pw::main_loop::MainLoopRc,
pw::core::CoreRc,
pw::registry::RegistryRc,
WatchListeners,
);
type MetaKeepEntry = (Box<dyn pw::proxy::ProxyT>, Box<dyn pw::proxy::Listener>);
type MetaKeepStore = std::rc::Rc<std::cell::RefCell<Vec<MetaKeepEntry>>>;
struct WatchListeners {
_registry_listener: pw::registry::Listener,
_core_listener: pw::core::Listener,
_meta_keep: MetaKeepStore,
}
#[allow(clippy::type_complexity)]
fn setup_watch(events: WatchEventQueue) -> std::result::Result<WatchKeep, String> {
use std::cell::{Cell, RefCell};
use std::rc::Rc;
pw_init_once();
let main_loop = pw::main_loop::MainLoopRc::new(None)
.map_err(|e| format!("create pipewire main loop failed: {e}"))?;
let context = pw::context::ContextRc::new(&main_loop, None)
.map_err(|e| format!("create pipewire context failed: {e}"))?;
let core = context
.connect_rc(None)
.map_err(|e| format!("connect to pipewire daemon failed (is PipeWire running?): {e}"))?;
let registry = core
.get_registry_rc()
.map_err(|e| format!("get pipewire registry failed: {e}"))?;
let query_failure = Rc::new(Cell::new(None::<EnumerationFailure>));
let state = Rc::new(RefCell::new(WatchState::default()));
let meta_keep: MetaKeepStore = Rc::new(RefCell::new(Vec::new()));
let failure_for_global = query_failure.clone();
let state_for_global = state.clone();
let events_for_global = events.clone();
let registry_for_global = registry.clone();
let meta_keep_for_global = meta_keep.clone();
let state_for_remove = state.clone();
let events_for_remove = events.clone();
let _registry_listener = registry
.add_listener_local()
.global(move |global| {
let _ = catch_unwind(AssertUnwindSafe(|| {
let Some(props) = global.props else {
return;
};
match global.type_ {
pw::types::ObjectType::Node => {
let media_class = props.get(*pw::keys::MEDIA_CLASS).unwrap_or("");
if media_class != "Audio/Sink" && media_class != "Audio/Source" {
return;
}
let node_name = props.get(*pw::keys::NODE_NAME).unwrap_or("");
if node_name.is_empty() {
failure_for_global.set(Some(EnumerationFailure::Identity));
lock_events(&events_for_global).invalidate();
return;
}
let description = props
.get(*pw::keys::NODE_DESCRIPTION)
.filter(|s| !s.is_empty())
.unwrap_or(node_name);
let rate = props.get("audio.rate").and_then(|s| s.parse::<u32>().ok());
let channels = props
.get(*pw::keys::AUDIO_CHANNELS)
.and_then(|s| s.parse::<u16>().ok());
let is_loopback = media_class == "Audio/Sink";
let source_kind = if is_loopback {
SourceKind::SystemLoopback
} else {
SourceKind::Mic
};
let mut st = state_for_global.borrow_mut();
let is_default = if is_loopback {
st.default_sink.as_deref() == Some(node_name)
} else {
st.default_source.as_deref() == Some(node_name)
};
let info = DeviceInfo {
id: node_name.to_string(),
name: description.to_string(),
source_kind,
sample_rate: rate.unwrap_or(NATIVE_RATE),
channels: channels.unwrap_or(NATIVE_CHANNELS),
is_loopback,
is_default,
};
st.by_global_id.insert(global.id, info.clone());
let initial_scan_done = st.initial_scan_done;
drop(st);
if initial_scan_done {
enqueue_event(&events_for_global, DeviceEvent::Added(info));
}
}
pw::types::ObjectType::Metadata => {
let meta_name = props.get("metadata.name").unwrap_or("");
if meta_name != "default" {
return;
}
let metadata: pw::metadata::Metadata =
match registry_for_global.bind(global) {
Ok(m) => m,
Err(_) => {
failure_for_global.set(Some(EnumerationFailure::Metadata));
lock_events(&events_for_global).invalidate();
return;
}
};
let state_for_meta = state_for_global.clone();
let events_for_meta = events_for_global.clone();
let listener = metadata
.add_listener_local()
.property(move |_subject, key, _type, value| {
catch_unwind(AssertUnwindSafe(|| {
for (property, kind) in [
(
"default.audio.sink",
flexaudio_core::DefaultDeviceKind::SystemAudio,
),
(
"default.audio.source",
flexaudio_core::DefaultDeviceKind::Microphone,
),
] {
if key.is_some_and(|key| key != property) {
continue;
}
let next = key.and(value).and_then(extract_json_name);
let mut state = state_for_meta.borrow_mut();
let publish = state.initial_scan_done;
let previous = if property == "default.audio.sink" {
&mut state.default_sink
} else {
&mut state.default_source
};
let event = transition_default(previous, next, kind);
drop(state);
if publish {
if let Some(event) = event {
enqueue_event(&events_for_meta, event);
}
}
}
}))
.ok();
0
})
.register();
meta_keep_for_global
.borrow_mut()
.push((Box::new(metadata), Box::new(listener)));
}
_ => {}
}
}));
})
.global_remove(move |id| {
let _ = catch_unwind(AssertUnwindSafe(|| {
let removed = state_for_remove.borrow_mut().by_global_id.remove(&id);
if let Some(info) = removed {
enqueue_event(&events_for_remove, DeviceEvent::Removed { id: info.id });
}
}));
})
.register();
let aborted = Rc::new(Cell::new(false));
let stage = Rc::new(Cell::new(0u8));
let pending = core
.sync(0)
.map_err(|e| format!("pipewire sync failed: {e}"))?;
let pending = Rc::new(Cell::new(pending.seq()));
let aborted_for_cb = aborted.clone();
let stage_for_cb = stage.clone();
let pending_for_cb = pending.clone();
let state_for_done = state.clone();
let loop_for_done = main_loop.clone();
let core_weak = core.downgrade();
let _core_listener = core
.add_listener_local()
.done(move |id, seq| {
if id != pw::core::PW_ID_CORE {
return;
}
let seq = seq.seq();
match stage_for_cb.get() {
0 if seq == pending_for_cb.get() => {
stage_for_cb.set(1);
if let Some(core) = core_weak.upgrade() {
match core.sync(0) {
Ok(p) => pending_for_cb.set(p.seq()),
Err(_) => {
aborted_for_cb.set(true);
loop_for_done.quit();
}
}
} else {
aborted_for_cb.set(true);
loop_for_done.quit();
}
}
1 if seq == pending_for_cb.get() => {
stage_for_cb.set(2);
state_for_done.borrow_mut().initial_scan_done = true;
loop_for_done.quit();
}
_ => {}
}
})
.register();
let deadline = std::time::Instant::now();
while !state.borrow().initial_scan_done && !aborted.get() {
let elapsed = deadline.elapsed().as_millis();
if elapsed >= ENUMERATE_DEADLINE_MS {
return Err("pipewire watch initial scan timed out".into());
}
let remaining = std::time::Duration::from_millis((ENUMERATE_DEADLINE_MS - elapsed) as u64);
if main_loop
.loop_()
.iterate(pw::loop_::Timeout::Finite(remaining))
< 0
{
aborted.set(true);
}
}
if aborted.get() {
return Err("pipewire watch initial scan aborted".into());
}
if let Some(failure) = query_failure.get() {
return Err(failure.message().into());
}
Ok((
main_loop,
core,
registry,
WatchListeners {
_registry_listener,
_core_listener,
_meta_keep: meta_keep,
},
))
}
fn enqueue_event(events: &WatchEventQueue, ev: DeviceEvent) {
lock_events(events).push(ev, MAX_WATCH_EVENTS);
}
#[cfg(test)]
mod tests {
use super::*;
use flexaudio_core::raw_ring::raw_ring;
#[test]
fn enumeration_missing_properties_only_fail_for_nodes() {
use pw::types::ObjectType;
for object_type in [
ObjectType::Core,
ObjectType::Module,
ObjectType::Client,
ObjectType::Metadata,
ObjectType::Device,
ObjectType::Port,
ObjectType::Other("unrelated interface".into()),
] {
assert!(matches!(
enumeration_properties(&object_type, None),
Ok(None)
));
}
assert!(matches!(
enumeration_properties(&ObjectType::Node, None),
Err(EnumerationFailure::Identity)
));
let properties = properties! { "node.name" => "fixture.audio" };
let props: &spa::utils::dict::DictRef = properties.as_ref();
assert!(std::ptr::eq(
enumeration_properties(&ObjectType::Node, Some(props))
.unwrap()
.unwrap(),
props,
));
let empty = pw::properties::PropertiesBox::new();
assert!(matches!(
enumeration_properties(&ObjectType::Node, Some(empty.as_ref())),
Ok(Some(_))
));
}
#[test]
fn backend_is_send() {
fn assert_send<T: Send>() {}
assert_send::<PwSystemBackend>();
}
#[test]
fn native_format_is_48k_stereo() {
let be = PwSystemBackend::new(false, None);
assert_eq!(be.native_format(), (NATIVE_RATE, NATIVE_CHANNELS));
assert_eq!(be.native_format(), (48_000, 2));
assert!(!be.exclude_self());
}
#[test]
fn stop_without_start_is_safe() {
let mut be = PwSystemBackend::new(false, None);
be.stop();
be.stop();
}
#[test]
fn system_exclude_self_is_graceful() {
let (prod, _cons) = raw_ring(1 << 16);
let sink = RawSink::new(prod, NATIVE_RATE, NATIVE_CHANNELS);
let mut be = PwSystemBackend::new(true, None);
assert!(be.exclude_self());
match be.start(sink) {
Ok(()) => {
be.stop();
}
Err(error) if error.kind() == flexaudio_core::ErrorKind::Backend => {
}
Err(other) => panic!("unexpected error variant: {other:?}"),
}
}
#[test]
fn extract_json_name_parses_default_metadata_value() {
assert_eq!(
extract_json_name(r#"{"name":"alsa_output.pci-0000_00_1f.3.analog-stereo"}"#)
.as_deref(),
Some("alsa_output.pci-0000_00_1f.3.analog-stereo")
);
assert_eq!(
extract_json_name(r#"{ "name" : "foo.bar" }"#).as_deref(),
Some("foo.bar")
);
assert_eq!(extract_json_name(r#"{"other":"x"}"#), None);
assert_eq!(extract_json_name(r#"{"name":""}"#), None);
assert_eq!(extract_json_name("not json"), None);
}
#[test]
fn list_devices_is_complete_or_backend_error() {
let devices = match list_devices() {
Ok(devices) => devices,
Err(error) => {
assert_eq!(error.kind(), flexaudio_core::ErrorKind::Backend);
assert!(
matches!(error, Error::Context { context, .. } if context.operation() == Operation::Enumerate)
);
return;
}
};
for d in &devices {
assert!(!d.id.is_empty(), "id (=node.name) is nonempty");
match d.source_kind {
SourceKind::SystemLoopback => assert!(d.is_loopback, "Sink is loopback"),
SourceKind::Mic => assert!(!d.is_loopback, "Source is not loopback"),
other => panic!("unexpected source_kind: {other:?}"),
}
assert!(d.sample_rate > 0);
assert!(d.channels > 0);
}
let default_loopback = devices
.iter()
.filter(|d| d.is_default && d.is_loopback)
.count();
let default_mic = devices
.iter()
.filter(|d| d.is_default && !d.is_loopback)
.count();
assert!(default_loopback <= 1);
assert!(default_mic <= 1);
}
#[test]
fn start_is_graceful_without_pipewire() {
let (prod, _cons) = raw_ring(1 << 16);
let sink = RawSink::new(prod, NATIVE_RATE, NATIVE_CHANNELS);
let mut be = PwSystemBackend::new(false, None);
match be.start(sink) {
Ok(()) => {
be.stop();
}
Err(error) if error.kind() == flexaudio_core::ErrorKind::Backend => {
}
Err(other) => panic!("unexpected error variant: {other:?}"),
}
}
#[test]
fn start_with_unknown_device_id_is_not_found_or_backend() {
let (prod, _cons) = raw_ring(1 << 16);
let sink = RawSink::new(prod, NATIVE_RATE, NATIVE_CHANNELS);
let mut be = PwSystemBackend::new(false, Some("flexaudio-no-such-sink-zzz".to_string()));
match be.start(sink) {
Err(Error::DeviceNotFound) => {}
Err(error) if error.kind() == flexaudio_core::ErrorKind::Backend => {}
Ok(()) => {
be.stop();
panic!("start should not succeed for an unknown device_id");
}
Err(other) => panic!("unexpected error variant: {other:?}"),
}
}
#[test]
#[ignore = "requires a running PipeWire session with audio playing (desktop/laptop)"]
fn capture_smoke() {
use std::time::Duration;
let (prod, mut cons) = raw_ring(1 << 18);
let sink = RawSink::new(prod, NATIVE_RATE, NATIVE_CHANNELS);
let mut be = PwSystemBackend::new(false, None);
be.start(sink)
.expect("start should succeed on a PipeWire desktop");
thread::sleep(Duration::from_millis(500));
be.stop();
let mut out = vec![0.0f32; 1920];
let got = cons.pop_slice(&mut out);
assert!(
got > 0,
"expected captured samples from the default sink monitor"
);
}
#[test]
fn process_backend_is_send() {
fn assert_send<T: Send>() {}
assert_send::<PwProcessBackend>();
}
#[test]
fn process_native_format_is_48k_stereo() {
let be = PwProcessBackend::new(4242, ProcessMode::Exclude);
assert_eq!(be.native_format(), (NATIVE_RATE, NATIVE_CHANNELS));
assert_eq!(be.native_format(), (48_000, 2));
assert_eq!(be.target_pid(), 4242);
assert_eq!(be.mode(), ProcessMode::Exclude);
let be2 = PwProcessBackend::new(1, ProcessMode::Include);
assert_eq!(be2.mode(), ProcessMode::Include);
}
#[test]
fn process_stop_without_start_is_safe() {
let mut be = PwProcessBackend::new(1234, ProcessMode::Include);
be.stop();
be.stop();
}
#[test]
fn process_exclude_mode_is_graceful() {
let (prod, _cons) = raw_ring(1 << 16);
let sink = RawSink::new(prod, NATIVE_RATE, NATIVE_CHANNELS);
let mut be = PwProcessBackend::new(u32::MAX, ProcessMode::Exclude);
match be.start(sink) {
Ok(()) => {
let (prod2, _cons2) = raw_ring(1 << 16);
let sink2 = RawSink::new(prod2, NATIVE_RATE, NATIVE_CHANNELS);
assert!(be.start(sink2).is_ok());
be.stop();
be.stop();
}
Err(error) if error.kind() == flexaudio_core::ErrorKind::Backend => {
}
Err(other) => panic!("unexpected error variant: {other:?}"),
}
}
#[test]
fn resolve_node_pid_via_client_table() {
use std::collections::HashMap;
let node = NodeEntry {
owning_client_id: Some(60),
..NodeEntry::default()
};
let mut clients = HashMap::new();
assert_eq!(resolve_node_pid(&node, &clients), None);
clients.insert(60, ClientEntry::from_props(None, Some("13394"), None));
assert_eq!(resolve_node_pid(&node, &clients), Some(13394));
let orphan = NodeEntry::default();
assert_eq!(resolve_node_pid(&orphan, &clients), None);
let direct = NodeEntry {
app_pid: Some(424242),
..NodeEntry::default()
};
assert_eq!(resolve_node_pid(&direct, &HashMap::new()), Some(424242));
let other = NodeEntry {
owning_client_id: Some(61),
..NodeEntry::default()
};
assert_eq!(resolve_node_pid(&other, &clients), None);
clients.insert(61, ClientEntry::from_props(None, Some("555"), None));
assert_eq!(resolve_node_pid(&other, &clients), Some(555));
assert_eq!(resolve_node_pid(&node, &clients), Some(13394));
}
#[test]
fn pid_from_props_prefers_application_process_id() {
assert_eq!(pid_from_props(Some("28551"), Some("3020")), Some(28551));
assert_eq!(pid_from_props(None, Some("13394")), Some(13394));
assert_eq!(pid_from_props(Some("42"), None), Some(42));
assert_eq!(pid_from_props(Some("nope"), Some("7")), Some(7));
assert_eq!(pid_from_props(Some("0"), Some("7")), Some(7));
assert_eq!(pid_from_props(None, None), None);
}
#[test]
fn node_pid_decision_table() {
use std::collections::{HashMap, HashSet};
let cases = [
(
None,
Some("pipewire-pulse"),
false,
false,
false,
None,
None,
false,
),
(
None,
Some("pipewire-pulse"),
true,
false,
false,
None,
None,
false,
),
(
None,
Some("pipewire-pulse"),
true,
true,
false,
Some(1028793),
None,
false,
),
(
None,
Some("pipewire-pulse"),
true,
true,
true,
Some(1028793),
Some(1028793),
true,
),
(
Some("pipewire-pulse"),
None,
true,
true,
true,
Some(1028793),
Some(1028793),
true,
),
(
Some("pipewire-pulse"),
None,
true,
true,
false,
None,
None,
false,
),
(
None,
Some("pipewire"),
false,
false,
false,
None,
Some(1584),
false,
),
(
None,
Some("pipewire"),
true,
false,
false,
None,
Some(1584),
false,
),
(
None,
Some("pipewire"),
true,
true,
false,
None,
Some(1584),
true,
),
(
None,
None,
true,
false,
false,
Some(1028793),
Some(1028793),
false,
),
(
None,
None,
true,
true,
true,
Some(1028793),
Some(1028793),
true,
),
];
for (
node_api,
client_api,
info_seen,
props_seen,
app_pid_from_info,
app_pid,
pid,
decidable,
) in cases
{
let clients =
HashMap::from([(40, ClientEntry::from_props(None, Some("1584"), client_api))]);
let entry = NodeEntry {
owning_client_id: Some(40),
app_pid,
app_pid_from_info,
pulse_proxied: is_pulse_proxied(node_api),
info_seen,
props_seen,
..NodeEntry::default()
};
assert_eq!(
resolve_node_pid(&entry, &clients),
pid,
"{entry:?}, {clients:?}"
);
assert_eq!(
exclude_decidable(&entry, &clients),
decidable,
"{entry:?}, {clients:?}"
);
let exclude = PidSelect::Exclude(HashSet::from([1028793]));
assert_eq!(
exclude.selects_node(&entry, &clients),
decidable && pid != Some(1028793)
);
assert_eq!(
PidSelect::Include(1028793).selects_node(&entry, &clients),
pid == Some(1028793)
);
}
}
#[test]
fn bound_info_pid_update_table() {
use std::collections::{HashMap, HashSet};
let cases = [
(false, None, Some(1028793), true),
(false, Some((Some("0"), None)), Some(1028793), true),
(true, None, None, false),
(true, Some((Some("1028793"), None)), Some(1028793), true),
(true, Some((Some("42"), None)), Some(42), true),
(true, Some((Some("1584"), None)), Some(1584), false),
(true, Some((None, None)), None, false),
(true, Some((Some("0"), None)), None, false),
(true, Some((Some("-1"), None)), None, false),
(true, Some((Some("nope"), None)), None, false),
(true, Some((Some(""), None)), None, false),
(true, Some((Some("4294967296"), None)), None, false),
];
let exclude = PidSelect::Exclude(HashSet::from([1584]));
for (node_api, client_api) in [
(None, Some("pipewire-pulse")),
(Some("pipewire-pulse"), None),
] {
let clients =
HashMap::from([(40, ClientEntry::from_props(None, Some("1584"), client_api))]);
for (props_changed, props, expected_pid, selected) in cases {
let mut entry = NodeEntry {
owning_client_id: Some(40),
app_pid: Some(1028793),
app_pid_from_info: true,
pulse_proxied: is_pulse_proxied(node_api),
info_seen: true,
props_seen: true,
..NodeEntry::default()
};
assert!(exclude.selects_node(&entry, &clients));
let previous = entry;
let changed = update_node_info(&mut entry, props_changed, props, &clients);
assert_eq!(changed, entry != previous);
assert_eq!(
entry.app_pid, expected_pid,
"node API={node_api:?}, client API={client_api:?}, mask={props_changed}, props={props:?}"
);
assert_eq!(resolve_node_pid(&entry, &clients), expected_pid);
assert_eq!(exclude.selects_node(&entry, &clients), selected);
assert_eq!(exclude_decidable(&entry, &clients), expected_pid.is_some());
}
}
}
#[test]
fn native_bound_info_pid_update_table() {
use std::collections::{HashMap, HashSet};
let clients = HashMap::from([(
40,
ClientEntry::from_props(None, Some("7"), Some("pipewire")),
)]);
for app_pid_from_info in [false, true] {
for props in [
None,
Some((None, None)),
Some((Some("0"), None)),
Some((Some("-1"), None)),
Some((Some("nope"), None)),
Some((Some(""), None)),
Some((Some("4294967296"), None)),
] {
let mut entry = NodeEntry {
owning_client_id: Some(40),
app_pid: Some(42),
app_pid_from_info,
info_seen: true,
props_seen: true,
..NodeEntry::default()
};
assert_eq!(
update_node_info(&mut entry, true, props, &clients),
app_pid_from_info,
);
assert_eq!(entry.app_pid, Some(42));
assert!(!entry.app_pid_from_info);
assert_eq!(resolve_node_pid(&entry, &clients), Some(42));
assert!(PidSelect::Include(42).selects_node(&entry, &clients));
assert!(!PidSelect::Include(7).selects_node(&entry, &clients));
assert!(PidSelect::Exclude(HashSet::from([7])).selects_node(&entry, &clients));
assert!(!PidSelect::Exclude(HashSet::from([42])).selects_node(&entry, &clients));
}
}
}
#[test]
fn late_client_provenance_rejects_stale_bound_pid() {
use std::collections::{HashMap, HashSet};
for props in [Some((None, None)), None] {
let mut entry = NodeEntry {
owning_client_id: Some(40),
..NodeEntry::default()
};
let mut clients = HashMap::new();
let exclude = PidSelect::Exclude(HashSet::from([7]));
assert!(update_node_info(
&mut entry,
true,
Some((Some("42"), None)),
&clients,
));
assert!(entry.app_pid_from_info);
assert_eq!(resolve_node_pid(&entry, &clients), Some(42));
assert!(exclude.selects_node(&entry, &clients));
assert!(update_node_info(&mut entry, true, props, &clients));
assert_eq!(entry.app_pid, Some(42));
assert!(!entry.app_pid_from_info);
assert_eq!(resolve_node_pid(&entry, &clients), Some(42));
assert!(exclude.selects_node(&entry, &clients));
clients.insert(
40,
ClientEntry::from_props(None, Some("7"), Some("pipewire-pulse")),
);
assert_eq!(resolve_node_pid(&entry, &clients), None);
assert!(!exclude_decidable(&entry, &clients));
assert!(!exclude.selects_node(&entry, &clients));
assert!(!PidSelect::Include(42).selects_node(&entry, &clients));
}
}
#[test]
fn bound_props_seen_table() {
use std::collections::{HashMap, HashSet};
let clients = HashMap::from([(40, ClientEntry::from_props(None, Some("7"), None))]);
for (props_changed, props, props_seen) in [
(false, None, false),
(false, Some((None, None)), false),
(true, None, false),
(true, Some((None, None)), true),
] {
let mut entry = NodeEntry {
owning_client_id: Some(40),
..NodeEntry::default()
};
assert!(update_node_info(&mut entry, props_changed, props, &clients));
assert!(entry.info_seen);
assert_eq!(entry.props_seen, props_seen);
assert_eq!(exclude_decidable(&entry, &clients), props_seen);
assert_eq!(
PidSelect::Exclude(HashSet::from([42])).selects_node(&entry, &clients),
props_seen,
);
}
}
#[test]
fn pulse_missing_or_invalid_bound_pid_table() {
use std::collections::HashMap;
for app_pid in [None, Some("0"), Some("-2"), Some("NaN"), Some("")] {
for (node_api, client_api) in [
(None, Some("pipewire-pulse")),
(Some("pipewire-pulse"), None),
] {
let clients =
HashMap::from([(40, ClientEntry::from_props(None, Some("1584"), client_api))]);
let mut entry = NodeEntry {
owning_client_id: Some(40),
..NodeEntry::default()
};
assert!(update_node_info(
&mut entry,
true,
Some((app_pid, node_api)),
&clients,
));
assert_eq!(resolve_node_pid(&entry, &clients), None);
assert!(!exclude_decidable(&entry, &clients));
}
}
}
#[test]
fn late_client_provenance_revokes_provisional_global_pid() {
use std::collections::{HashMap, HashSet};
let mut entry = NodeEntry {
owning_client_id: Some(40),
app_pid: Some(1584),
..NodeEntry::default()
};
let mut clients = HashMap::new();
update_node_info(&mut entry, false, None, &clients);
let exclude = PidSelect::Exclude(HashSet::from([1028793]));
assert!(entry.info_seen);
assert!(!entry.props_seen);
assert!(!exclude.selects_node(&entry, &clients));
clients.insert(40, ClientEntry::from_props(None, Some("1584"), None));
assert!(!exclude.selects_node(&entry, &clients));
clients.insert(
40,
ClientEntry::from_props(None, Some("1584"), Some("pipewire-pulse")),
);
assert!(!exclude.selects_node(&entry, &clients));
update_node_info(&mut entry, true, Some((Some("1028793"), None)), &clients);
assert_eq!(resolve_node_pid(&entry, &clients), Some(1028793));
assert!(!exclude.selects_node(&entry, &clients));
}
#[test]
fn pid_select_exclude_takes_a_set() {
use std::collections::HashSet;
let sel = PidSelect::Exclude(HashSet::from([10, 20]));
assert!(sel.is_subject_pid(10) && sel.is_subject_pid(20) && !sel.is_subject_pid(30));
assert!(sel.selects(Some(30)));
assert!(!sel.selects(Some(20)));
assert!(!sel.selects(None));
let inc = PidSelect::Include(7);
assert!(inc.selects(Some(7)) && !inc.selects(Some(8)) && !inc.selects(None));
assert_eq!(inc.node_key(), "7");
assert_eq!(sel.node_key(), "excl-10");
}
#[test]
fn effective_exclusion_unions_and_dedups() {
use std::collections::HashSet;
let self_pid = 4242u32;
let cases: &[(bool, &[u32], HashSet<u32>, &str)] = &[
(
false,
&[],
HashSet::new(),
"neither flag nor pids → sink-monitor path",
),
(
true,
&[],
HashSet::from([self_pid]),
"exclude_self alone → just self",
),
(
false,
&[5, 6],
HashSet::from([5, 6]),
"pids alone → fan-in without self",
),
(
true,
&[5, 4242],
HashSet::from([5, self_pid]),
"self pid already listed → union, no duplicate",
),
];
for (excl_self, pids, want, msg) in cases {
assert_eq!(
effective_exclusion(*excl_self, pids, self_pid),
*want,
"{msg}"
);
}
}
#[test]
fn system_backend_exclude_pids_builder() {
let be = PwSystemBackend::new(false, None).with_exclude_pids(vec![5, 6]);
assert_eq!(be.exclude_pids(), &[5, 6]);
assert!(!be.exclude_self());
}
#[test]
fn pair_ports_maps_channels() {
let out = vec![(10u32, "FL".to_string()), (11u32, "FR".to_string())];
let inp = vec![(20u32, "FL".to_string()), (21u32, "FR".to_string())];
let mut pairs = pair_ports(&out, &inp);
pairs.sort();
assert_eq!(pairs, vec![(10, 20), (11, 21)], "FL→FL / FR→FR");
let inp_rev = vec![(21u32, "FR".to_string()), (20u32, "FL".to_string())];
let mut pairs = pair_ports(&out, &inp_rev);
pairs.sort();
assert_eq!(
pairs,
vec![(10, 20), (11, 21)],
"FL→FL / FR→FR even with reversed order"
);
let mono_out = vec![(30u32, "MONO".to_string())];
let stereo_in = vec![(40u32, "FL".to_string()), (41u32, "FR".to_string())];
let mut pairs = pair_ports(&mono_out, &stereo_in);
pairs.sort();
assert_eq!(
pairs,
vec![(30, 40), (30, 41)],
"mono is duplicated to FL/FR"
);
let out_noch = vec![(50u32, String::new()), (51u32, String::new())];
let in_noch = vec![(60u32, String::new()), (61u32, String::new())];
let pairs = pair_ports(&out_noch, &in_noch);
assert_eq!(pairs.len(), 2);
let ins: std::collections::HashSet<u32> = pairs.iter().map(|(_, i)| *i).collect();
assert_eq!(ins.len(), 2, "each input port is used at most once");
assert!(pair_ports(&[], &inp).is_empty());
assert!(pair_ports(&out, &[]).is_empty());
let out_fl = vec![(70u32, "FL".to_string())];
let in_fr = vec![(80u32, "FR".to_string())];
let pairs = pair_ports(&out_fl, &in_fr);
assert_eq!(
pairs,
vec![(70, 80)],
"single output is duplicated to remaining inputs"
);
}
#[test]
fn link_plan_is_complete_requires_every_channel_on_both_sides() {
struct Case {
expected_out: Option<u32>,
out_len: usize,
in_len: usize,
pairs_len: usize,
chans: usize,
want: bool,
why: &'static str,
}
let case = |expected_out, out_len, in_len, pairs_len, chans, want, why| Case {
expected_out,
out_len,
in_len,
pairs_len,
chans,
want,
why,
};
let cases = [
case(
Some(2),
2,
1,
1,
2,
false,
"capture input FR has not arrived yet",
),
case(Some(2), 2, 2, 2, 2, true, "stereo source fully paired"),
case(
Some(2),
1,
2,
2,
2,
false,
"target output FR missing — pair_ports' mono rule duplicated FL onto both \
inputs, which must not latch",
),
case(
Some(1),
1,
2,
2,
2,
true,
"genuine mono source duplicated onto FL+FR",
),
case(
Some(6),
6,
2,
2,
2,
false,
"multichannel input is unsupported",
),
case(
None,
2,
2,
2,
2,
false,
"unknown layout cannot establish complete routing",
),
case(None, 0, 2, 0, 2, false, "nothing to link"),
case(
Some(0),
1,
2,
2,
2,
false,
"a declared count of 0 alongside a visible port means the node has not \
finished describing itself — not known yet, so incomplete",
),
];
for c in cases {
assert_eq!(
link_plan_is_complete(c.expected_out, c.out_len, c.in_len, c.pairs_len, c.chans),
c.want,
"{} ({:?}, {}, {}, {}, {})",
c.why,
c.expected_out,
c.out_len,
c.in_len,
c.pairs_len,
c.chans
);
}
}
#[test]
fn process_start_is_graceful_without_pipewire() {
let (prod, _cons) = raw_ring(1 << 16);
let sink = RawSink::new(prod, NATIVE_RATE, NATIVE_CHANNELS);
let mut be = PwProcessBackend::new(u32::MAX, ProcessMode::Include);
match be.start(sink) {
Ok(()) => {
let (prod2, _cons2) = raw_ring(1 << 16);
let sink2 = RawSink::new(prod2, NATIVE_RATE, NATIVE_CHANNELS);
assert!(be.start(sink2).is_ok());
be.stop();
be.stop();
}
Err(error) if error.kind() == flexaudio_core::ErrorKind::Backend => {
}
Err(other) => panic!("unexpected error variant: {other:?}"),
}
}
#[test]
#[ignore = "requires a running PipeWire session with the target PID playing audio (set FLEXAUDIO_TEST_PID)"]
fn process_capture_smoke() {
use std::time::Duration;
let Ok(pid_str) = std::env::var("FLEXAUDIO_TEST_PID") else {
eprintln!("skipping because FLEXAUDIO_TEST_PID is not set");
return;
};
let pid: u32 = pid_str.parse().expect("FLEXAUDIO_TEST_PID must be a u32");
let (prod, mut cons) = raw_ring(1 << 18);
let sink = RawSink::new(prod, NATIVE_RATE, NATIVE_CHANNELS);
let mut be = PwProcessBackend::new(pid, ProcessMode::Include);
be.start(sink)
.expect("start should succeed on a PipeWire desktop");
thread::sleep(Duration::from_millis(800));
be.stop();
let mut out = vec![0.0f32; 1920];
let got = cons.pop_slice(&mut out);
assert!(
got > 0,
"expected captured samples link-factory-linked from PID {pid}"
);
}
#[test]
fn watcher_is_send() {
fn assert_send<T: Send>() {}
assert_send::<PwDeviceWatcher>();
}
#[test]
fn watcher_graceful_without_pipewire() {
match PwDeviceWatcher::start() {
Ok(mut w) => {
let _ = w.poll_event();
w.stop();
}
Err(error) if error.kind() == flexaudio_core::ErrorKind::Backend => {
}
Err(other) => panic!("unexpected error variant: {other:?}"),
}
}
#[test]
fn watcher_double_stop_is_safe() {
if let Ok(mut w) = PwDeviceWatcher::start() {
w.stop();
w.stop();
}
}
#[test]
fn enqueue_and_drain_is_fifo() {
let events: WatchEventQueue = Arc::new(Mutex::new(WatchEvents::default()));
let mic = DeviceInfo {
id: "mic.a".into(),
name: "Mic A".into(),
source_kind: SourceKind::Mic,
sample_rate: NATIVE_RATE,
channels: NATIVE_CHANNELS,
is_loopback: false,
is_default: false,
};
enqueue_event(&events, DeviceEvent::Added(mic.clone()));
enqueue_event(&events, DeviceEvent::Removed { id: "mic.a".into() });
enqueue_event(
&events,
DeviceEvent::DefaultChanged {
kind: flexaudio_core::DefaultDeviceKind::SystemAudio,
id: "sink.x".into(),
},
);
let mut drained = Vec::new();
while let Some(ev) = lock_events(&events).poll() {
drained.push(ev);
}
assert_eq!(
drained,
vec![
DeviceEvent::Added(mic),
DeviceEvent::Removed { id: "mic.a".into() },
DeviceEvent::DefaultChanged {
kind: flexaudio_core::DefaultDeviceKind::SystemAudio,
id: "sink.x".into(),
},
]
);
}
#[test]
fn enqueue_event_caps_queue_and_drops_oldest() {
let events: WatchEventQueue = Arc::new(Mutex::new(WatchEvents::default()));
let total = MAX_WATCH_EVENTS + 10;
for i in 0..total {
enqueue_event(
&events,
DeviceEvent::Removed {
id: format!("n{i}"),
},
);
}
assert_eq!(
lock_events(&events).poll(),
Some(DeviceEvent::RescanRequired { dropped_events: 10 })
);
let queue = events.lock().unwrap();
let q = &queue.deltas;
assert_eq!(
q.len(),
MAX_WATCH_EVENTS,
"queue length is capped at the limit"
);
match q.front().unwrap() {
DeviceEvent::Removed { id } => assert_eq!(id, "n10", "oldest events are dropped first"),
other => panic!("unexpected event: {other:?}"),
}
match q.back().unwrap() {
DeviceEvent::Removed { id } => assert_eq!(id, &format!("n{}", total - 1)),
other => panic!("unexpected event: {other:?}"),
}
}
}