//! Desktop Push-to-Talk capability model (SRS-195 / SRS-196 / SAD-071 / //! SDD-081 / SDD-082). //! //! This module defines the typed `PttCapabilityLevel` enum, the //! lightweight `PttBackendDescriptor` value the audio engine //! publishes to upstream consumers, the `AudioTransmitGate` object //! that owns the authoritative `transmit_active` flag (SAD-075 / //! SDD-089), and the `MissedKeyUpWatchdog` task (SAD-079 / SDD-092 //! / DEC-028). //! //! Concrete platform backends (`WindowsRawInputBackend`, //! `MacOSEventTapBackend`, `LinuxGnomeWaylandBackend`) live in the //! sibling `backends` module; this file holds the cross-platform //! pieces. use core::fmt; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::Duration; use tokio::sync::watch; use tracing::{info, warn}; /// Detected runtime capability of the active desktop PTT backend. /// /// The reported value shall match runtime behaviour — a backend /// that *could* deliver `L2GlobalHoldToTalk` but lacks the /// user-granted permission or the required compositor support /// reports `L0Focused` (SRS-196 / SysRS-298). /// /// `L4DeviceAware` is reserved per the gen2 v0.9.3 baseline /// (SDD-082) and is **not** produced by any MVP implementation. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum PttCapabilityLevel { /// Focused PTT. Press / release works only while the /// application window has input focus. Mandatory baseline on /// every desktop platform per SysRS-296. L0Focused, /// Global shortcut activation. The OS recognises a global /// accelerator and notifies the application, but hold-to-talk /// semantics may be approximated rather than guaranteed. L1GlobalShortcut, /// Global hold-to-talk. Press and release events are delivered /// while the application is not focused. L2GlobalHoldToTalk, /// Global hold-to-talk plus mouse side buttons (typically /// Mouse4 / Mouse5, sometimes labelled "back" / "forward"). L3GlobalWithMouseButtons, /// Device-aware PTT. Reserved; no MVP implementation produces /// this value (SDD-082). L4DeviceAware, } impl PttCapabilityLevel { /// Short identifier used by the diagnostics sanitizer and by /// the release verification record. Stable across releases — /// release notes and platform-test traces compare against these /// strings. pub fn as_str(self) -> &'static str { match self { Self::L0Focused => "L0Focused", Self::L1GlobalShortcut => "L1GlobalShortcut", Self::L2GlobalHoldToTalk => "L2GlobalHoldToTalk", Self::L3GlobalWithMouseButtons => "L3GlobalWithMouseButtons", Self::L4DeviceAware => "L4DeviceAware", } } /// True when the level represents a global (non-focused) /// behaviour. UI consumers use this to gate the "global PTT /// available" affordance. pub fn is_global(self) -> bool { matches!( self, Self::L1GlobalShortcut | Self::L2GlobalHoldToTalk | Self::L3GlobalWithMouseButtons | Self::L4DeviceAware ) } } impl fmt::Display for PttCapabilityLevel { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.as_str()) } } /// Diagnostics-safe descriptor of the active PTT backend. Carries /// only the fields the privacy policy permits in the user-initiated /// diagnostic export (SRS-202 / DEC-027): the detected capability /// level, a fixed `backend_id` string per implementation, and an /// optional bound-input class (`"keyboard"`, `"mouse-side-button"`, /// …). The raw key code, scan code, virtual-key value, or keysym /// of any user binding is **never** part of this structure. #[derive(Debug, Clone, PartialEq, Eq)] pub struct PttBackendDescriptor { /// Detected runtime capability. pub level: PttCapabilityLevel, /// Stable identifier per implementation. Examples: /// `"focused"`, `"raw-input"`, `"low-level-hook"`, /// `"event-tap"`, `"gnome-wayland-portal"`. pub backend_id: &'static str, /// Coarse description of the bound input. `None` when no /// binding is active. The value is a stable category string, /// never a key code. pub bound_input_class: Option<&'static str>, } impl PttBackendDescriptor { /// The universal Focused-PTT fallback (SDD-087). Every desktop /// platform reports this value until a platform-specific /// global backend lands in a follow-up code milestone. pub const fn focused() -> Self { Self { level: PttCapabilityLevel::L0Focused, backend_id: "focused", bound_input_class: Some("keyboard"), } } } impl Default for PttBackendDescriptor { fn default() -> Self { Self::focused() } } /// Owns the authoritative `transmit_active` AtomicBool plus a /// `tokio::sync::watch` channel so subscribers (notably the /// missed-key-up watchdog) can observe transitions without /// polling. Per SAD-075 / SDD-089 this is the **only** mutator /// path on `transmit_active`; the encoder feed and the platform /// PTT backends call `set` and `load` exclusively. /// /// Cheap to clone — internally an `Arc` over the atomic and the /// watch sender. #[derive(Clone)] pub struct AudioTransmitGate { inner: Arc, } struct TransmitGateInner { flag: Arc, tx: watch::Sender, } impl AudioTransmitGate { /// Construct a new gate initialised to `initial`. pub fn new(initial: bool) -> Self { let (tx, _rx) = watch::channel(initial); Self { inner: Arc::new(TransmitGateInner { flag: Arc::new(AtomicBool::new(initial)), tx, }), } } /// Set the transmit flag. When the value changes, every /// `watch::Receiver` returned by [`Self::subscribe`] observes /// the new value. pub fn set(&self, active: bool) { // We always store on the atomic (it's the hot read path // for the encoder feed) and we always send on the watch // channel — the watch implementation deduplicates so a // repeat `set(true)` does not wake subscribers. self.inner.flag.store(active, Ordering::Relaxed); let _ = self.inner.tx.send(active); } /// Read the current value. Cheap; no locking. pub fn load(&self) -> bool { self.inner.flag.load(Ordering::Relaxed) } /// Subscribe to value transitions. The returned receiver /// observes every distinct transition (the watch channel /// deduplicates same-value sends). pub fn subscribe(&self) -> watch::Receiver { self.inner.tx.subscribe() } /// Live shared handle to the underlying `AtomicBool` for hot /// paths that need to consult the flag once per outbound audio /// frame without going through the gate wrappers. Callers must /// treat the returned Arc as read-only; the gate's `set` /// remains the sole mutator (SAD-075). pub fn flag_arc(&self) -> Arc { self.inner.flag.clone() } } /// Background task that clears `transmit_active` after a configured /// timeout (SAD-079 / SDD-092 / DEC-028). /// /// Subscribes to the gate's watch channel, notes the timestamp of /// each `false -> true` transition, clears the timestamp on each /// `true -> false` transition. If the `true` lifetime exceeds the /// configured ceiling the task self-clears the gate to false and /// emits a sanitised diagnostic record (no key data — DEC-027). pub struct MissedKeyUpWatchdog { handle: Option>, } impl MissedKeyUpWatchdog { /// Default timeout per DEC-028 — 30 seconds. pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); /// Spawn the watchdog against an [`AudioTransmitGate`]. /// /// Provided for backwards compatibility with the original /// SAD-079 wiring (which spec'd the watchdog as subscribing to /// `transmit_active`). In the current implementation this is /// only correct for PTT mode — in Continuous mode the gate is /// supposed to stay `true` indefinitely. Prefer /// [`Self::spawn_on_ptt_held`] which subscribes to the /// raw key-down signal instead and is mode-agnostic. pub fn spawn(gate: AudioTransmitGate, timeout: Duration) -> Self { let rx = gate.subscribe(); let on_timeout: Box = Box::new(move || gate.set(false)); Self::spawn_inner(rx, on_timeout, timeout) } /// Spawn the watchdog against an arbitrary `bool` watch /// channel — typically the `ptt_held` signal exposed by /// [`crate::TransmitModeSelector::subscribe_ptt_held`]. The /// `on_timeout` callback is invoked when the signal has been /// `true` for longer than `timeout` without transitioning back /// to `false`; it should clear whatever upstream produced the /// stuck-true state (e.g. `selector.set_ptt_held(false)`). /// /// This variant is the correct shape for the new selector + /// transmit-mode architecture: it watches the PTT-key signal, /// not the resolved gate, so it never fires in Continuous mode /// where the gate is intentionally held `true`. pub fn spawn_on_signal( signal: tokio::sync::watch::Receiver, on_timeout: F, timeout: Duration, ) -> Self where F: Fn() + Send + Sync + 'static, { Self::spawn_inner(signal, Box::new(on_timeout), timeout) } fn spawn_inner( rx: tokio::sync::watch::Receiver, on_timeout: Box, timeout: Duration, ) -> Self { let handle = tokio::spawn(async move { run_watchdog(rx, on_timeout, timeout).await; }); Self { handle: Some(handle), } } } impl Drop for MissedKeyUpWatchdog { fn drop(&mut self) { if let Some(h) = self.handle.take() { h.abort(); } } } async fn run_watchdog( mut rx: tokio::sync::watch::Receiver, on_timeout: Box, timeout: Duration, ) { // Track whether we are currently in a `signal = true` // window. We don't need the actual timestamp; we just need a // bounded wait that wakes on either the signal going false or // the timeout elapsing. info!( target: "chanora_audio", timeout_secs = timeout.as_secs(), "missed-key-up watchdog spawned" ); loop { // Wait for a transition. if rx.changed().await.is_err() { // Source dropped; exit. return; } let is_active = *rx.borrow_and_update(); if !is_active { // Either the user released, or someone else cleared // the signal. Either way the watchdog has nothing to // do until the next press. continue; } // The signal just went true. Wait for either a release // transition or for the timeout to elapse. let release_or_timeout = tokio::select! { r = rx.changed() => r.map(|_| *rx.borrow_and_update()), _ = tokio::time::sleep(timeout) => { // Timeout — invoke the registered callback and // emit the privacy-safe diagnostic record. The // field set here is deliberately limited to the // values the gen2 sanitizer permits (DEC-027): the // active capability descriptor is owned by the // controller and is not in scope here, so we emit // only the watchdog-relevant fact. warn!( target: "chanora_audio", timeout_secs = timeout.as_secs(), "missed-key-up watchdog fired; clearing ptt_held" ); on_timeout(); continue; } }; match release_or_timeout { Ok(false) => { // Normal release; loop and wait for the next press. } Ok(true) => { // Edge case: same-value double-set. Watch // deduplicates so this should not actually fire, // but we keep waiting on the timeout for the // next iteration. } Err(_) => { // Channel closed; exit. return; } } } } #[cfg(test)] mod tests { use super::*; #[test] fn level_as_str_is_stable() { assert_eq!(PttCapabilityLevel::L0Focused.as_str(), "L0Focused"); assert_eq!( PttCapabilityLevel::L2GlobalHoldToTalk.as_str(), "L2GlobalHoldToTalk" ); assert_eq!( PttCapabilityLevel::L3GlobalWithMouseButtons.as_str(), "L3GlobalWithMouseButtons" ); } #[test] fn level_is_global_classification() { assert!(!PttCapabilityLevel::L0Focused.is_global()); assert!(PttCapabilityLevel::L1GlobalShortcut.is_global()); assert!(PttCapabilityLevel::L2GlobalHoldToTalk.is_global()); assert!(PttCapabilityLevel::L3GlobalWithMouseButtons.is_global()); assert!(PttCapabilityLevel::L4DeviceAware.is_global()); } #[test] fn focused_descriptor_carries_only_safe_fields() { let d = PttBackendDescriptor::focused(); assert_eq!(d.level, PttCapabilityLevel::L0Focused); assert_eq!(d.backend_id, "focused"); assert_eq!(d.bound_input_class, Some("keyboard")); // Compile-time check: the struct shape itself excludes // anything that could carry a key code (DEC-027). let _: &str = d.backend_id; let _: Option<&str> = d.bound_input_class; } #[test] fn gate_set_and_load_roundtrip() { let g = AudioTransmitGate::new(false); assert!(!g.load()); g.set(true); assert!(g.load()); g.set(false); assert!(!g.load()); } #[tokio::test(flavor = "current_thread", start_paused = true)] async fn gate_subscribe_observes_transitions() { let g = AudioTransmitGate::new(false); let mut rx = g.subscribe(); g.set(true); rx.changed().await.unwrap(); assert!(*rx.borrow_and_update()); g.set(false); rx.changed().await.unwrap(); assert!(!*rx.borrow_and_update()); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn watchdog_clears_transmit_after_timeout() { let g = AudioTransmitGate::new(false); let _wd = MissedKeyUpWatchdog::spawn(g.clone(), Duration::from_millis(80)); // Give the watchdog a tick to subscribe before we press. tokio::time::sleep(Duration::from_millis(20)).await; g.set(true); // Wait past the timeout. The watchdog runs on a separate // worker so real-time elapses in parallel. tokio::time::sleep(Duration::from_millis(200)).await; assert!(!g.load(), "watchdog should have cleared transmit_active"); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn watchdog_does_not_clear_on_normal_release() { let g = AudioTransmitGate::new(false); let _wd = MissedKeyUpWatchdog::spawn(g.clone(), Duration::from_millis(80)); tokio::time::sleep(Duration::from_millis(20)).await; g.set(true); tokio::time::sleep(Duration::from_millis(30)).await; g.set(false); // Wait past what would have been the timeout. tokio::time::sleep(Duration::from_millis(200)).await; assert!(!g.load()); // The gate should still be `false` and the next press // should re-arm the watchdog cleanly. g.set(true); tokio::time::sleep(Duration::from_millis(200)).await; assert!(!g.load(), "watchdog should fire on the second press as well"); } /// Continuous-mode regression: when the watchdog subscribes to /// the selector's `ptt_held` signal instead of the resolved /// gate, holding the gate at `true` for longer than the /// timeout (the natural Continuous-mode state) must NOT fire /// the watchdog. This is the bug the user reported as /// "Continuous transmission disabled after some time". #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn watchdog_on_signal_does_not_fire_when_ptt_held_stays_false() { use tokio::sync::watch; // ptt_held watch starts at false and never goes true; the // gate (simulating Continuous mode) is held at true the // whole test. let g = AudioTransmitGate::new(true); let (tx, rx) = watch::channel(false); let cleared = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let cleared_for_cb = cleared.clone(); let _wd = MissedKeyUpWatchdog::spawn_on_signal( rx, move || cleared_for_cb.store(true, std::sync::atomic::Ordering::SeqCst), Duration::from_millis(80), ); tokio::time::sleep(Duration::from_millis(20)).await; // Simulate Continuous mode: gate stays true; ptt_held // stays false the whole window. tokio::time::sleep(Duration::from_millis(200)).await; assert!( g.load(), "Continuous-mode gate is pinned true; watchdog must not touch it" ); assert!( !cleared.load(std::sync::atomic::Ordering::SeqCst), "watchdog callback must not fire while ptt_held stays false" ); drop(tx); } /// Spawn-on-signal still fires when the signal itself is stuck /// at true past the timeout. This is the actual stuck-PTT case /// (e.g. OS suppressed the key-up while the app was minimised). #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn watchdog_on_signal_fires_when_signal_stays_true() { use tokio::sync::watch; let (tx, rx) = watch::channel(false); let cleared = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let cleared_for_cb = cleared.clone(); let _wd = MissedKeyUpWatchdog::spawn_on_signal( rx, move || cleared_for_cb.store(true, std::sync::atomic::Ordering::SeqCst), Duration::from_millis(80), ); tokio::time::sleep(Duration::from_millis(20)).await; let _ = tx.send(true); tokio::time::sleep(Duration::from_millis(200)).await; assert!( cleared.load(std::sync::atomic::Ordering::SeqCst), "watchdog callback must fire when signal stays true past the timeout" ); } }