//! 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 (0–500 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, pending: Arc>>>, 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, 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, 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); } }