diff --git a/crates/chanora_audio/src/ptt_backends/mod.rs b/crates/chanora_audio/src/ptt_backends/mod.rs index f7eb8cf..891a157 100644 --- a/crates/chanora_audio/src/ptt_backends/mod.rs +++ b/crates/chanora_audio/src/ptt_backends/mod.rs @@ -22,6 +22,7 @@ use core::fmt; use crate::ptt::{AudioTransmitGate, PttBackendDescriptor}; +use thiserror::Error; mod focused; @@ -108,35 +109,51 @@ impl fmt::Display for PttInputClass { } /// Errors raised by a desktop PTT backend. -#[derive(Debug)] +#[derive(Debug, Error)] pub enum PttBackendError { /// The OS rejected the backend initialisation (e.g. Raw Input /// registration failed, event tap creation failed). + #[error("init failed: {0}")] Init(String), /// The user-granted permission required for global capture is /// not granted (typically macOS Input Monitoring / Accessibility). + #[error("permission denied")] PermissionDenied, /// The display server or compositor does not expose the /// expected interface (typically a non-tested Linux compositor). + #[error("unsupported environment")] UnsupportedEnvironment, /// Caller submitted a binding whose `platform_key` cannot be /// parsed in the active OS. + #[error("invalid binding: {0}")] InvalidBinding(String), } -impl fmt::Display for PttBackendError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Init(s) => write!(f, "init failed: {s}"), - Self::PermissionDenied => f.write_str("permission denied"), - Self::UnsupportedEnvironment => f.write_str("unsupported environment"), - Self::InvalidBinding(s) => write!(f, "invalid binding: {s}"), - } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ptt_backend_error_display_strings_stay_stable() { + assert_eq!( + PttBackendError::Init("rawinput".into()).to_string(), + "init failed: rawinput" + ); + assert_eq!( + PttBackendError::PermissionDenied.to_string(), + "permission denied" + ); + assert_eq!( + PttBackendError::UnsupportedEnvironment.to_string(), + "unsupported environment" + ); + assert_eq!( + PttBackendError::InvalidBinding("bad key".into()).to_string(), + "invalid binding: bad key" + ); } } -impl std::error::Error for PttBackendError {} - /// Cross-platform desktop PTT backend (SDD-081). /// /// All implementations call exactly the audio transmit gate's diff --git a/crates/chanora_audio/src/voice_render.rs b/crates/chanora_audio/src/voice_render.rs index 72039b1..144a5db 100644 --- a/crates/chanora_audio/src/voice_render.rs +++ b/crates/chanora_audio/src/voice_render.rs @@ -1,4 +1,5 @@ /// Diagnostics returned by render downmix helpers. +#[cfg(any(target_os = "ios", test))] #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub(crate) struct RenderDownmixStats { /// Peak absolute sample magnitude after i16 conversion. @@ -7,47 +8,7 @@ pub(crate) struct RenderDownmixStats { pub clipped_samples: u64, } -/// Downmix interleaved stereo f32 samples into mono i16 samples. -/// -/// The helper is allocation-free and safe for realtime render callbacks. -/// If the stereo source is shorter than expected, the remainder of `out` -/// is filled with silence. #[cfg(any(target_os = "ios", test))] -pub(crate) fn downmix_stereo_f32_to_mono_i16( - stereo: &[f32], - out: &mut [i16], - gain: f32, - muted: bool, -) -> RenderDownmixStats { - if muted { - out.fill(0); - return RenderDownmixStats::default(); - } - - let available_frames = stereo.len() / 2; - if available_frames < out.len() { - out.fill(0); - } - - let mut peak = 0_u16; - let mut clipped_samples = 0_u64; - for (dst, lr) in out.iter_mut().zip(stereo.chunks_exact(2)) { - let mono = (lr[0] + lr[1]) * 0.5 * gain; - let clamped = mono.clamp(-1.0, 1.0); - if (mono - clamped).abs() > f32::EPSILON { - clipped_samples = clipped_samples.saturating_add(1); - } - let sample = (clamped * i16::MAX as f32) as i16; - *dst = sample; - peak = peak.max(sample.unsigned_abs()); - } - - RenderDownmixStats { - peak_i16: peak.min(i16::MAX as u16) as i16, - clipped_samples, - } -} - pub(crate) fn downmix_stereo_f32_to_interleaved_i16( stereo: &[f32], out: &mut [i16], @@ -122,10 +83,7 @@ pub(crate) fn limit_peak_inplace(samples: &mut [f32], threshold: f32) -> f32 { if threshold <= 0.0 || !threshold.is_finite() { return 1.0; } - let peak = samples - .iter() - .map(|s| s.abs()) - .fold(0.0_f32, f32::max); + let peak = samples.iter().map(|s| s.abs()).fold(0.0_f32, f32::max); if peak <= threshold { return 1.0; } @@ -145,7 +103,7 @@ mod tests { let stereo = [1.0_f32, 1.0, 0.25, -0.25, -2.0, -2.0]; let mut out = [0_i16; 3]; - let stats = downmix_stereo_f32_to_mono_i16(&stereo, &mut out, 2.0, false); + let stats = downmix_stereo_f32_to_interleaved_i16(&stereo, &mut out, 1, 2.0, false); assert_eq!(out[0], i16::MAX); assert_eq!(out[1], 0); @@ -159,7 +117,7 @@ mod tests { let stereo = [1.0_f32, 1.0, -1.0, -1.0]; let mut out = [123_i16; 2]; - let stats = downmix_stereo_f32_to_mono_i16(&stereo, &mut out, 1.0, true); + let stats = downmix_stereo_f32_to_interleaved_i16(&stereo, &mut out, 1, 1.0, true); assert_eq!(out, [0, 0]); assert_eq!(stats, RenderDownmixStats::default()); @@ -234,7 +192,7 @@ mod tests { let mut scratch = [1.0_f32, 1.0, -0.5, -0.5, 0.8, 0.8]; limit_peak_inplace(&mut scratch, 0.95); let mut out = [0_i16; 3]; - let stats = downmix_stereo_f32_to_mono_i16(&scratch, &mut out, 1.0, false); + let stats = downmix_stereo_f32_to_interleaved_i16(&scratch, &mut out, 1, 1.0, false); assert_eq!(stats.clipped_samples, 0); assert!(stats.peak_i16 < i16::MAX); } diff --git a/crates/chanora_diagnostics/src/lib.rs b/crates/chanora_diagnostics/src/lib.rs index cf1b1c6..52b1f44 100644 --- a/crates/chanora_diagnostics/src/lib.rs +++ b/crates/chanora_diagnostics/src/lib.rs @@ -36,7 +36,7 @@ #![forbid(unsafe_code)] #![warn(missing_docs)] -use std::collections::HashSet; +use std::collections::{HashSet, VecDeque}; use std::sync::{Arc, Mutex}; use thiserror::Error; @@ -712,7 +712,7 @@ impl DiagnosticExport { /// diagnostic export and state-sync replay verification. #[derive(Debug, Clone)] pub struct ProtocolEventRecorder { - events: Vec, + events: VecDeque, capacity: usize, } @@ -720,17 +720,20 @@ impl ProtocolEventRecorder { /// Create a recorder with the given ring-buffer capacity. pub fn new(capacity: usize) -> Self { Self { - events: Vec::with_capacity(capacity), + events: VecDeque::with_capacity(capacity), capacity, } } fn push(&mut self, ts: &str, kind: &str, detail: &str) { + if self.capacity == 0 { + return; + } let s = format!("[{ts}] {kind}: {detail}"); if self.events.len() >= self.capacity { - self.events.remove(0); + self.events.pop_front(); } - self.events.push(s); + self.events.push_back(s); } /// Record a successful connection. @@ -777,12 +780,12 @@ impl ProtocolEventRecorder { /// Drain all recorded events and reset the buffer. pub fn drain(&mut self) -> Vec { - std::mem::take(&mut self.events) + self.events.drain(..).collect() } /// Snapshot all recorded events without clearing the buffer. pub fn snapshot(&self) -> Vec { - self.events.clone() + self.events.iter().cloned().collect() } } @@ -1103,4 +1106,13 @@ mod tests { assert_eq!(first, second); assert_eq!(drained, first); } + + #[test] + fn protocol_event_zero_capacity_drops_events() { + let mut recorder = ProtocolEventRecorder::new(0); + recorder.record_connected("Server"); + + assert!(recorder.snapshot().is_empty()); + assert!(recorder.drain().is_empty()); + } }