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
+127
View File
@@ -17,6 +17,14 @@ use serde::Serialize;
static JSON_ENABLED: AtomicBool = AtomicBool::new(false);
/// First wire version for the desktop-audio-exclusion status family.
///
/// Existing event tags predate explicit versioning. These events are consumed
/// across the PixelPass/PeerSpeak process boundary and are landing before the
/// PeerSpeak parser, so their version is carried on every record rather than
/// inferred from either binary's package version.
pub(crate) const AUDIO_EXCLUSION_EVENT_VERSION: u8 = 1;
/// Turn JSON event output on. Called once at startup from `--output json`.
pub fn set_json(enabled: bool) {
JSON_ENABLED.store(enabled, Ordering::Relaxed);
@@ -63,6 +71,21 @@ pub enum Event<'a> {
/// stream went away. Under `--strict-audio`, `lost` means viewers currently
/// hear silence; without it, viewers fall back to whole-desktop audio.
AppAudio { state: AppAudioState },
/// One otherwise-eligible playback stream could not be linked safely.
StreamUnsupported {
version: u8,
stream_serial: u64,
reason: &'a str,
},
/// The configured AEC identity never appeared before its validation
/// deadline. Fan-out remains fail-closed.
AecFailed { version: u8, module_index: u64 },
/// A previously validated AEC identity disappeared. Every owned fan-out
/// link is revoked before this transition is reported.
AecRevoked { version: u8, module_index: u64 },
/// An echo-cancel group other than the configured PeerSpeak instance is
/// present and excluded from fan-out.
ForeignAecWarning { version: u8, link_group: &'a str },
}
#[derive(Serialize)]
@@ -79,6 +102,58 @@ pub enum AppAudioState {
Lost,
}
/// Owned form of the audio-exclusion status family. The PipeWire observer can
/// enqueue this through an unbounded sender without borrowing its snapshot;
/// a Tokio-side forwarder then converts it to the public JSON [`Event`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum AudioExclusionEvent {
StreamUnsupported {
stream_serial: u64,
reason: &'static str,
},
AecFailed {
module_index: u64,
},
AecRevoked {
module_index: u64,
},
ForeignAecWarning {
link_group: String,
},
}
impl AudioExclusionEvent {
fn as_event(&self) -> Event<'_> {
match self {
Self::StreamUnsupported {
stream_serial,
reason,
} => Event::StreamUnsupported {
version: AUDIO_EXCLUSION_EVENT_VERSION,
stream_serial: *stream_serial,
reason,
},
Self::AecFailed { module_index } => Event::AecFailed {
version: AUDIO_EXCLUSION_EVENT_VERSION,
module_index: *module_index,
},
Self::AecRevoked { module_index } => Event::AecRevoked {
version: AUDIO_EXCLUSION_EVENT_VERSION,
module_index: *module_index,
},
Self::ForeignAecWarning { link_group } => Event::ForeignAecWarning {
version: AUDIO_EXCLUSION_EVENT_VERSION,
link_group,
},
}
}
/// Emit this owned status record through the stable stdout protocol.
pub(crate) fn emit(&self) {
emit(self.as_event());
}
}
/// Emit one event as a JSON line on stdout, flushed. No-op unless JSON
/// output was enabled with [`set_json`], so call sites can sprinkle these
/// unconditionally without branching.
@@ -118,4 +193,56 @@ mod tests {
.unwrap();
assert_eq!(lost, r#"{"event":"app_audio","state":"lost"}"#);
}
#[test]
fn audio_exclusion_event_wire_shapes_are_versioned_and_exact() {
let unsupported = serde_json::to_string(
&AudioExclusionEvent::StreamUnsupported {
stream_serial: 4_294_967_297,
reason: "link-creation-failed",
}
.as_event(),
)
.unwrap();
assert_eq!(
unsupported,
r#"{"event":"stream_unsupported","version":1,"stream_serial":4294967297,"reason":"link-creation-failed"}"#
);
let failed = serde_json::to_string(
&AudioExclusionEvent::AecFailed {
module_index: 536_870_919,
}
.as_event(),
)
.unwrap();
assert_eq!(
failed,
r#"{"event":"aec_failed","version":1,"module_index":536870919}"#
);
let revoked = serde_json::to_string(
&AudioExclusionEvent::AecRevoked {
module_index: 536_870_919,
}
.as_event(),
)
.unwrap();
assert_eq!(
revoked,
r#"{"event":"aec_revoked","version":1,"module_index":536870919}"#
);
let warning = serde_json::to_string(
&AudioExclusionEvent::ForeignAecWarning {
link_group: "echo-cancel-9999-13".to_string(),
}
.as_event(),
)
.unwrap();
assert_eq!(
warning,
r#"{"event":"foreign_aec_warning","version":1,"link_group":"echo-cancel-9999-13"}"#
);
}
}