feat(host): build desktop audio exclusion foundation
This commit is contained in:
@@ -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])
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user