Files
chanora/crates/chanora_audio/src/release_tail.rs
T
EdisonJwa 33d80894d0 feat(ptt,audio): wire PTT key edges through ReleaseTailTimer (SDD-096)
Previously the PttController handed its real AudioTransmitGate to
the platform backend and the backend wrote transmit_active directly
on every key edge — bypassing the 200 ms release tail and the
TransmitMode selector entirely. The tail timer was constructed and
exposed on ChanoraSession but never received any input, so SDD-096
and SRS-206 were spec-only.

Wire it: PttController now owns a synthetic 'press-edge gate' which
it hands to the backend in place of the real one. An internal
edge-watcher task subscribes to that press-gate, translating
true/false transitions into ReleaseTailTimer.key_down/key_up calls.
The release-tail timer feeds the selector's ptt_held input; the
selector recomputes transmit_active honouring mode, in_channel,
and hard_mute, and writes the real gate. Single owner of
transmit_active is preserved (SAD-083 invariant).

PttController::new now takes Arc<ReleaseTailTimer> instead of
AudioTransmitGate; ChanoraSession threads its session-scoped timer
through both the start_audio path and the supervisor reconnect
path. The legacy bridge set_ptt call (still used by the in-focus
Listener fallback and the e2e test) is rerouted through the timer
so the same tail and mute semantics apply uniformly.

ReleaseTailTimer gains force_release() — cancels any pending task
AND clears the selector's ptt_held. PttController::stop uses it so
shutdown can't leave transmit_active stuck at true.

Tests
- press_edge_drives_selector_through_release_tail: backend press
  edge → real gate follows, key_up → tail keeps gate true for tail
  window then clears.
- stop_clears_press_and_cancels_tail: stop() drops transmit even
  with a tail in flight.
- e2e test now sets release_tail_ms=0 + waits one tick so the
  pttActive=false assertion isn't racing the default 200 ms tail.

cargo test --workspace --lib: 74 passed / 0 failed / 1 ignored
(+2 new tests vs. the previous 72).
flutter analyze: clean (6 pre-existing Radio.groupValue infos).
2026-05-16 00:05:54 +08:00

