feat(ios,p0): iOS P0 platform, audio fixes, channel UX

This commit is contained in:
Edison Jwa
2026-05-17 22:00:00 +09:00
parent a1fefc8ab6
commit 7a59f5b9a1
38 changed files with 1705 additions and 674 deletions
+56 -18
View File
@@ -216,12 +216,7 @@ impl AudioEngine {
// other platform stays on the cpal / SDL flow below.
#[cfg(target_os = "ios")]
{
return Self::start_with_gate_ios(
cfg,
voice_out_tx,
voice_in_rx,
transmit_gate,
);
return Self::start_with_gate_ios(cfg, voice_out_tx, voice_in_rx, transmit_gate);
}
#[cfg(not(target_os = "ios"))]
{
@@ -684,6 +679,55 @@ impl AudioEngine {
info!(target: "chanora_audio", "audio engine stopped");
}
/// iOS-only: restart the underlying VoiceProcessingIO unit after
/// route changes.
pub fn ios_restart_voice_unit(&self) -> Result<(), AudioError> {
#[cfg(target_os = "ios")]
{
let mut guard = self._ios_voice_unit.lock().unwrap();
let unit = guard
.as_mut()
.ok_or_else(|| AudioError::Backend("ios voice unit not running".to_string()))?;
return unit.restart();
}
#[cfg(not(target_os = "ios"))]
{
Ok(())
}
}
/// iOS-only: pause the underlying VoiceProcessingIO unit.
pub fn ios_pause_voice_unit(&self) -> Result<(), AudioError> {
#[cfg(target_os = "ios")]
{
let mut guard = self._ios_voice_unit.lock().unwrap();
let unit = guard
.as_mut()
.ok_or_else(|| AudioError::Backend("ios voice unit not running".to_string()))?;
return unit.pause();
}
#[cfg(not(target_os = "ios"))]
{
Ok(())
}
}
/// iOS-only: resume the underlying VoiceProcessingIO unit.
pub fn ios_resume_voice_unit(&self) -> Result<(), AudioError> {
#[cfg(target_os = "ios")]
{
let mut guard = self._ios_voice_unit.lock().unwrap();
let unit = guard
.as_mut()
.ok_or_else(|| AudioError::Backend("ios voice unit not running".to_string()))?;
return unit.resume();
}
#[cfg(not(target_os = "ios"))]
{
Ok(())
}
}
/// Set the **transmission gate** (SRS-201). When true the
/// encoder feed is allowed to emit Opus frames; when false the
/// captured audio is discarded before encoding. This is the
@@ -768,8 +812,7 @@ impl AudioEngine {
/// sensible range internally.
pub fn set_output_gain(&self, gain: f32) {
let clamped = gain.clamp(0.0, 4.0);
self.output_gain
.store(clamped.to_bits(), Ordering::Relaxed);
self.output_gain.store(clamped.to_bits(), Ordering::Relaxed);
}
/// Current master output gain.
@@ -818,12 +861,8 @@ fn try_open_capture(
in_stream_cfg.buffer_size = cpal::BufferSize::Default;
}
let mut opus_enc = OpusEncoder::new(
OpusSampleRate::Hz48000,
OpusChannels::Mono,
OpusApp::Voip,
)
.map_err(|e| AudioError::Opus(format!("encoder new: {e}")))?;
let mut opus_enc = OpusEncoder::new(OpusSampleRate::Hz48000, OpusChannels::Mono, OpusApp::Voip)
.map_err(|e| AudioError::Opus(format!("encoder new: {e}")))?;
// Opus VOIP tuning. Defaults give us 'auto' bitrate (can drop
// to ~6 kbps during silence \u2014 which sounds garbled when
@@ -1147,13 +1186,12 @@ where
// `same_rate` is the common case where the device is already at
// 48 kHz — bypass the resampler entirely.
let same_rate = dev_sample_rate == SAMPLE_RATE;
let resample_state: Arc<Mutex<PlaybackResampleState>> = Arc::new(Mutex::new(
PlaybackResampleState {
let resample_state: Arc<Mutex<PlaybackResampleState>> =
Arc::new(Mutex::new(PlaybackResampleState {
pos: 0.0,
last_l: 0.0,
last_r: 0.0,
},
));
}));
// Reusable scratch buffer for 48 kHz stereo samples coming out
// of AudioHandler. Allocating a fresh `Vec` per cpal callback on
// glibc malloc was costing measurable time on the realtime audio
+58 -21
View File
@@ -85,8 +85,8 @@ use audiopus::{
};
use coreaudio::audio_unit::audio_format::LinearPcmFlags;
use coreaudio::audio_unit::render_callback::{self, data};
use coreaudio::audio_unit::{AudioUnit, Element, SampleFormat, Scope, StreamFormat};
use coreaudio::audio_unit::IOType;
use coreaudio::audio_unit::{AudioUnit, Element, SampleFormat, Scope, StreamFormat};
use tokio::sync::mpsc;
use tracing::{debug, error, info, warn};
use tsclientlib::audio::AudioHandler;
@@ -110,7 +110,6 @@ const FRAME_SAMPLES_MONO: usize = 960;
/// shared `crate::framing` module.
const MAX_OPUS_FRAME: usize = 1275;
/// Sample rate every layer above us assumes. Matches the Opus
/// encoder rate, the `tsclientlib::AudioHandler` mix rate, and the
/// sample rate we ask iOS to give us via VPIO's StreamFormat.
@@ -175,12 +174,9 @@ impl IosCaptureState {
frames_sent: Arc<AtomicU32>,
mic_gain: f32,
) -> Result<Self, AudioError> {
let mut encoder = OpusEncoder::new(
OpusSampleRate::Hz48000,
OpusChannels::Mono,
OpusApp::Voip,
)
.map_err(|e| AudioError::Opus(format!("encoder new (ios): {e}")))?;
let mut encoder =
OpusEncoder::new(OpusSampleRate::Hz48000, OpusChannels::Mono, OpusApp::Voip)
.map_err(|e| AudioError::Opus(format!("encoder new (ios): {e}")))?;
// VoIP-tuned settings — bitrate 32 kbps, complexity 10,
// inband FEC on, packet-loss-perc 5. Soft-fail each setter
@@ -438,12 +434,8 @@ impl IosVoiceUnit {
// scratch are owned by the closure — no Mutex needed
// because the input callback is the sole writer/reader on
// the audio thread.
let mut capture_state = IosCaptureState::new(
voice_out_tx,
transmit_active,
frames_sent,
mic_gain,
)?;
let mut capture_state =
IosCaptureState::new(voice_out_tx, transmit_active, frames_sent, mic_gain)?;
unit.set_input_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
// VPIO with our pinned stream format delivers
@@ -530,10 +522,21 @@ impl IosVoiceUnit {
// earlier callbacks (when scratch was bigger) would
// leak through otherwise.
scratch_stereo[..needed].fill(0.0);
// Lock + fill. Same pattern as Linux/SDL output.
{
let mut h = handler_for_render.lock().unwrap();
let _removed = h.fill_buffer(&mut scratch_stereo[..needed]);
// Non-blocking fill on the realtime callback thread.
// If the inbound forwarder currently owns this mutex,
// emit this period as silence instead of blocking and
// risking an AudioUnit underrun pop/click.
match handler_for_render.try_lock() {
Ok(mut h) => {
let _removed = h.fill_buffer(&mut scratch_stereo[..needed]);
}
Err(std::sync::TryLockError::WouldBlock) => {
// scratch_stereo is already zeroed above.
}
Err(std::sync::TryLockError::Poisoned(e)) => {
// Never panic on the realtime IO thread.
warn!(target: "chanora_audio", "AudioHandler mutex poisoned: {e}");
}
}
// Downmix stereo f32 -> mono i16 with master gain.
@@ -652,9 +655,43 @@ impl IosVoiceUnit {
),
}
Ok(Self {
unit,
})
Ok(Self { unit })
}
/// Restart the audio unit after route change handling.
///
/// Route rebinding on iOS is most reliable when we bounce the
/// VoiceProcessingIO unit through an uninitialize/reinitialize
/// cycle, then start again.
pub fn restart(&mut self) -> Result<(), AudioError> {
self.unit
.stop()
.map_err(|e| AudioError::Backend(format!("vpio restart stop: {e}")))?;
self.unit
.uninitialize()
.map_err(|e| AudioError::Backend(format!("vpio restart uninit: {e}")))?;
self.unit
.initialize()
.map_err(|e| AudioError::Backend(format!("vpio restart init: {e}")))?;
self.unit
.start()
.map_err(|e| AudioError::Backend(format!("vpio restart start: {e}")))?;
info!(target: "chanora_audio", "ios VPIO audio unit restarted");
Ok(())
}
/// Pause the audio unit during an interruption.
pub fn pause(&mut self) -> Result<(), AudioError> {
self.unit
.stop()
.map_err(|e| AudioError::Backend(format!("vpio pause stop: {e}")))
}
/// Resume the audio unit after an interruption.
pub fn resume(&mut self) -> Result<(), AudioError> {
self.unit
.start()
.map_err(|e| AudioError::Backend(format!("vpio resume start: {e}")))
}
}
+1 -3
View File
@@ -42,9 +42,7 @@ mod sdl_output;
mod ios_voice_unit;
pub use engine::{AudioEngine, AudioEngineConfig};
pub use ptt::{
AudioTransmitGate, MissedKeyUpWatchdog, PttBackendDescriptor, PttCapabilityLevel,
};
pub use ptt::{AudioTransmitGate, MissedKeyUpWatchdog, PttBackendDescriptor, PttCapabilityLevel};
pub use ptt_backends::{
select as select_ptt_backend, DesktopPttBackend, FocusedPttBackend, PttBackendError,
PttBinding, PttInputClass,
+4 -1
View File
@@ -421,7 +421,10 @@ mod tests {
// should re-arm the watchdog cleanly.
g.set(true);
tokio::time::sleep(Duration::from_millis(200)).await;
assert!(!g.load(), "watchdog should fire on the second press as well");
assert!(
!g.load(),
"watchdog should fire on the second press as well"
);
}
/// Continuous-mode regression: when the watchdog subscribes to
+19 -28
View File
@@ -46,9 +46,7 @@ use zbus::blocking::Connection as BlockingConnection;
use zbus::zvariant::{OwnedValue, Value};
use zbus::{proxy, Connection as AsyncConnection};
use super::{
AudioTransmitGate, DesktopPttBackend, PttBackendError, PttBinding, PttInputClass,
};
use super::{AudioTransmitGate, DesktopPttBackend, PttBackendError, PttBinding, PttInputClass};
use crate::ptt::{PttBackendDescriptor, PttCapabilityLevel};
/// Try to construct a `LinuxGnomeWaylandBackend`. Returns `None`
@@ -84,7 +82,9 @@ fn is_gnome_on_wayland() -> bool {
let desktop = env::var("XDG_CURRENT_DESKTOP")
.unwrap_or_default()
.to_ascii_lowercase();
desktop.split(':').any(|s| s == "gnome" || s == "gnome-flashback")
desktop
.split(':')
.any(|s| s == "gnome" || s == "gnome-flashback")
}
// ---------- D-Bus proxies ----------
@@ -171,11 +171,7 @@ trait Request {
/// is `0` for success, `1` for user cancellation, `2` for
/// other failure.
#[zbus(signal)]
fn response(
&self,
response: u32,
results: HashMap<String, OwnedValue>,
) -> zbus::Result<()>;
fn response(&self, response: u32, results: HashMap<String, OwnedValue>) -> zbus::Result<()>;
/// Cancel an in-flight request.
fn close(&self) -> zbus::Result<()>;
@@ -246,13 +242,11 @@ impl LinuxGnomeWaylandBackend {
let join_result = std::thread::Builder::new()
.name("chanora-ptt-portal-probe".to_string())
.spawn(|| -> Result<u32, String> {
let conn = BlockingConnection::session()
.map_err(|e| format!("session bus: {e}"))?;
let proxy = BlockingGlobalShortcutsProxy::new(&conn)
.map_err(|e| format!("proxy: {e}"))?;
proxy
.version()
.map_err(|e| format!("portal version: {e}"))
let conn =
BlockingConnection::session().map_err(|e| format!("session bus: {e}"))?;
let proxy =
BlockingGlobalShortcutsProxy::new(&conn).map_err(|e| format!("proxy: {e}"))?;
proxy.version().map_err(|e| format!("portal version: {e}"))
})
.map_err(|e| PttBackendError::Init(format!("probe thread spawn: {e}")))?
.join()
@@ -309,9 +303,10 @@ impl DesktopPttBackend for LinuxGnomeWaylandBackend {
// Stash command + worker handles. `try_lock` is fine: the
// backend isn't yet shared, and `start` is called once at
// engine init.
let mut inner = self.inner.try_lock().map_err(|_| {
PttBackendError::Init("backend inner mutex contended".to_string())
})?;
let mut inner = self
.inner
.try_lock()
.map_err(|_| PttBackendError::Init("backend inner mutex contended".to_string()))?;
// Clean up any prior worker (defensive — `start` is
// expected to be called exactly once per backend
// instance).
@@ -546,7 +541,9 @@ async fn create_session(
.get("session_handle")
.and_then(|v| <&str>::try_from(v).ok())
.map(|s| s.to_string())
.ok_or_else(|| zbus::Error::Failure("CreateSession returned no session_handle".to_string()))?;
.ok_or_else(|| {
zbus::Error::Failure("CreateSession returned no session_handle".to_string())
})?;
Ok(zbus::zvariant::OwnedObjectPath::try_from(session_handle)
.map_err(|e| zbus::Error::Failure(format!("session_handle path parse: {e}")))?)
}
@@ -714,19 +711,13 @@ mod tests {
#[test]
fn classify_returns_keyboard_for_typical_trigger_description() {
let v = shortcuts_owned_value(vec![shortcut_entry(SHORTCUT_ID, Some("Ctrl+Alt+P"))]);
assert_eq!(
classify_shortcuts_value(&v),
Some(PttInputClass::Keyboard)
);
assert_eq!(classify_shortcuts_value(&v), Some(PttInputClass::Keyboard));
}
#[test]
fn classify_returns_keyboard_when_trigger_description_missing() {
let v = shortcuts_owned_value(vec![shortcut_entry(SHORTCUT_ID, None)]);
assert_eq!(
classify_shortcuts_value(&v),
Some(PttInputClass::Keyboard)
);
assert_eq!(classify_shortcuts_value(&v), Some(PttInputClass::Keyboard));
}
#[test]
+11 -30
View File
@@ -38,9 +38,7 @@ use std::time::Duration;
use tokio::sync::watch;
use tracing::{info, warn};
use super::{
AudioTransmitGate, DesktopPttBackend, PttBackendError, PttBinding, PttInputClass,
};
use super::{AudioTransmitGate, DesktopPttBackend, PttBackendError, PttBinding, PttInputClass};
use crate::ptt::{PttBackendDescriptor, PttCapabilityLevel};
// ---------- FFI ----------
@@ -407,15 +405,10 @@ impl MacOSEventTapBackend {
/// Build the descriptor for a given permission + binding pair
/// (used by `descriptor()` and the re-query worker).
fn build_descriptor(
permission: PermissionState,
class: PttInputClass,
) -> PttBackendDescriptor {
fn build_descriptor(permission: PermissionState, class: PttInputClass) -> PttBackendDescriptor {
let level = match permission {
PermissionState::Granted => match class {
PttInputClass::MouseSideButton => {
PttCapabilityLevel::L3GlobalWithMouseButtons
}
PttInputClass::MouseSideButton => PttCapabilityLevel::L3GlobalWithMouseButtons,
_ => PttCapabilityLevel::L2GlobalHoldToTalk,
},
// Undetermined or Denied (we wouldn't be here for
@@ -535,9 +528,8 @@ impl DesktopPttBackend for MacOSEventTapBackend {
}
return;
}
let source = unsafe {
CFMachPortCreateRunLoopSource(std::ptr::null_mut(), port, 0)
};
let source =
unsafe { CFMachPortCreateRunLoopSource(std::ptr::null_mut(), port, 0) };
if source.is_null() {
warn!(
target: "chanora_audio",
@@ -613,10 +605,7 @@ impl DesktopPttBackend for MacOSEventTapBackend {
let now = query_permission();
if now != last {
perm_atomic.store(now.to_u8(), Ordering::Relaxed);
let desc = MacOSEventTapBackend::build_descriptor(
now,
perm_binding_class,
);
let desc = MacOSEventTapBackend::build_descriptor(now, perm_binding_class);
let _ = perm_desc_tx.send(desc);
info!(
target: "chanora_audio",
@@ -711,9 +700,7 @@ extern "C" fn tap_callback(
// restart the app. The CGEvent docs explicitly say returning
// the event unchanged is the correct no-op for these
// notification types.
if etype == KCG_EVENT_TAP_DISABLED_BY_TIMEOUT
|| etype == KCG_EVENT_TAP_DISABLED_BY_USER_INPUT
{
if etype == KCG_EVENT_TAP_DISABLED_BY_TIMEOUT || etype == KCG_EVENT_TAP_DISABLED_BY_USER_INPUT {
warn!(
target: "chanora_audio",
event = "tap_disabled",
@@ -735,9 +722,7 @@ extern "C" fn tap_callback(
if bound < 0 {
return event;
}
let kc = unsafe {
CGEventGetIntegerValueField(event, KCG_KEYBOARD_EVENT_KEYCODE)
};
let kc = unsafe { CGEventGetIntegerValueField(event, KCG_KEYBOARD_EVENT_KEYCODE) };
if kc == bound as i64 {
let pressed = etype == KCG_EVENT_KEY_DOWN;
state.gate.set(pressed);
@@ -748,9 +733,7 @@ extern "C" fn tap_callback(
if bound < 0 {
return event;
}
let btn = unsafe {
CGEventGetIntegerValueField(event, KCG_MOUSE_EVENT_BUTTON_NUMBER)
};
let btn = unsafe { CGEventGetIntegerValueField(event, KCG_MOUSE_EVENT_BUTTON_NUMBER) };
if btn == bound as i64 {
let pressed = etype == KCG_EVENT_OTHER_MOUSE_DOWN;
state.gate.set(pressed);
@@ -812,10 +795,8 @@ mod tests {
#[test]
fn build_descriptor_granted_none_reports_L2_keyboard() {
let d = MacOSEventTapBackend::build_descriptor(
PermissionState::Granted,
PttInputClass::None,
);
let d =
MacOSEventTapBackend::build_descriptor(PermissionState::Granted, PttInputClass::None);
assert_eq!(d.level, PttCapabilityLevel::L2GlobalHoldToTalk);
assert_eq!(d.bound_input_class, None);
}
@@ -29,16 +29,15 @@ use windows::core::{w, PCWSTR};
use windows::Win32::Foundation::{HMODULE, HWND, LPARAM, LRESULT, WPARAM};
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
use windows::Win32::UI::Input::{
GetRawInputData, RegisterRawInputDevices, HRAWINPUT, RAWINPUT, RAWINPUTDEVICE,
RAWINPUTHEADER, RID_INPUT, RIDEV_INPUTSINK, RIDEV_REMOVE, RIM_TYPEKEYBOARD, RIM_TYPEMOUSE,
GetRawInputData, RegisterRawInputDevices, HRAWINPUT, RAWINPUT, RAWINPUTDEVICE, RAWINPUTHEADER,
RIDEV_INPUTSINK, RIDEV_REMOVE, RID_INPUT, RIM_TYPEKEYBOARD, RIM_TYPEMOUSE,
};
use windows::Win32::UI::WindowsAndMessaging::{
CallNextHookEx, CreateWindowExW, DefWindowProcW, DispatchMessageW, GetMessageW,
PostThreadMessageW, RegisterClassExW, SetWindowsHookExW, TranslateMessage,
UnhookWindowsHookEx, HC_ACTION, HHOOK, HOOKPROC, KBDLLHOOKSTRUCT, MSG, MSLLHOOKSTRUCT,
WH_KEYBOARD_LL, WH_MOUSE_LL, WINDOW_EX_STYLE, WINDOW_STYLE, WM_INPUT, WM_KEYDOWN, WM_KEYUP,
WM_QUIT, WM_SYSKEYDOWN, WM_SYSKEYUP, WM_XBUTTONDOWN, WM_XBUTTONUP, WNDCLASSEXW, XBUTTON1,
XBUTTON2,
PostThreadMessageW, RegisterClassExW, SetWindowsHookExW, TranslateMessage, UnhookWindowsHookEx,
HC_ACTION, HHOOK, HOOKPROC, KBDLLHOOKSTRUCT, MSG, MSLLHOOKSTRUCT, WH_KEYBOARD_LL, WH_MOUSE_LL,
WINDOW_EX_STYLE, WINDOW_STYLE, WM_INPUT, WM_KEYDOWN, WM_KEYUP, WM_QUIT, WM_SYSKEYDOWN,
WM_SYSKEYUP, WM_XBUTTONDOWN, WM_XBUTTONUP, WNDCLASSEXW, XBUTTON1, XBUTTON2,
};
use super::{AudioTransmitGate, DesktopPttBackend, PttBackendError, PttBinding};
@@ -467,10 +466,7 @@ unsafe fn run_raw_input_loop(
hwndTarget: hwnd,
},
];
let reg_ok = RegisterRawInputDevices(
&devices,
std::mem::size_of::<RAWINPUTDEVICE>() as u32,
);
let reg_ok = RegisterRawInputDevices(&devices, std::mem::size_of::<RAWINPUTDEVICE>() as u32);
if reg_ok.is_err() {
warn!(
target: "chanora_audio",
@@ -920,11 +916,7 @@ unsafe extern "system" fn kbd_hook_proc(code: i32, wparam: WPARAM, lparam: LPARA
/// `KBDLLHOOKSTRUCT` from `lparam` then calls into this helper so
/// the tests can exercise the press-edge translation without
/// installing a global hook.
pub(crate) fn dispatch_hook_keyboard(
ctx: &HookContext,
wparam: WPARAM,
kb: &KBDLLHOOKSTRUCT,
) {
pub(crate) fn dispatch_hook_keyboard(ctx: &HookContext, wparam: WPARAM, kb: &KBDLLHOOKSTRUCT) {
if ctx.binding.class() != 1 {
return;
}
@@ -1206,10 +1198,7 @@ mod tests {
// Keyboard class + mouse-side-button key string: the
// keymap parses the string as a key label and finds no
// match → None.
let r = resolve_binding(&binding(
PttInputClass::Keyboard,
"mouse-side-button:8",
));
let r = resolve_binding(&binding(PttInputClass::Keyboard, "mouse-side-button:8"));
assert_eq!(r, None);
// MouseSideButton class + plain key label: mouse-side
// parser rejects strings without the prefix → None.
+90 -60
View File
@@ -1,33 +1,20 @@
//! 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.
//! When the PTT key is released, audio transmission continues for a
//! configurable tail duration (0500 ms, default 200 ms) to avoid
//! abrupt cutoff. Cancellation is performed by aborting the pending
//! [`JoinHandle`]; the [`tokio::sync::watch`] channel enables cooperative
//! early exit so the spawned task can skip writing to the gate when
//! cancelled. Drives [`AudioTransmitGate`] directly.
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use std::sync::Arc;
use tokio::sync::watch;
use tokio::task::JoinHandle;
use crate::ptt::AudioTransmitGate;
use crate::transmit_selector::TransmitModeSelector;
/// Maximum configurable release-tail, in milliseconds.
@@ -36,26 +23,56 @@ 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.
/// Coalesces a PTT key release into a deferred gate update.
///
/// Cheap to clone via `Arc`.
/// Threading model (SDD-096):
///
/// * `tail_ms` is an [`AtomicU32`] so config changes are visible
/// immediately to any in-flight release task.
/// * The timer holds a [`watch::Sender<bool>`] for cooperative early
/// exit alongside the [`JoinHandle`]; the handle is replaced on
/// each `key_up` in a cheap [`std::sync::RwLock`] on PTT edge
/// transitions only — never on the audio frame hot path.
/// * Cancellation is performed by aborting the pending
/// [`JoinHandle`] in `cancel_pending()`. The watch channel allows
/// the spawned task to cooperatively exit without writing to the
/// gate when cancelled: the task races `cancel_rx.changed()`
/// against the tail sleep; if the sender is replaced or dropped,
/// the task returns early without applying the gate.
/// * `unsafe` is never used.
pub struct ReleaseTailTimer {
/// Protected by RwLock; only the PTT edge-transition methods
/// (`key_down`, `key_up`, `arm`) acquire it.
selector: Arc<TransmitModeSelector>,
pending: Arc<Mutex<Option<JoinHandle<()>>>>,
/// Watch sender for cooperative early exit within the in-flight
/// task. The task subscribes via [`watch::Sender::subscribe`] and
/// races `cancel_rx.changed()` against the tail sleep.
cancel_tx: std::sync::RwLock<watch::Sender<bool>>,
/// The in-flight release task, replaced on every `key_up`.
pending_handle: std::sync::RwLock<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).
/// Construct a release-tail timer bound to a transmit-mode
/// selector and an initial tail value.
pub fn new(selector: Arc<TransmitModeSelector>, tail_ms: u32) -> Self {
Self {
selector,
pending: Arc::new(Mutex::new(None)),
cancel_tx: std::sync::RwLock::new(watch::channel(false).0),
pending_handle: std::sync::RwLock::new(None),
tail_ms: AtomicU32::new(tail_ms.min(MAX_TAIL_MS)),
}
}
/// Arm (or re-arm) the timer with a fresh `gate` and `tail_ms`
/// clamped to `0..=MAX_TAIL_MS` (SDD-096).
pub fn arm(&self, gate: AudioTransmitGate, tail_ms: u32) {
self.selector.replace_gate(gate);
self.tail_ms
.store(tail_ms.min(MAX_TAIL_MS), Ordering::Relaxed);
}
/// 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);
@@ -67,51 +84,64 @@ impl ReleaseTailTimer {
}
/// Notify the timer that the PTT key went down. Cancels any
/// pending release and immediately marks the selector's
/// `ptt_held` input as `true`.
/// pending release and immediately sets `transmit_active = 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.
/// that waits for the tail to elapse (or cancellation) and then
/// clears the gate. 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 mut cancel_rx = {
let tx = self.cancel_tx.read().expect("cancel_tx lock poisoned");
tx.subscribe()
};
let tail_ms = self.tail_ms();
let new_handle = tokio::spawn(async move {
if tail > 0 {
tokio::time::sleep(Duration::from_millis(tail as u64)).await;
if tail_ms > 0 {
match tokio::time::timeout(
Duration::from_millis(tail_ms as u64),
cancel_rx.changed(),
)
.await
{
// Timeout elapsed → timer was NOT cancelled → apply gate.
Err(_) => {}
// Channel closed (sender dropped) → timer was cancelled → skip gate.
Ok(Err(_)) => return,
// Received a value → treat as cancellation.
Ok(Ok(())) => return,
}
}
selector.set_ptt_held(false);
});
if let Ok(mut g) = self.pending.lock() {
if let Ok(mut g) = self.pending_handle.write() {
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.
/// Cancel any pending release task and leave the gate 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.
/// Cancel any pending release and force `ptt_held = false`.
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 Ok(mut g) = self.pending_handle.write() {
if let Some(h) = g.take() {
h.abort();
}
@@ -128,21 +158,20 @@ impl Drop for ReleaseTailTimer {
#[cfg(test)]
mod tests {
use super::*;
use crate::ptt::AudioTransmitGate;
use crate::transmit_mode::TransmitMode;
use crate::TransmitMode;
fn setup(tail_ms: u32) -> (AudioTransmitGate, Arc<TransmitModeSelector>, ReleaseTailTimer) {
fn setup(tail_ms: u32) -> (AudioTransmitGate, 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)
let selector = Arc::new(TransmitModeSelector::new(gate.clone()));
selector.set_mode(TransmitMode::Ptt);
selector.set_in_channel(true);
let timer = ReleaseTailTimer::new(selector, tail_ms);
(gate, timer)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn release_clears_after_tail() {
let (gate, _sel, timer) = setup(80);
let (gate, timer) = setup(80);
timer.key_down();
assert!(gate.load());
timer.key_up();
@@ -156,19 +185,22 @@ mod tests {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn redown_within_tail_cancels_release() {
let (gate, _sel, timer) = setup(200);
let (gate, 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");
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);
let (gate, timer) = setup(0);
timer.key_down();
assert!(gate.load());
timer.key_up();
@@ -178,9 +210,7 @@ mod tests {
#[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);
let (_gate, timer) = setup(100);
timer.set_tail_ms(99999);
assert_eq!(timer.tail_ms(), MAX_TAIL_MS);
timer.set_tail_ms(0);
+1 -2
View File
@@ -93,8 +93,7 @@ impl SdlOutput {
output_gain: Arc<AtomicU32>,
output_muted: Arc<AtomicBool>,
) -> Result<Self, AudioError> {
let sdl = sdl2::init()
.map_err(|e| AudioError::Backend(format!("sdl init: {e}")))?;
let sdl = sdl2::init().map_err(|e| AudioError::Backend(format!("sdl init: {e}")))?;
let subsystem = sdl
.audio()
.map_err(|e| AudioError::Backend(format!("sdl audio subsystem: {e}")))?;
@@ -137,7 +137,10 @@ impl TransmitModeSelector {
/// 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()
self.gate
.read()
.expect("selector gate lock poisoned")
.clone()
}
fn compute(&self) -> bool {