feat(host): emit desktop audio exclusion status events

This commit is contained in:
2026-08-21 16:31:11 -04:00
parent 98cde2c19b
commit 5a65f50c4b
4 changed files with 402 additions and 12 deletions
+234 -8
View File
@@ -18,7 +18,11 @@ use super::observer::{EventKind, Millis, Projection};
use super::taint::snapshot::{
GlobalId, GraphSnapshot, MediaRole, PortDirection, PortSnapshot, Serial,
};
use super::taint::{Decisions, ExclusionCtx, Reason, StickyState, evaluate};
use super::taint::{
Decisions, ECHO_CANCEL_GROUP_PREFIX, ExclusionCtx, Reason, StickyState, evaluate,
};
use crate::common::output::AudioExclusionEvent;
use tokio::sync::mpsc;
const AEC_VALIDATION_TIMEOUT_MILLIS: Millis = 5_000;
@@ -148,15 +152,48 @@ pub(super) struct FanoutController {
validator: AecValidator,
sticky: StickyState,
states: BTreeMap<Serial, StreamCaptureState>,
foreign_aec_groups: BTreeSet<String>,
status_tx: Option<mpsc::UnboundedSender<AudioExclusionEvent>>,
}
impl FanoutController {
#[cfg(test)]
pub(super) fn new(capture_sink: Serial, aec: AecConfig) -> Self {
Self::build(capture_sink, aec, None)
}
/// Construct the production controller with a non-blocking status path.
/// The PipeWire callback only enqueues owned records; JSON serialization
/// and stdout I/O happen on a Tokio task outside the graph loop.
pub(super) fn with_status_sender(
capture_sink: Serial,
aec: AecConfig,
status_tx: mpsc::UnboundedSender<AudioExclusionEvent>,
) -> Self {
Self::build(capture_sink, aec, Some(status_tx))
}
fn build(
capture_sink: Serial,
aec: AecConfig,
status_tx: Option<mpsc::UnboundedSender<AudioExclusionEvent>>,
) -> Self {
Self {
capture_sink,
validator: AecValidator::new(aec, AEC_VALIDATION_TIMEOUT_MILLIS),
sticky: StickyState::default(),
states: BTreeMap::new(),
foreign_aec_groups: BTreeSet::new(),
status_tx,
}
}
fn emit_status(&self, event: AudioExclusionEvent) {
if let Some(status_tx) = &self.status_tx {
// A closed receiver means the machine front-end no longer accepts
// notices. Capture ownership remains safe and should not be
// poisoned merely because its best-effort stdout consumer ended.
let _ = status_tx.send(event);
}
}
@@ -174,9 +211,37 @@ impl MutationProjectionSink for FanoutController {
now_us: u64,
links: &mut dyn LinkMutation,
) {
let prior_aec_state = self.validator.state();
self.validator
.observe(&projection.snapshot, projection.graph_ready, now_us / 1_000);
let aec_state = self.validator.state();
let aec_status = if prior_aec_state != aec_state {
self.validator
.configured_module_id()
.and_then(|module_index| match aec_state {
AecState::Failed => Some(AudioExclusionEvent::AecFailed { module_index }),
AecState::Revoked => Some(AudioExclusionEvent::AecRevoked { module_index }),
_ => None,
})
} else {
None
};
let configured_aec = self.validator.configured_module_id();
let foreign_aec_groups: BTreeSet<String> = projection
.snapshot
.nodes()
.filter_map(|node| {
let group = node.props.link_group.as_ref()?;
(group.starts_with(ECHO_CANCEL_GROUP_PREFIX)
&& configured_aec
.is_none_or(|module| node.props.pulse_module_id != Some(module)))
.then(|| group.clone())
})
.collect();
let new_foreign_groups: Vec<String> = foreign_aec_groups
.difference(&self.foreign_aec_groups)
.cloned()
.collect();
let ctx = ExclusionCtx {
aec_module_id: self.validator.validated_module_id(),
pipewire_pulse_pid: projection.pipewire_pulse_pid,
@@ -234,16 +299,23 @@ impl MutationProjectionSink for FanoutController {
(serial, state)
})
.collect();
let mut stream_statuses = Vec::new();
for (&serial, state) in &next_states {
if self.states.get(&serial) == Some(state) {
continue;
}
match state {
StreamCaptureState::Unsupported { issue } => tracing::warn!(
serial = serial.0,
reason = issue.code(),
"audio fan-out: stream unsupported"
),
StreamCaptureState::Unsupported { issue } => {
tracing::warn!(
serial = serial.0,
reason = issue.code(),
"audio fan-out: stream unsupported"
);
stream_statuses.push(AudioExclusionEvent::StreamUnsupported {
stream_serial: serial.0,
reason: issue.code(),
});
}
StreamCaptureState::Captured => tracing::info!(
serial = serial.0,
"audio fan-out: every required link is active"
@@ -252,6 +324,20 @@ impl MutationProjectionSink for FanoutController {
}
}
self.states = next_states;
self.foreign_aec_groups = foreign_aec_groups;
// `reconcile` above has already dropped every link forbidden by an AEC
// transition or a newly unsafe graph. Only report after that mutation
// boundary, so `aec_revoked` can never race ahead of link revocation.
if let Some(event) = aec_status {
self.emit_status(event);
}
for link_group in new_foreign_groups {
self.emit_status(AudioExclusionEvent::ForeignAecWarning { link_group });
}
for event in stream_statuses {
self.emit_status(event);
}
}
}
@@ -496,6 +582,20 @@ mod tests {
controller.on_projection(&projection(graph), EventKind::Graph, 1_000, links);
}
fn controller_with_status(
capture_sink: Serial,
aec: AecConfig,
) -> (
FanoutController,
mpsc::UnboundedReceiver<AudioExclusionEvent>,
) {
let (status_tx, status_rx) = mpsc::unbounded_channel();
(
FanoutController::with_status_sender(capture_sink, aec, status_tx),
status_rx,
)
}
#[test]
fn stereo_ports_pair_by_channel_not_enumeration_order() {
let (graph, app, sink, [app_fl, app_fr, sink_fl, sink_fr]) = stereo_graph();
@@ -745,6 +845,50 @@ mod tests {
));
}
#[test]
fn aec_validation_deadline_emits_the_failed_cause_once() {
let (graph, app, sink, _) = stereo_graph();
let mut links = FakeLinks::default();
let (mut controller, mut status_rx) =
controller_with_status(sink, AecConfig::PulseModule(999));
drive(&mut controller, &graph, &mut links);
assert!(matches!(
controller.states()[&app],
StreamCaptureState::Gated {
aec: AecState::Validating
}
));
controller.on_projection(
&projection(&graph),
EventKind::Tick,
(AEC_VALIDATION_TIMEOUT_MILLIS + 2) * 1_000,
&mut links,
);
assert_eq!(
status_rx.try_recv(),
Ok(AudioExclusionEvent::AecFailed { module_index: 999 })
);
assert!(matches!(
controller.states()[&app],
StreamCaptureState::Gated {
aec: AecState::Failed
}
));
controller.on_projection(
&projection(&graph),
EventKind::Tick,
(AEC_VALIDATION_TIMEOUT_MILLIS + 3) * 1_000,
&mut links,
);
assert_eq!(
status_rx.try_recv(),
Err(mpsc::error::TryRecvError::Empty),
"a sticky failed state must not repeat the event on every tick"
);
}
#[test]
fn link_creation_failure_is_sticky_for_the_same_intent() {
let (graph, app, sink, _) = stereo_graph();
@@ -770,7 +914,7 @@ mod tests {
*planned.iter().next().expect("stereo link"),
ManagedLinkState::Failed,
);
let mut controller = FanoutController::new(sink, AecConfig::Off);
let (mut controller, mut status_rx) = controller_with_status(sink, AecConfig::Off);
drive(&mut controller, &graph, &mut links);
assert_eq!(
controller.states()[&app],
@@ -779,11 +923,23 @@ mod tests {
}
);
assert_eq!(links.creates, 2);
assert_eq!(
status_rx.try_recv(),
Ok(AudioExclusionEvent::StreamUnsupported {
stream_serial: app.0,
reason: "link-creation-failed",
})
);
drive(&mut controller, &graph, &mut links);
assert_eq!(
links.creates, 2,
"an identical failed intent must not hot-loop"
);
assert_eq!(
status_rx.try_recv(),
Err(mpsc::error::TryRecvError::Empty),
"an unchanged unsupported stream must not repeat its event"
);
}
#[test]
@@ -792,7 +948,8 @@ mod tests {
let aec = graph.module_node("aec-playback", MediaRole::StreamOutput, 999);
graph.port_on_channel(aec, PortDirection::Out, false, Some("FL"));
let mut links = FakeLinks::default();
let mut controller = FanoutController::new(sink, AecConfig::PulseModule(999));
let (mut controller, mut status_rx) =
controller_with_status(sink, AecConfig::PulseModule(999));
drive(&mut controller, &graph, &mut links);
assert_eq!(controller.states()[&app], StreamCaptureState::Captured);
assert_eq!(links.held.len(), 2);
@@ -811,5 +968,74 @@ mod tests {
aec: AecState::Revoked
}
);
assert_eq!(
status_rx.try_recv(),
Ok(AudioExclusionEvent::AecRevoked { module_index: 999 })
);
}
#[test]
fn second_echo_cancel_group_emits_one_foreign_warning() {
use crate::host::taint::fixture::pulse_module;
let (mut graph, _app, sink, _) = stereo_graph();
let own_client = graph.client_of_module(999);
let mut own_props = pulse_module(own_client, 999, PULSE_PID);
own_props.link_group = Some("echo-cancel-owned-13".to_string());
graph.node("owned-aec-playback", MediaRole::StreamOutput, own_props);
let foreign_client = graph.client_of_module(1_000);
let mut foreign_props = pulse_module(foreign_client, 1_000, PULSE_PID);
foreign_props.link_group = Some("echo-cancel-foreign-14".to_string());
graph.node(
"foreign-aec-playback",
MediaRole::StreamOutput,
foreign_props.clone(),
);
graph.node("foreign-aec-capture", MediaRole::StreamInput, foreign_props);
let mut links = FakeLinks::default();
let (mut controller, mut status_rx) =
controller_with_status(sink, AecConfig::PulseModule(999));
drive(&mut controller, &graph, &mut links);
assert_eq!(
status_rx.try_recv(),
Ok(AudioExclusionEvent::ForeignAecWarning {
link_group: "echo-cancel-foreign-14".to_string(),
})
);
assert_eq!(
status_rx.try_recv(),
Err(mpsc::error::TryRecvError::Empty),
"all legs in one foreign group collapse to one warning"
);
drive(&mut controller, &graph, &mut links);
assert_eq!(
status_rx.try_recv(),
Err(mpsc::error::TryRecvError::Empty),
"a still-live foreign group must not warn on every graph event"
);
}
#[test]
fn unindexed_native_echo_cancel_group_is_still_foreign() {
let (mut graph, _app, sink, _) = stereo_graph();
graph.group_node(
"native-aec-playback",
MediaRole::StreamOutput,
"echo-cancel-native-1",
77,
);
let mut links = FakeLinks::default();
let (mut controller, mut status_rx) = controller_with_status(sink, AecConfig::Off);
drive(&mut controller, &graph, &mut links);
assert_eq!(
status_rx.try_recv(),
Ok(AudioExclusionEvent::ForeignAecWarning {
link_group: "echo-cancel-native-1".to_string(),
})
);
}
}