192 lines
6.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Release-tail timer (SDD-096).
//!
//! When a PTT key is released we don't immediately cut transmission
//! — we keep the gate open for a short configurable tail (0500 ms,
//! default 200 ms) so room reverb and the trailing edge of words
//! aren't clipped. A subsequent `key_down` within the tail window
//! cancels the pending release so transmission stays continuous.
//!
//! The timer drives the `ptt_held` input of a
//! [`crate::transmit_selector::TransmitModeSelector`] rather than
//! the [`crate::AudioTransmitGate`] directly — the selector then
//! decides whether the desired gate state is `true` or `false`
//! based on the current [`crate::TransmitMode`]. This keeps a
//! single owner of `transmit_active` (SAD-083).
//!
//! Threading model:
//!
//! * `tail_ms` is an [`AtomicU32`] so config changes are visible
//! immediately to any in-flight release task.
//! * The pending [`JoinHandle`] is held in a [`std::sync::Mutex`].
//! The mutex is only ever touched on PTT *edge* transitions
//! (`key_down` / `key_up`) — never on the audio frame hot path
//! — so the brief acquisition is acceptable.
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::task::JoinHandle;
use crate::transmit_selector::TransmitModeSelector;
/// Maximum configurable release-tail, in milliseconds.
pub const MAX_TAIL_MS: u32 = 500;
/// Default release-tail (SDD-096).
pub const DEFAULT_TAIL_MS: u32 = 200;
/// Coalesces a PTT key release into a deferred selector update.
///
/// Cheap to clone via `Arc`.
pub struct ReleaseTailTimer {
selector: Arc<TransmitModeSelector>,
pending: Arc<Mutex<Option<JoinHandle<()>>>>,
tail_ms: AtomicU32,
}
impl ReleaseTailTimer {
/// Construct a new timer wired to `selector`. `tail_ms` is
/// clamped to `0..=MAX_TAIL_MS` (SDD-096).
pub fn new(selector: Arc<TransmitModeSelector>, tail_ms: u32) -> Self {
Self {
selector,
pending: Arc::new(Mutex::new(None)),
tail_ms: AtomicU32::new(tail_ms.min(MAX_TAIL_MS)),
}
}
/// Update the configured tail, clamped to `0..=MAX_TAIL_MS`.
pub fn set_tail_ms(&self, ms: u32) {
self.tail_ms.store(ms.min(MAX_TAIL_MS), Ordering::Relaxed);
}
/// Current configured tail (post-clamp).
pub fn tail_ms(&self) -> u32 {
self.tail_ms.load(Ordering::Relaxed)
}
/// Notify the timer that the PTT key went down. Cancels any
/// pending release and immediately marks the selector's
/// `ptt_held` input as `true`.
pub fn key_down(&self) {
self.cancel_pending();
self.selector.set_ptt_held(true);
}
/// Notify the timer that the PTT key went up. Spawns a task
/// that sleeps for `tail_ms` and then clears the selector's
/// `ptt_held` input. A subsequent [`Self::key_down`] within
/// the window cancels this task.
pub fn key_up(&self) {
let tail = self.tail_ms();
let selector = self.selector.clone();
let new_handle = tokio::spawn(async move {
if tail > 0 {
tokio::time::sleep(Duration::from_millis(tail as u64)).await;
}
selector.set_ptt_held(false);
});
if let Ok(mut g) = self.pending.lock() {
if let Some(prev) = g.replace(new_handle) {
prev.abort();
}
}
}
/// Cancel any pending release task and leave the selector's
/// `ptt_held` flag at whatever value it currently holds.
pub fn cancel(&self) {
self.cancel_pending();
}
/// Cancel any pending release task and immediately clear the
/// selector's `ptt_held` input. Used on PTT controller
/// shutdown to guarantee `transmit_active` does not get stuck
/// at `true` if the user's last action was a key-down with
/// no matching key-up reaching us before the shutdown.
pub fn force_release(&self) {
self.cancel_pending();
self.selector.set_ptt_held(false);
}
fn cancel_pending(&self) {
if let Ok(mut g) = self.pending.lock() {
if let Some(h) = g.take() {
h.abort();
}
}
}
}
impl Drop for ReleaseTailTimer {
fn drop(&mut self) {
self.cancel_pending();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ptt::AudioTransmitGate;
use crate::transmit_mode::TransmitMode;
fn setup(tail_ms: u32) -> (AudioTransmitGate, Arc<TransmitModeSelector>, ReleaseTailTimer) {
let gate = AudioTransmitGate::new(false);
let sel = Arc::new(TransmitModeSelector::new(gate.clone()));
sel.set_mode(TransmitMode::Ptt);
sel.set_in_channel(true);
let timer = ReleaseTailTimer::new(sel.clone(), tail_ms);
(gate, sel, timer)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn release_clears_after_tail() {
let (gate, _sel, timer) = setup(80);
timer.key_down();
assert!(gate.load());
timer.key_up();
// Still transmitting during the tail.
tokio::time::sleep(Duration::from_millis(20)).await;
assert!(gate.load(), "should still be true during tail");
// After the tail elapses, gate clears.
tokio::time::sleep(Duration::from_millis(120)).await;
assert!(!gate.load(), "gate should clear after tail");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn redown_within_tail_cancels_release() {
let (gate, _sel, timer) = setup(200);
timer.key_down();
timer.key_up();
tokio::time::sleep(Duration::from_millis(20)).await;
timer.key_down();
// Wait past the original tail; gate must still be true.
tokio::time::sleep(Duration::from_millis(250)).await;
assert!(gate.load(), "subsequent key_down should cancel pending release");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn zero_tail_clears_immediately() {
let (gate, _sel, timer) = setup(0);
timer.key_down();
assert!(gate.load());
timer.key_up();
tokio::time::sleep(Duration::from_millis(30)).await;
assert!(!gate.load());
}
#[test]
fn set_tail_ms_clamps_to_max() {
let gate = AudioTransmitGate::new(false);
let sel = Arc::new(TransmitModeSelector::new(gate));
let timer = ReleaseTailTimer::new(sel, 100);
timer.set_tail_ms(99999);
assert_eq!(timer.tail_ms(), MAX_TAIL_MS);
timer.set_tail_ms(0);
assert_eq!(timer.tail_ms(), 0);
timer.set_tail_ms(MAX_TAIL_MS);
assert_eq!(timer.tail_ms(), MAX_TAIL_MS);
}
}