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
+29
View File
@@ -257,6 +257,13 @@ pub struct ChanoraSession {
/// Release-tail timer (SDD-096). Drives the selector's
/// `ptt_held` input from PTT key edges.
release_tail: Arc<ReleaseTailTimer>,
/// Missed-key-up watchdog (SAD-079 / DEC-028). Subscribes to
/// `voice_selector.subscribe_ptt_held()` so it only fires when
/// an actual PTT key has been "stuck" for the configured
/// timeout (default 30 s). Lives on the session because it
/// must outlive engine restarts. Spawned lazily on the first
/// `start_audio` because it needs a tokio runtime context.
ptt_watchdog: Arc<Mutex<Option<chanora_audio::MissedKeyUpWatchdog>>>,
/// Last PTT binding the user requested via `set_ptt_binding`.
/// Kept here so it survives the gap between user-saving a
/// binding (which may happen before any audio is running) and
@@ -291,6 +298,7 @@ impl ChanoraSession {
voice_selector: selector,
release_tail,
pending_binding: Arc::new(Mutex::new(None)),
ptt_watchdog: Arc::new(Mutex::new(None)),
}
}
@@ -554,6 +562,27 @@ impl ChanoraSession {
let controller = ptt::PttController::new(self.release_tail.clone());
state.ptt_controller = Some(controller.clone());
// Spawn the missed-key-up watchdog on first start_audio.
// It subscribes to the selector's `ptt_held` watch — NOT
// the real gate — so it only fires when the user has
// actually been holding the PTT key for >30 s. In
// Continuous mode `ptt_held` is never set true, so the
// watchdog never fires (the fix for the bug where
// Continuous transmission would disable itself after the
// 30 s default timeout).
{
let mut wd = self.ptt_watchdog.lock().await;
if wd.is_none() {
let signal = self.voice_selector.subscribe_ptt_held();
let selector = self.voice_selector.clone();
*wd = Some(chanora_audio::MissedKeyUpWatchdog::spawn_on_signal(
signal,
move || selector.set_ptt_held(false),
chanora_audio::MissedKeyUpWatchdog::DEFAULT_TIMEOUT,
));
}
}
// Apply any binding the user saved before audio was running
// (SDD-094 follow-up). Persistence + caching happen in
// `set_ptt_binding`; here we forward the cached value to