Issue 1: in Continuous transmit mode the talk indicator turned
gray-out / mic disabled after ~30 s and could only be revived by
toggling mic mute. Root cause: SAD-079 MissedKeyUpWatchdog
subscribed to AudioTransmitGate.transmit_active and force-cleared
it after 30 s of true. In PTT mode this is correct (stuck key =
bug). In Continuous mode transmit_active is *supposed* to stay
true indefinitely; the watchdog assumption doesn't hold.
Fix: the watchdog now subscribes to a new ptt_held watch on the
TransmitModeSelector (the raw key-state input, not the resolved
gate). In Continuous mode ptt_held is never set true, so the
watchdog never fires. In PTT mode it still fires on a stuck
key-down as before. The session owns the watchdog (was on the
engine) so it survives engine restarts; it's spawned lazily on the
first start_audio.
MissedKeyUpWatchdog gains spawn_on_signal(rx, on_timeout, timeout)
alongside the existing spawn(gate, timeout) — old shape preserved
for backwards compat. run_watchdog generalised to take any
watch::Receiver<bool> + Box<dyn Fn() + Send + Sync>.
Two new tests:
- watchdog_on_signal_does_not_fire_when_ptt_held_stays_false
(the Continuous-mode regression test)
- watchdog_on_signal_fires_when_signal_stays_true
(the stuck-key case still fires)
Issue 2: the Voice Bar stats line said 'PTT on/off' even when the
user was in Continuous mode where no PTT key is involved. Renamed
to 'Mic on/off' (mode-neutral) and l10n-ised the on/off literal:
- en: 'Mic on' / 'Mic off'
- zh: '麦克风 开启' / '麦克风 关闭'
cargo test --workspace --lib: 80 passed / 0 failed / 1 ignored
(was 78, +2 watchdog tests).
flutter analyze: clean (6 pre-existing Radio.groupValue infos).
486 lines
18 KiB
Rust
486 lines
18 KiB
Rust
//! 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<TransmitGateInner>,
|
|
}
|
|
|
|
struct TransmitGateInner {
|
|
flag: Arc<AtomicBool>,
|
|
tx: watch::Sender<bool>,
|
|
}
|
|
|
|
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<bool> {
|
|
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<AtomicBool> {
|
|
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<tokio::task::JoinHandle<()>>,
|
|
}
|
|
|
|
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<dyn Fn() + Send + Sync> = 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<F>(
|
|
signal: tokio::sync::watch::Receiver<bool>,
|
|
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<bool>,
|
|
on_timeout: Box<dyn Fn() + Send + Sync>,
|
|
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<bool>,
|
|
on_timeout: Box<dyn Fn() + Send + Sync>,
|
|
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"
|
|
);
|
|
}
|
|
}
|