Files
chanora/crates/chanora_audio/src/transmit_selector.rs
T

527 lines
20 KiB
Rust

//! 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 driven by Rust-owned VAD state.
//!
//! 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;
/// Resolved microphone-permission state mirrored from the platform
/// permission requester (SDD-106 §5). The audio-engine side treats
/// any non-`Granted` value as an authoritative clamp on the
/// transmit gate (SDD-106 §6, SRS-209).
///
/// Wire encoding is a plain `u8` so it can live in an
/// `AtomicU8` alongside the rest of [`TransmitModeSelector`]'s
/// inputs without taking a lock.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum PermissionGate {
/// No resolved value seen yet. [`TransmitModeSelector::compute`]
/// treats this as not-granted, so any platform that publishes
/// `Unknown` (e.g. the Android bridge before
/// `checkSelfPermission` resolves) fails safe to listen-only
/// per SRS-209. Note: the selector's *constructor default* is
/// [`PermissionGate::Granted`] (see
/// [`TransmitModeSelector::new`]) to preserve non-Android
/// desktop behaviour where no permission event is ever
/// published; the Android bridge overwrites the slot with the
/// resolved cold-launch state via
/// [`TransmitModeSelector::set_permission_state`] before
/// `voice_join`.
Unknown = 0,
/// Permission granted; normal PTT / continuous evaluation
/// resumes.
Granted = 1,
/// Permission denied (re-promptable). Transmit clamped to
/// false (SDD-106 §6).
Denied = 2,
/// Permission permanently denied. Transmit clamped to false
/// (SDD-106 §6); the UI is expected to deep-link to system
/// settings (SDD-106 §3).
PermanentlyDenied = 3,
}
impl PermissionGate {
fn from_u8(v: u8) -> Self {
match v {
1 => Self::Granted,
2 => Self::Denied,
3 => Self::PermanentlyDenied,
_ => Self::Unknown,
}
}
fn as_u8(self) -> u8 {
self as u8
}
/// True when the platform reports the microphone permission is
/// usable. Any other state must clamp the transmit gate
/// (SDD-106 §6).
pub fn is_granted(self) -> bool {
matches!(self, Self::Granted)
}
}
/// Selector that maps user/session state to the `transmit_active`
/// gate (SAD-083).
pub struct TransmitModeSelector {
gate: std::sync::RwLock<AudioTransmitGate>,
mode: AtomicU8,
in_channel: AtomicBool,
hard_mute: AtomicBool,
ptt_held: AtomicBool,
voice_activity_open: AtomicBool,
/// SDD-106 §5/§6 / SRS-209: latest resolved microphone
/// permission state. Stored as a `u8` so writes from the
/// JNI thread (Android permission requester → bridge) and
/// reads from the audio thread are lock-free. The
/// constructor default is [`PermissionGate::Granted`] to
/// preserve non-Android desktop behaviour (no platform
/// permission event is ever published there, so the field
/// stays inert). The Android bridge overwrites this with the
/// resolved cold-launch state via
/// [`Self::set_permission_state`] before `voice_join` per
/// SRS-209; any non-`Granted` value (including a
/// platform-published `Unknown`) clamps transmit to false
/// in [`Self::compute`].
///
/// See [`Self::set_permission_state`] for the SDD-106 §6
/// precedence-order documentation.
permission_state: AtomicU8,
/// 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 {
/// 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),
voice_activity_open: AtomicBool::new(false),
// SDD-106 §5: default to Granted on construction so
// non-Android hosts (which never publish a permission
// event) are not silently clamped. The Android bridge
// overwrites this with the resolved cold-launch state
// before voice_join per SRS-209.
permission_state: AtomicU8::new(PermissionGate::Granted.as_u8()),
ptt_held_tx,
}
}
/// Update the cached microphone-permission state and
/// re-evaluate the gate (SDD-106 §5/§6, SRS-209). Called from
/// the Android JNI permission hook in `chanora_bridge`; on
/// non-Android platforms this method is unused. The check
/// inside [`Self::compute`] orders the permission clamp BEFORE
/// hard-mute, channel membership, PTT, and transmit mode — a
/// permission revocation immediately silences the microphone
/// even mid-PTT.
pub fn set_permission_state(&self, state: PermissionGate) {
self.permission_state
.store(state.as_u8(), Ordering::Relaxed);
self.recompute();
}
/// Current cached microphone-permission state. Exposed for
/// diagnostics and tests; the audio engine reads through the
/// resolved transmit gate, not this field directly.
pub fn permission_state(&self) -> PermissionGate {
PermissionGate::from_u8(self.permission_state.load(Ordering::Relaxed))
}
/// 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)
}
/// Rust-owned VAD gate input for VoiceActivity mode.
pub fn set_voice_activity_open(&self, v: bool) {
self.voice_activity_open.store(v, Ordering::Relaxed);
self.recompute();
}
/// Current Rust-owned VAD gate state.
pub fn voice_activity_open(&self) -> bool {
self.voice_activity_open.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.
pub fn gate(&self) -> AudioTransmitGate {
self.gate
.read()
.expect("selector gate lock poisoned")
.clone()
}
fn compute(&self) -> bool {
// SDD-106 §6 / SRS-209: permission clamp has the highest
// precedence. If the platform reports anything other than
// `Granted` (including the cold-launch `Unknown`), transmit
// is forced to false regardless of PTT key state, transmit
// mode, channel membership, or hard-mute. This authoritative
// clamp lives on the Rust side so a revocation event
// received mid-PTT silences the microphone even if the Dart
// UI has not yet re-rendered.
if !PermissionGate::from_u8(self.permission_state.load(Ordering::Relaxed)).is_granted() {
return false;
}
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),
TransmitMode::Continuous => true,
TransmitMode::VoiceActivity => self.voice_activity_open.load(Ordering::Relaxed),
}
}
/// 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)]
// Permission-clamp tests below cover SDD-106 §6 / DEC-030 /
// SRS-209 via SWE4-UV-053 (Denied/PermanentlyDenied/Unknown
// clamp), SWE4-UV-054 (Granted release), and SWE4-UV-055
// (non-RECORD_AUDIO permissions do not clamp). Precedence and
// replace_gate extension tests cite SWE4-UV-053 as the
// precedence-ordering extension.
mod tests {
use super::*;
fn fresh() -> (AudioTransmitGate, TransmitModeSelector) {
let g = AudioTransmitGate::new(false);
let s = TransmitModeSelector::new(g.clone());
(g, s)
}
/// SWE4-UV-037 (PTT + gate): PTT requires both in-channel and key-held.
#[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");
}
/// SWE4-UV-037: continuous-transmit mode ignores PTT key state.
#[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");
}
/// SWE4-UV-037: voice-activity mode follows VAD state.
#[test]
fn voice_activity_requires_vad_open() {
let (g, s) = fresh();
s.set_mode(TransmitMode::VoiceActivity);
s.set_in_channel(true);
assert!(!g.load());
s.set_voice_activity_open(true);
assert!(g.load());
s.set_voice_activity_open(false);
assert!(!g.load());
}
/// SWE4-UV-037 / SWE4-UV-041: hard-mute clamps the transmit
/// gate to false (mirrors the Android permission-revoked clamp).
#[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());
}
/// SWE4-UV-037: leaving the channel forces the gate to false.
#[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());
}
/// SDD-106 §6 / SRS-209 / DEC-030 / SWE4-UV-053: a `Denied`
/// (or `PermanentlyDenied`, or cold-launch `Unknown`)
/// permission state clamps transmit to false even with PTT
/// held, channel joined, and hard-mute released. The clamp
/// must hold from the instant the event is published.
#[test]
fn permission_state_denied_clamps_transmit_to_false() {
let (g, s) = fresh();
s.set_mode(TransmitMode::Ptt);
s.set_in_channel(true);
s.set_ptt_held(true);
// Sanity: with default Granted the gate is hot.
assert!(g.load(), "baseline: PTT + channel + granted -> true");
s.set_permission_state(PermissionGate::Denied);
assert!(
!g.load(),
"SDD-106 §6: Denied clamps transmit regardless of PTT/channel"
);
s.set_permission_state(PermissionGate::PermanentlyDenied);
assert!(
!g.load(),
"SDD-106 §6: PermanentlyDenied clamps transmit regardless of PTT/channel"
);
// Continuous + permission denied is also clamped — the
// mode does not override the permission check.
s.set_mode(TransmitMode::Continuous);
assert!(
!g.load(),
"SDD-106 §6: permission clamp wins over Continuous mode"
);
// And the Unknown cold-launch state is treated as not
// granted (fail-safe per SRS-209).
s.set_permission_state(PermissionGate::Unknown);
assert!(
!g.load(),
"SDD-106 §6 / SRS-209: Unknown defaults to listen-only"
);
}
/// SDD-106 §6 / SWE4-UV-054: after a Denied state, a
/// subsequent Granted transition releases the clamp and
/// normal PTT-driven evaluation resumes on the next state
/// tick.
#[test]
fn permission_state_granted_releases_clamp() {
let (g, s) = fresh();
s.set_mode(TransmitMode::Ptt);
s.set_in_channel(true);
s.set_ptt_held(true);
s.set_permission_state(PermissionGate::Denied);
assert!(!g.load(), "precondition: clamped under Denied");
s.set_permission_state(PermissionGate::Granted);
assert!(
g.load(),
"SDD-106 §6: Granted restores normal PTT-driven evaluation"
);
// Releasing the key drops the gate as usual — the clamp is
// no longer in effect so PTT semantics apply.
s.set_ptt_held(false);
assert!(!g.load());
}
/// SDD-106 §5/§6 / SWE4-UV-055: a permission event for a
/// non-microphone permission (e.g. `POST_NOTIFICATIONS`)
/// must not affect the transmit gate. The selector only
/// exposes a single permission slot — by contract the
/// bridge filters to `RECORD_AUDIO` before calling
/// [`set_permission_state`]. This test pins the selector's
/// contract: never-written means never-clamped from this
/// code path.
#[test]
fn permission_state_for_other_permission_does_not_clamp() {
let (g, s) = fresh();
s.set_mode(TransmitMode::Continuous);
s.set_in_channel(true);
assert!(g.load());
// The selector exposes no setter for non-RECORD_AUDIO
// permissions; demonstrating contractually that without a
// call to set_permission_state, the gate is unaffected.
// (The bridge JNI hook is responsible for filtering.) The
// selector remains in its Granted default.
assert_eq!(s.permission_state(), PermissionGate::Granted);
assert!(
g.load(),
"SDD-106 §5: unrelated permission events leave transmit unaffected"
);
}
/// SDD-106 §6 / SWE4-UV-053 (precedence-ordering extension):
/// the permission clamp and hard-mute clamp compose
/// independently. When both are active the gate is false;
/// releasing the permission clamp alone leaves the gate
/// false because hard-mute still holds; releasing both with
/// PTT held + in-channel restores transmit.
#[test]
fn permission_clamp_takes_precedence_over_hard_mute() {
let (g, s) = fresh();
s.set_mode(TransmitMode::Ptt);
s.set_in_channel(true);
s.set_ptt_held(true);
s.set_hard_mute(true);
s.set_permission_state(PermissionGate::Denied);
assert!(!g.load(), "both clamps active -> false");
// Release the permission clamp; hard-mute still holds.
s.set_permission_state(PermissionGate::Granted);
assert!(
!g.load(),
"SDD-106 §6: hard-mute continues to clamp after permission release"
);
// Release hard-mute too; PTT + channel + granted -> true.
s.set_hard_mute(false);
assert!(g.load(), "both clamps cleared, PTT held -> true");
}
/// SDD-106 §6 / SWE4-UV-053 (precedence-ordering extension):
/// the permission clamp survives a [`TransmitModeSelector::
/// replace_gate`] hot-swap. After the audio engine restarts
/// and a fresh gate is wired in, the clamp must still be
/// observed by the new gate immediately.
#[test]
fn permission_clamp_survives_replace_gate() {
let (_g, s) = fresh();
s.set_mode(TransmitMode::Continuous);
s.set_in_channel(true);
s.set_permission_state(PermissionGate::Denied);
// Hot-swap to a fresh gate (simulating an audio-engine
// restart). The new gate must observe the clamp on the
// first recompute.
let new_gate = AudioTransmitGate::new(true);
s.replace_gate(new_gate.clone());
assert!(
!new_gate.load(),
"SDD-106 §6: clamp survives replace_gate hot-swap"
);
// Releasing the clamp re-enables transmit on the new gate.
s.set_permission_state(PermissionGate::Granted);
assert!(new_gate.load(), "Granted on new gate -> true");
}
}