fix(ptt,ui): Continuous mode no longer self-disables after 30 s; rename PTT label to Mic

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).
This commit is contained in:
EdisonJwa
2026-05-16 00:57:10 +08:00
parent 21945979a3
commit 6d4975bd6e
10 changed files with 229 additions and 37 deletions
@@ -21,6 +21,8 @@
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
use tokio::sync::watch;
use crate::ptt::AudioTransmitGate;
use crate::transmit_mode::TransmitMode;
@@ -32,6 +34,14 @@ pub struct TransmitModeSelector {
in_channel: AtomicBool,
hard_mute: AtomicBool,
ptt_held: AtomicBool,
/// Watch channel mirroring `ptt_held` transitions. The
/// missed-key-up watchdog (SAD-079) subscribes here rather
/// than to the gate, so it only fires when an actual PTT key
/// press has been "stuck" for the timeout. In Continuous
/// mode `ptt_held` is never written, so the watchdog never
/// fires — that is the desired behaviour (Continuous is
/// supposed to keep transmitting indefinitely).
ptt_held_tx: watch::Sender<bool>,
}
impl TransmitModeSelector {
@@ -41,12 +51,14 @@ impl TransmitModeSelector {
/// the first mutating call (which then writes the resolved
/// value).
pub fn new(gate: AudioTransmitGate) -> Self {
let (ptt_held_tx, _rx) = watch::channel(false);
Self {
gate: std::sync::RwLock::new(gate),
mode: AtomicU8::new(TransmitMode::default().as_u8()),
in_channel: AtomicBool::new(false),
hard_mute: AtomicBool::new(false),
ptt_held: AtomicBool::new(false),
ptt_held_tx,
}
}
@@ -100,6 +112,10 @@ impl TransmitModeSelector {
/// [`crate::ReleaseTailTimer`]).
pub fn set_ptt_held(&self, v: bool) {
self.ptt_held.store(v, Ordering::Relaxed);
// Best-effort publish for the missed-key-up watchdog;
// watch::Sender::send returns Err only when there are no
// receivers, which is fine.
let _ = self.ptt_held_tx.send(v);
self.recompute();
}
@@ -108,6 +124,15 @@ impl TransmitModeSelector {
self.ptt_held.load(Ordering::Relaxed)
}
/// Subscribe to `ptt_held` transitions. Used by the
/// missed-key-up watchdog (SAD-079) so it fires on the actual
/// PTT-key-down lifetime, not on the resolved `transmit_active`
/// (which is supposed to stay `true` indefinitely in
/// Continuous mode).
pub fn subscribe_ptt_held(&self) -> watch::Receiver<bool> {
self.ptt_held_tx.subscribe()
}
/// Shared snapshot of the underlying gate. Provided for the
/// audio engine's hot read path. Returns a clone so callers
/// don't hold the internal lock.