feat(host): emit desktop audio exclusion status events
This commit is contained in:
+234
-8
@@ -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(),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user