feat(host): build desktop audio exclusion foundation

This commit is contained in:
2026-08-21 15:39:07 -04:00
parent 5d3da8b006
commit 781defcd84
16 changed files with 2586 additions and 564 deletions
+333
View File
@@ -0,0 +1,333 @@
//! Pure Phase-6 fan-out planning.
//!
//! This module converts one coherent taint decision into exact per-port link
//! specifications. It deliberately cannot create PipeWire objects: the
//! mutation edge will consume this plan on the observer thread, re-evaluate in
//! that same callback, then retain the resulting non-lingering link proxies.
//!
//! Port identity is `audio.channel`, never registry enumeration order or a
//! recyclable global id. An unknown or incompatible layout makes that stream
//! unsupported; it never produces a guessed partial capture.
#![allow(dead_code)]
use std::collections::{BTreeMap, BTreeSet};
use super::taint::snapshot::{
GlobalId, GraphSnapshot, MediaRole, PortDirection, PortSnapshot, Serial,
};
use super::taint::{Decisions, Reason};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub(super) struct DesiredLink {
pub(super) output_node: GlobalId,
pub(super) output_port: GlobalId,
pub(super) input_node: GlobalId,
pub(super) input_port: GlobalId,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum PlanIssue {
CaptureSinkMissing,
CaptureSinkNotASink,
MissingOutputPorts,
MissingCapturePorts,
UnidentifiedChannel,
AmbiguousCaptureChannel,
DuplicateOutputChannel,
IncompatibleChannelLayout,
ExclusiveCapturePort,
}
impl PlanIssue {
pub(super) fn code(self) -> &'static str {
match self {
Self::CaptureSinkMissing => "capture-sink-missing",
Self::CaptureSinkNotASink => "capture-sink-not-a-sink",
Self::MissingOutputPorts => "missing-output-ports",
Self::MissingCapturePorts => "missing-capture-ports",
Self::UnidentifiedChannel => "unidentified-channel",
Self::AmbiguousCaptureChannel => "ambiguous-capture-channel",
Self::DuplicateOutputChannel => "duplicate-output-channel",
Self::IncompatibleChannelLayout => "incompatible-channel-layout",
Self::ExclusiveCapturePort => "exclusive-capture-port",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) enum StreamPlan {
Excluded { reason: Reason },
Unsupported { issue: PlanIssue },
Capture { links: BTreeSet<DesiredLink> },
}
/// Plan every candidate in the decision universe against one capture sink.
///
/// The caller must pass `decisions` produced from this exact `snapshot`. The
/// mutation edge upholds that condition structurally by evaluating and planning
/// inline in one observer callback; keeping this function pure makes the port
/// matrix independently falsifiable.
pub(super) fn plan(
snapshot: &GraphSnapshot,
decisions: &Decisions,
capture_sink: Serial,
) -> BTreeMap<Serial, StreamPlan> {
let sink = snapshot.node(capture_sink);
let sink_issue = match sink {
None => Some(PlanIssue::CaptureSinkMissing),
Some(node) if node.role != MediaRole::Sink => Some(PlanIssue::CaptureSinkNotASink),
Some(_) => None,
};
decisions
.candidates
.iter()
.map(|(&serial, decision)| {
let stream_plan = if let Some(reason) = decision.reason() {
StreamPlan::Excluded { reason }
} else if let Some(issue) = sink_issue {
StreamPlan::Unsupported { issue }
} else {
let node = snapshot
.node(serial)
.expect("a decision from this snapshot names a live node");
match pair_ports(
snapshot.ports_of(node.id),
snapshot.ports_of(sink.expect("validated above").id),
node.id,
sink.expect("validated above").id,
) {
Ok(links) => StreamPlan::Capture { links },
Err(issue) => StreamPlan::Unsupported { issue },
}
};
(serial, stream_plan)
})
.collect()
}
fn pair_ports<'a>(
candidate_ports: impl Iterator<Item = &'a PortSnapshot>,
capture_ports: impl Iterator<Item = &'a PortSnapshot>,
output_node: GlobalId,
input_node: GlobalId,
) -> Result<BTreeSet<DesiredLink>, PlanIssue> {
let outputs: Vec<&PortSnapshot> = candidate_ports
.filter(|port| port.direction == PortDirection::Out && !port.monitor)
.collect();
if outputs.is_empty() {
return Err(PlanIssue::MissingOutputPorts);
}
let inputs: Vec<&PortSnapshot> = capture_ports
.filter(|port| port.direction == PortDirection::In)
.collect();
if inputs.is_empty() {
return Err(PlanIssue::MissingCapturePorts);
}
if inputs.iter().any(|port| port.exclusive) {
return Err(PlanIssue::ExclusiveCapturePort);
}
let mut inputs_by_channel: BTreeMap<&str, GlobalId> = BTreeMap::new();
for port in &inputs {
let channel = port
.channel
.as_deref()
.ok_or(PlanIssue::UnidentifiedChannel)?;
if inputs_by_channel.insert(channel, port.id).is_some() {
return Err(PlanIssue::AmbiguousCaptureChannel);
}
}
let mut output_channels = BTreeSet::new();
let mut links = BTreeSet::new();
for output in outputs {
let channel = output
.channel
.as_deref()
.ok_or(PlanIssue::UnidentifiedChannel)?;
if !output_channels.insert(channel) {
return Err(PlanIssue::DuplicateOutputChannel);
}
if channel == "MONO" {
for &input_port in inputs_by_channel.values() {
links.insert(DesiredLink {
output_node,
output_port: output.id,
input_node,
input_port,
});
}
continue;
}
let Some(&input_port) = inputs_by_channel.get(channel) else {
return Err(PlanIssue::IncompatibleChannelLayout);
};
links.insert(DesiredLink {
output_node,
output_port: output.id,
input_node,
input_port,
});
}
Ok(links)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::host::taint::fixture::{Graph, PULSE_PID};
use crate::host::taint::snapshot::{MediaRole, PortDirection};
use crate::host::taint::{ExclusionCtx, StickyState, evaluate};
fn decisions(graph: &Graph) -> (GraphSnapshot, Decisions) {
let snapshot = graph.build();
let (decisions, _) = evaluate(
&snapshot,
&ExclusionCtx {
pipewire_pulse_pid: Some(PULSE_PID),
graph_ready: true,
..ExclusionCtx::default()
},
&StickyState::default(),
);
(snapshot, decisions)
}
fn stereo_graph() -> (Graph, Serial, Serial, [GlobalId; 4]) {
let mut graph = Graph::new();
let app = graph.app_node("music", MediaRole::StreamOutput, 42);
let sink = graph.native_virtual_node("pixelpass_capture_test", MediaRole::Sink, 43);
let app_fl = graph.port_on_channel(app, PortDirection::Out, false, Some("FL"));
let app_fr = graph.port_on_channel(app, PortDirection::Out, false, Some("FR"));
let sink_fl = graph.port_on_channel(sink, PortDirection::In, false, Some("FL"));
let sink_fr = graph.port_on_channel(sink, PortDirection::In, false, Some("FR"));
(
graph,
app.serial,
sink.serial,
[app_fl, app_fr, sink_fl, sink_fr],
)
}
#[test]
fn stereo_ports_pair_by_channel_not_enumeration_order() {
let (graph, app, sink, [app_fl, app_fr, sink_fl, sink_fr]) = stereo_graph();
let (snapshot, decisions) = decisions(&graph);
assert_eq!(
plan(&snapshot, &decisions, sink).get(&app),
Some(&StreamPlan::Capture {
links: BTreeSet::from([
DesiredLink {
output_node: snapshot.node(app).unwrap().id,
output_port: app_fl,
input_node: snapshot.node(sink).unwrap().id,
input_port: sink_fl,
},
DesiredLink {
output_node: snapshot.node(app).unwrap().id,
output_port: app_fr,
input_node: snapshot.node(sink).unwrap().id,
input_port: sink_fr,
},
]),
})
);
}
#[test]
fn mono_fans_to_both_stereo_inputs() {
let mut graph = Graph::new();
let app = graph.app_node("mono", MediaRole::StreamOutput, 42);
let sink = graph.native_virtual_node("pixelpass_capture_test", MediaRole::Sink, 43);
let mono = graph.port_on_channel(app, PortDirection::Out, false, Some("MONO"));
let left = graph.port_on_channel(sink, PortDirection::In, false, Some("FL"));
let right = graph.port_on_channel(sink, PortDirection::In, false, Some("FR"));
let (snapshot, decisions) = decisions(&graph);
let StreamPlan::Capture { links } =
plan(&snapshot, &decisions, sink.serial)[&app.serial].clone()
else {
panic!("mono stream must be plannable");
};
assert_eq!(links.len(), 2);
assert_eq!(
links
.iter()
.map(|link| link.output_port)
.collect::<BTreeSet<_>>(),
BTreeSet::from([mono])
);
assert_eq!(
links
.iter()
.map(|link| link.input_port)
.collect::<BTreeSet<_>>(),
BTreeSet::from([left, right])
);
}
#[test]
fn unknown_or_incompatible_layout_is_unsupported_not_guessed() {
let (mut graph, app, sink, _) = stereo_graph();
let surround = graph.app_node("surround", MediaRole::StreamOutput, 44);
graph.port_on_channel(surround, PortDirection::Out, false, Some("FC"));
let unknown = graph.app_node("unknown", MediaRole::StreamOutput, 45);
graph.port_on_channel(unknown, PortDirection::Out, false, None);
let (snapshot, decisions) = decisions(&graph);
let plans = plan(&snapshot, &decisions, sink);
assert!(matches!(plans[&app], StreamPlan::Capture { .. }));
assert_eq!(
plans[&surround.serial],
StreamPlan::Unsupported {
issue: PlanIssue::IncompatibleChannelLayout
}
);
assert_eq!(
plans[&unknown.serial],
StreamPlan::Unsupported {
issue: PlanIssue::UnidentifiedChannel
}
);
}
#[test]
fn excluded_candidate_never_produces_a_link_plan() {
let (mut graph, app, sink, _) = stereo_graph();
let call = graph.peerspeak_node("call", 7);
graph.port_on_channel(call, PortDirection::Out, false, Some("FL"));
let (snapshot, decisions) = decisions(&graph);
let plans = plan(&snapshot, &decisions, sink);
assert!(matches!(plans[&app], StreamPlan::Capture { .. }));
assert_eq!(
plans[&call.serial],
StreamPlan::Excluded {
reason: Reason::PeerspeakOwned
}
);
}
#[test]
fn replacing_the_capture_sink_changes_every_target_port() {
let (mut graph, app, old_sink, _) = stereo_graph();
let new_sink = graph.native_virtual_node("pixelpass_capture_new", MediaRole::Sink, 46);
let new_fl = graph.port_on_channel(new_sink, PortDirection::In, false, Some("FL"));
let new_fr = graph.port_on_channel(new_sink, PortDirection::In, false, Some("FR"));
let (snapshot, decisions) = decisions(&graph);
let old = plan(&snapshot, &decisions, old_sink);
let new = plan(&snapshot, &decisions, new_sink.serial);
let StreamPlan::Capture { links: old } = &old[&app] else {
panic!("old sink plan");
};
let StreamPlan::Capture { links: new } = &new[&app] else {
panic!("new sink plan");
};
assert_ne!(old, new);
assert_eq!(
new.iter()
.map(|link| link.input_port)
.collect::<BTreeSet<_>>(),
BTreeSet::from([new_fl, new_fr])
);
}
}