feat(ios,p0): iOS P0 platform, audio fixes, channel UX
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}")))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 (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.
|
||||
//! When the PTT key is released, audio transmission continues for a
|
||||
//! configurable tail duration (0–500 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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user