//! Cross-platform transmit-mode selector (SAD-083). //! //! Single writer of `transmit_active` other than the missed-key-up //! watchdog (SAD-079). Computes the desired gate state from four //! lock-free inputs: //! //! * `mode` — current [`TransmitMode`] //! * `in_channel` — true when the session is in a voice channel //! * `hard_mute` — final clamp; forces `false` regardless of mode //! * `ptt_held` — raw key state (via [`crate::ReleaseTailTimer`] //! on PTT mode) //! //! Hard-mute is a final clamp; leaving the channel forces the gate //! to `false`. `VoiceActivity` is treated identically to //! `Continuous` per DEC-030 until a VAD implementation lands. //! //! All four inputs are stored as atomics so any thread can update //! them without taking a lock. After each update we call //! [`TransmitModeSelector::recompute`] which writes the resolved //! desired value through the [`AudioTransmitGate`]. use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; use tokio::sync::watch; use crate::ptt::AudioTransmitGate; use crate::transmit_mode::TransmitMode; /// Selector that maps user/session state to the `transmit_active` /// gate (SAD-083). pub struct TransmitModeSelector { gate: std::sync::RwLock, mode: AtomicU8, 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, } impl TransmitModeSelector { /// Construct a selector wired to `gate`. Defaults: /// [`TransmitMode::Ptt`], not in channel, not muted, key /// released. The gate's initial value is left untouched until /// 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, } } /// Rewire the selector to a fresh [`AudioTransmitGate`] /// (typically the gate exposed by a newly-started /// [`crate::AudioEngine`]). Cached mode / channel / mute / /// ptt_held are preserved; the new gate is immediately /// updated to the resolved value. pub fn replace_gate(&self, gate: AudioTransmitGate) { if let Ok(mut g) = self.gate.write() { *g = gate; } self.recompute(); } /// Update the selected mode and re-evaluate the gate. pub fn set_mode(&self, m: TransmitMode) { self.mode.store(m.as_u8(), Ordering::Relaxed); self.recompute(); } /// Current selected mode. pub fn mode(&self) -> TransmitMode { TransmitMode::from_u8(self.mode.load(Ordering::Relaxed)).unwrap_or_default() } /// Update channel membership and re-evaluate. pub fn set_in_channel(&self, v: bool) { self.in_channel.store(v, Ordering::Relaxed); self.recompute(); } /// Current channel-membership flag. pub fn in_channel(&self) -> bool { self.in_channel.load(Ordering::Relaxed) } /// Final-clamp hard mute. When `true` the gate is forced to /// `false` regardless of mode. pub fn set_hard_mute(&self, v: bool) { self.hard_mute.store(v, Ordering::Relaxed); self.recompute(); } /// Current hard-mute flag. pub fn hard_mute(&self) -> bool { self.hard_mute.load(Ordering::Relaxed) } /// PTT key state (set by the platform input backend through /// [`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(); } /// Current PTT-held flag. pub fn ptt_held(&self) -> bool { 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 { 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. pub fn gate(&self) -> AudioTransmitGate { self.gate.read().expect("selector gate lock poisoned").clone() } fn compute(&self) -> bool { if self.hard_mute.load(Ordering::Relaxed) { return false; } if !self.in_channel.load(Ordering::Relaxed) { return false; } match self.mode() { TransmitMode::Ptt => self.ptt_held.load(Ordering::Relaxed), // DEC-030: VoiceActivity behaves as Continuous in v1. TransmitMode::Continuous | TransmitMode::VoiceActivity => true, } } /// Recompute the desired gate state and publish it. Exposed /// for tests; callers normally trigger this implicitly through /// the setter methods. pub fn recompute(&self) { let desired = self.compute(); if let Ok(g) = self.gate.read() { g.set(desired); } } } #[cfg(test)] mod tests { use super::*; fn fresh() -> (AudioTransmitGate, TransmitModeSelector) { let g = AudioTransmitGate::new(false); let s = TransmitModeSelector::new(g.clone()); (g, s) } #[test] fn ptt_requires_channel_and_key() { let (g, s) = fresh(); s.set_mode(TransmitMode::Ptt); s.set_ptt_held(true); assert!(!g.load(), "no channel -> false"); s.set_in_channel(true); assert!(g.load(), "channel + key -> true"); s.set_ptt_held(false); assert!(!g.load(), "key released -> false"); } #[test] fn continuous_ignores_ptt_held() { let (g, s) = fresh(); s.set_mode(TransmitMode::Continuous); s.set_in_channel(true); assert!(g.load(), "channel + continuous -> true"); s.set_ptt_held(true); assert!(g.load()); s.set_ptt_held(false); assert!(g.load(), "continuous independent of key state"); } #[test] fn voice_activity_matches_continuous_v1() { let (g, s) = fresh(); s.set_mode(TransmitMode::VoiceActivity); s.set_in_channel(true); assert!(g.load()); } #[test] fn hard_mute_clamps() { let (g, s) = fresh(); s.set_mode(TransmitMode::Continuous); s.set_in_channel(true); assert!(g.load()); s.set_hard_mute(true); assert!(!g.load(), "hard mute clamps to false"); s.set_hard_mute(false); assert!(g.load()); } #[test] fn leaving_channel_forces_false() { let (g, s) = fresh(); s.set_mode(TransmitMode::Continuous); s.set_in_channel(true); assert!(g.load()); s.set_in_channel(false); assert!(!g.load()); } }