From 2902a8bcd52e85ac5a3e73e8b07dc11a48bd2546 Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Fri, 5 Jun 2026 13:57:53 +0900 Subject: [PATCH] fix(audio): eliminate Android output stutter via Oboe config + lock-free callback (#20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(audio): eliminate Android output stutter via Oboe config + lock-free callback Phase 1 — Oboe configuration: - Change output stream from Usage::VoiceCommunication to Usage::Game with ContentType::Sonification to avoid forcing the Legacy (OpenSL ES) data path on most devices (Oboe issue #2075) - Switch output format from i16 Mono to f32 Stereo, matching Qint's proven configuration and eliminating per-callback downmix conversion - Set buffer size to 2x burst after stream open, reducing default buffer from 8-20x burst to 2x burst for lower latency - Remove scratch Mutex>; callback writes directly to Oboe buffer Phase 2 — Lock-free output callback: - Add audio_event_queue.rs: lock-free SPSC bridge using crossbeam ArrayQueue with separate packet (lossy) and control (reliable) channels - OutputCallback now owns AudioHandler directly (no Arc> on Android) - Inbound forwarder pushes packets via AudioEventProducer (no mutex) - set_client_volume pushes control commands via event queue on Android - iOS/desktop Arc> path unchanged * fix(audio): address PR #20 review findings - Store AudioEventConsumer directly in OutputCallback to eliminate per-callback Arc clone on the real-time audio thread - Add SAFETY comment for the unsafe from_raw_parts_mut transmute - Bound set_client_volume spin-loop to 64 retries with warn log - Remove redundant crossbeam-utils direct dependency - Regenerate license inventory for new crossbeam deps (CI fix) * fix(audio): use ASCII TODO punctuation --- Cargo.lock | 21 ++ crates/chanora_audio/Cargo.toml | 1 + .../chanora_audio/src/android_voice_unit.rs | 146 +++++++----- crates/chanora_audio/src/audio_event_queue.rs | 120 ++++++++++ crates/chanora_audio/src/engine.rs | 82 ++++--- crates/chanora_audio/src/lib.rs | 2 + .../chanora_audio/src/mobile_voice_backend.rs | 9 +- docs/security/license-inventory.md | 211 +++++++++++++++++- 8 files changed, 510 insertions(+), 82 deletions(-) create mode 100644 crates/chanora_audio/src/audio_event_queue.rs diff --git a/Cargo.lock b/Cargo.lock index 939cdfd..38190da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -423,6 +423,7 @@ dependencies = [ "coreaudio-rs", "cpal", "criterion", + "crossbeam", "dhat", "dispatch2", "futures-util", @@ -803,6 +804,17 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" +[[package]] +name = "crossbeam" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" +dependencies = [ + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -831,6 +843,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" diff --git a/crates/chanora_audio/Cargo.toml b/crates/chanora_audio/Cargo.toml index d94642a..6dc5d8b 100644 --- a/crates/chanora_audio/Cargo.toml +++ b/crates/chanora_audio/Cargo.toml @@ -29,6 +29,7 @@ audiopus = "0.3.0-rc.0" tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491", default-features = false, features = ["audio"] } tokio = { version = "1", features = ["sync", "rt", "macros", "time"] } rustfft = "6.2.0" +crossbeam = { version = "0.8", default-features = false, features = ["alloc", "crossbeam-queue"] } [target.'cfg(all(not(target_os = "android"), not(target_os = "ios"), not(target_os = "macos")))'.dependencies] # Desktop audio I/O for Windows capture/playback and Linux capture. diff --git a/crates/chanora_audio/src/android_voice_unit.rs b/crates/chanora_audio/src/android_voice_unit.rs index 8eb5f35..5d069e6 100644 --- a/crates/chanora_audio/src/android_voice_unit.rs +++ b/crates/chanora_audio/src/android_voice_unit.rs @@ -44,6 +44,7 @@ use std::sync::{Arc, Mutex}; use audiopus::coder::Encoder as OpusEncoder; use tracing::{debug, info, warn}; +use crate::audio_event_queue::{AudioCommand, AudioEventQueue}; use crate::mobile_voice_backend::{ clear_android_audio_diagnostics, latency_tier_for, next_input_preset_after, next_sharing_mode_after, publish_android_audio_diagnostics, AchievedInputPreset, @@ -63,7 +64,7 @@ use oboe::{ AudioInputCallback, AudioInputStreamSafe, AudioOutputCallback, AudioOutputStreamSafe, AudioStream, AudioStreamAsync, AudioStreamBase, AudioStreamBuilder, AudioStreamSafe, DataCallbackResult, Input as OboeInput, InputPreset, Mono, Output as OboeOutput, - PerformanceMode, SessionId, SharingMode, Usage, + PerformanceMode, SessionId, SharingMode, Stereo, Usage, }; use crate::processor::AudioProcessor; @@ -518,14 +519,14 @@ impl AudioInputCallback for InputCallback { // // Mirrors the iOS VPIO render callback. Pulls mixed 48 kHz stereo f32 // from `AudioHandler::fill_buffer`, applies output gain + mute, and -// writes mono i16 to the Oboe output buffer. +// writes stereo f32 directly to the Oboe output buffer. struct OutputCallback { - handler: Arc>>, + handler: AudioHandler, + event_consumer: crate::audio_event_queue::AudioEventConsumer, output_gain: Arc, output_muted: Arc, event_tx: BackendEventTx, - scratch: Arc>>, render_reference: Arc, audio_processing_stats: Arc, pending_render_ref: [f32; crate::frame::FRAME_10MS_SAMPLES], @@ -533,51 +534,65 @@ struct OutputCallback { } impl AudioOutputCallback for OutputCallback { - type FrameType = (i16, Mono); + type FrameType = (f32, Stereo); fn on_audio_ready( &mut self, _stream: &mut dyn AudioOutputStreamSafe, - frames: &mut [i16], + frames: &mut [(f32, f32)], ) -> DataCallbackResult { let _ = catch_unwind(AssertUnwindSafe(|| { - let needed = frames.len() * 2; // stereo - let scratch = &mut self.scratch.lock().unwrap(); - if scratch.len() < needed { - scratch.resize(needed, 0.0); - } else { - for s in &mut scratch[..needed] { - *s = 0.0; + // SAFETY: `frames: &mut [(f32, f32)]` is an interleaved stereo + // buffer. `(f32, f32)` has the same size (8 bytes) and alignment + // (4 bytes) as `[f32; 2]`, so reinterpreting the slice as a flat + // `&mut [f32]` of length `frames.len() * 2` is sound. The Rust + // reference does not *guarantee* `#[repr(Rust)]` tuple layout, + // but (a) both fields are identical F32 primitives with no + // padding possible, and (b) the oboe crate uses + // `#[repr(transparent)]` on its frame type alias so the ABI + // contract is upheld at the FFI boundary. `frames` is not + // accessed again after `buf` is created, so no aliasing UB. + let buf: &mut [f32] = unsafe { + std::slice::from_raw_parts_mut(frames.as_mut_ptr() as *mut f32, frames.len() * 2) + }; + for s in buf.iter_mut() { + *s = 0.0; + } + for cmd in self.event_consumer.drain_controls() { + match cmd { + AudioCommand::SetVolume(id, vol) => { + if let Some(q) = self.handler.get_mut_queues().get_mut(&id) { + q.volume = vol; + } + } + AudioCommand::RemoveClient(id) => { + self.handler.get_mut_queues().remove(&id); + } } } - match self.handler.try_lock() { - Ok(mut h) => { - let _ = h.fill_buffer(&mut scratch[..needed]); - } - Err(std::sync::TryLockError::WouldBlock) => {} - Err(std::sync::TryLockError::Poisoned(e)) => { - warn!( - target: "chanora_audio", - "AudioHandler mutex poisoned: {}", - e - ); + + for pkt in self.event_consumer.drain_packets(50) { + if let Err(e) = self.handler.handle_packet(pkt.client_id, pkt.data) { + debug!(target: "chanora_audio", error = %e, "decode failed"); } } + + let _ = self.handler.fill_buffer(buf); let gain = f32::from_bits(self.output_gain.load(Ordering::Relaxed)); let muted = self.output_muted.load(Ordering::Relaxed); - let _ = crate::voice_render::downmix_stereo_f32_to_mono_i16( - &scratch[..needed], - frames, - gain, - muted, - ); + if muted { + for s in buf.iter_mut() { + *s = 0.0; + } + } else if gain != 1.0 { + for s in buf.iter_mut() { + *s *= gain; + } + } self.audio_processing_stats - .update_render(crate::frame::dbfs(&scratch[..needed]), frames.len() as u32); + .update_render(crate::frame::dbfs(buf), frames.len() as u32); - // Accumulate the full render callback into 10 ms mono chunks so - // AEC sees consistent reference timing even when output callbacks - // are shorter or longer than 10 ms. - for chunk in scratch[..needed].chunks_exact(2) { + for chunk in buf.chunks_exact(2) { self.pending_render_ref[self.pending_render_ref_len] = (chunk[0] + chunk[1]) * 0.5; self.pending_render_ref_len += 1; if self.pending_render_ref_len == crate::frame::FRAME_10MS_SAMPLES { @@ -677,8 +692,6 @@ impl AndroidVoiceUnit { .map_err(|e| BackendError::OpenFailed(format!("capture state init: {e}")))?, )); - let scratch = Arc::new(Mutex::new(Vec::with_capacity(8192))); - // --- Open input stream (SDD-112) --------------------------- let input_builder = AudioStreamBuilder::default() .set_direction::() @@ -766,8 +779,8 @@ impl AndroidVoiceUnit { let output_builder = AudioStreamBuilder::default() .set_direction::() .set_sample_rate(cfg.sample_rate as i32) - .set_channel_count::() - .set_format::() + .set_channel_count::() + .set_format::() .set_performance_mode(if cfg.request_low_latency { PerformanceMode::LowLatency } else { @@ -778,16 +791,21 @@ impl AndroidVoiceUnit { } else { SharingMode::Shared }) - .set_usage(Usage::VoiceCommunication) - .set_content_type(oboe::ContentType::Speech); + // Usage::Game avoids forcing the Legacy (OpenSL ES) data path + // that Usage::VoiceCommunication triggers on most devices. + // Android audio routing is already handled by + // AudioManager.MODE_IN_COMMUNICATION on the Flutter side. + .set_usage(Usage::Game) + .set_content_type(oboe::ContentType::Sonification); let render_ref_for_output = render_ref_buf.clone(); + let event_queue = params.event_producer.queue(); let output_cb = OutputCallback { - handler: params.handler.clone(), + handler: params.handler, + event_consumer: AudioEventQueue::consumer(&event_queue), output_gain: params.output_gain.clone(), output_muted: params.output_muted.clone(), event_tx: event_tx.clone(), - scratch: scratch.clone(), render_reference: render_ref_for_output, audio_processing_stats: audio_processing_stats.clone(), pending_render_ref: [0.0_f32; crate::frame::FRAME_10MS_SAMPLES], @@ -806,20 +824,41 @@ impl AndroidVoiceUnit { Self::open_output_fallback( cfg, &event_tx, - params.handler.clone(), + AudioHandler::new(), + AudioEventQueue::consumer(&event_queue), params.output_gain.clone(), params.output_muted.clone(), audio_processing_stats.clone(), - scratch.clone(), render_ref_buf, )? } }; + let output_frames_per_burst = output_stream.get_frames_per_burst(); + if output_frames_per_burst > 0 { + let desired = output_frames_per_burst * 2; + match output_stream.set_buffer_size_in_frames(desired) { + Ok(actual) => { + debug!( + target: "chanora_audio", + desired, + actual, + "android: output buffer size tuned" + ); + } + Err(e) => { + warn!( + target: "chanora_audio", + error = ?e, + "android: output buffer size tuning failed; using device default" + ); + } + } + } + let output_perf = perf_from_oboe(output_stream.get_performance_mode()); let output_share = share_from_oboe(output_stream.get_sharing_mode()); let output_sample_rate = output_stream.get_sample_rate(); - let output_frames_per_burst = output_stream.get_frames_per_burst(); // SDD-112 / SRS-210: structured "stream opened" event with // achieved values. No PII; only platform-reported scalars. @@ -1038,19 +1077,19 @@ impl AndroidVoiceUnit { fn open_output_fallback( cfg: &AndroidVoiceStreamConfig, event_tx: &BackendEventTx, - handler: Arc>>, + handler: AudioHandler, + event_consumer: crate::audio_event_queue::AudioEventConsumer, output_gain: Arc, output_muted: Arc, audio_processing_stats: Arc, - scratch: Arc>>, render_reference: Arc, ) -> Result, BackendError> { let cb = OutputCallback { handler, + event_consumer, output_gain, output_muted, event_tx: event_tx.clone(), - scratch, render_reference, audio_processing_stats, pending_render_ref: [0.0_f32; crate::frame::FRAME_10MS_SAMPLES], @@ -1059,12 +1098,13 @@ impl AndroidVoiceUnit { let builder = AudioStreamBuilder::default() .set_direction::() .set_sample_rate(cfg.sample_rate as i32) - .set_channel_count::() - .set_format::() + .set_channel_count::() + .set_format::() .set_performance_mode(PerformanceMode::LowLatency) .set_sharing_mode(SharingMode::Shared) - .set_usage(Usage::VoiceCommunication) - .set_content_type(oboe::ContentType::Speech) + // Same Usage::Game rationale as primary output builder above. + .set_usage(Usage::Game) + .set_content_type(oboe::ContentType::Sonification) .set_callback(cb); builder .open_stream() diff --git a/crates/chanora_audio/src/audio_event_queue.rs b/crates/chanora_audio/src/audio_event_queue.rs new file mode 100644 index 0000000..0a4a185 --- /dev/null +++ b/crates/chanora_audio/src/audio_event_queue.rs @@ -0,0 +1,120 @@ +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +use chanora_protocol::InAudioBuf; +use crossbeam::queue::ArrayQueue; + +use crate::engine::SessionAudioId; + +const PACKET_QUEUE_CAPACITY: usize = 100; +const CONTROL_QUEUE_CAPACITY: usize = 32; + +/// A raw inbound voice packet waiting to be inserted into AudioHandler. +pub struct AudioPacket { + /// Client whose TeamSpeak audio packet this belongs to. + pub client_id: SessionAudioId, + /// Raw inbound TeamSpeak audio payload accepted by AudioHandler::handle_packet. + pub data: InAudioBuf, +} + +/// Control commands from the main thread to the audio callback. +pub enum AudioCommand { + /// Set a client's output volume. + SetVolume(SessionAudioId, f32), + /// Remove a client's decode queue. + // TODO: Wire to client disconnect path; handled in callback but no + // producer currently pushes this command. + RemoveClient(SessionAudioId), +} + +/// Lock-free bridge between the inbound forwarder / main thread and the +/// audio callback. The callback owns the consumer halves. +pub struct AudioEventQueue { + /// Bounded lossy queue for raw voice packets. On overflow, the push + /// fails and the packet is dropped (counted via `packets_dropped`). + /// Capacity: 100 packets (~2 seconds at 50pps, far more than needed). + pub packet_queue: ArrayQueue, + /// Bounded reliable queue for control commands (volume, client removal). + /// On overflow, the caller retries. Capacity: 32 commands. + pub control_queue: ArrayQueue, + /// Atomic counter for dropped packets (for diagnostics). + pub packets_dropped: AtomicU64, +} + +impl AudioEventQueue { + /// Create the Android audio event bridge with fixed queue capacities. + pub fn new() -> Arc { + Arc::new(Self { + packet_queue: ArrayQueue::new(PACKET_QUEUE_CAPACITY), + control_queue: ArrayQueue::new(CONTROL_QUEUE_CAPACITY), + packets_dropped: AtomicU64::new(0), + }) + } + + /// Create a producer handle sharing this queue. + pub fn producer(queue: &Arc) -> AudioEventProducer { + AudioEventProducer { + queue: Arc::clone(queue), + } + } + + /// Create a consumer handle sharing this queue. + pub fn consumer(queue: &Arc) -> AudioEventConsumer { + AudioEventConsumer { + queue: Arc::clone(queue), + } + } +} + +/// Producer side used by the inbound forwarder and engine control methods. +#[derive(Clone)] +pub struct AudioEventProducer { + queue: Arc, +} + +impl AudioEventProducer { + /// Push a raw voice packet, incrementing the drop counter if full. + pub fn push_packet(&self, packet: AudioPacket) -> Result<(), AudioPacket> { + self.queue.packet_queue.push(packet).map_err(|packet| { + self.queue.packets_dropped.fetch_add(1, Ordering::Relaxed); + packet + }) + } + + /// Push a control command, returning it unchanged if the queue is full. + pub fn push_control(&self, cmd: AudioCommand) -> Result<(), AudioCommand> { + self.queue.control_queue.push(cmd) + } + + /// Shared queue backing this producer. + pub fn queue(&self) -> Arc { + Arc::clone(&self.queue) + } +} + +/// Consumer side used by the Android output callback. +pub struct AudioEventConsumer { + queue: Arc, +} + +impl AudioEventConsumer { + /// Pop up to `cap` queued packets. + pub fn drain_packets(&self, cap: usize) -> impl Iterator + '_ { + let mut drained = 0; + std::iter::from_fn(move || { + if drained >= cap { + return None; + } + let packet = self.queue.packet_queue.pop(); + if packet.is_some() { + drained += 1; + } + packet + }) + } + + /// Pop all currently queued controls. + pub fn drain_controls(&self) -> impl Iterator + '_ { + std::iter::from_fn(move || self.queue.control_queue.pop()) + } +} diff --git a/crates/chanora_audio/src/engine.rs b/crates/chanora_audio/src/engine.rs index 176bb83..c55a235 100644 --- a/crates/chanora_audio/src/engine.rs +++ b/crates/chanora_audio/src/engine.rs @@ -52,6 +52,8 @@ use tsclientlib::audio::AudioHandler; use chanora_protocol::{InboundVoice, OutPacket}; +#[cfg(target_os = "android")] +use crate::audio_event_queue::{AudioCommand, AudioEventQueue, AudioPacket}; use crate::AudioError; #[cfg(all( @@ -305,8 +307,11 @@ pub struct AudioEngine { output_muted: Arc, audio_processing_config: Arc>, audio_processing_stats: Arc, + #[cfg(not(target_os = "android"))] audio_handler: Arc>>, #[cfg(target_os = "android")] + audio_event_producer: crate::audio_event_queue::AudioEventProducer, + #[cfg(target_os = "android")] voice_out_tx: mpsc::Sender, #[cfg(target_os = "android")] voice_activity_selector: Option>, @@ -482,7 +487,7 @@ impl AudioEngine { voice_out_tx: mpsc::Sender, transmit_gate: crate::ptt::AudioTransmitGate, frames_sent: Arc, - audio_handler: Arc>>, + event_producer: crate::audio_event_queue::AudioEventProducer, output_gain: Arc, output_muted: Arc, voice_activity_selector: Option>, @@ -552,7 +557,8 @@ impl AudioEngine { transmit_active: transmit_gate.flag_arc(), frames_sent: frames_sent.clone(), mic_gain, - handler: audio_handler.clone(), + handler: AudioHandler::new(), + event_producer: event_producer.clone(), output_gain: output_gain.clone(), output_muted: output_muted.clone(), voice_activity_selector: voice_activity_selector.clone(), @@ -572,7 +578,7 @@ impl AudioEngine { voice_out_tx.clone(), transmit_gate.clone(), frames_sent.clone(), - audio_handler.clone(), + event_producer.clone(), output_gain.clone(), output_muted.clone(), voice_activity_selector.clone(), @@ -1007,8 +1013,8 @@ impl AudioEngine { let audio_processing_config = Arc::new(Mutex::new(crate::AudioProcessingConfig::default())); let audio_processing_stats = Arc::new(crate::SharedAudioProcessingStats::default()); - let audio_handler: Arc>> = - Arc::new(Mutex::new(AudioHandler::new())); + let event_queue = AudioEventQueue::new(); + let event_producer = AudioEventQueue::producer(&event_queue); if !cfg.mobile_voice_preset { return Err(AudioError::Backend( @@ -1069,7 +1075,8 @@ impl AudioEngine { transmit_active: transmit_flag_for_capture, frames_sent: frames_sent.clone(), mic_gain: cfg.mic_gain, - handler: audio_handler.clone(), + handler: AudioHandler::new(), + event_producer: event_producer.clone(), output_gain: output_gain.clone(), output_muted: output_muted.clone(), voice_activity_selector: cfg.voice_activity_selector.clone(), @@ -1103,7 +1110,7 @@ impl AudioEngine { voice_out_tx.clone(), transmit_gate.clone(), frames_sent.clone(), - audio_handler.clone(), + event_producer.clone(), output_gain.clone(), output_muted.clone(), cfg.voice_activity_selector.clone(), @@ -1151,7 +1158,7 @@ impl AudioEngine { let capture_active = true; let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel(); - let handler_for_task = audio_handler.clone(); + let event_producer_for_task = event_producer.clone(); let frames_received_for_task = frames_received.clone(); tokio::spawn(async move { loop { @@ -1164,10 +1171,8 @@ impl AudioEngine { match item { Some(v) => { let id = SessionAudioId(v.from_client); - let mut h = handler_for_task.lock().unwrap(); - if let Err(e) = h.handle_packet(id, v.packet) { - debug!(target: "chanora_audio", error = %e, "decode failed"); - } else { + let packet = AudioPacket { client_id: id, data: v.packet }; + if event_producer_for_task.push_packet(packet).is_ok() { frames_received_for_task.fetch_add(1, Ordering::Relaxed); } } @@ -1186,7 +1191,7 @@ impl AudioEngine { output_muted, audio_processing_config, audio_processing_stats, - audio_handler, + audio_event_producer: event_producer, voice_out_tx, voice_activity_selector: cfg.voice_activity_selector.clone(), mic_gain: cfg.mic_gain, @@ -1467,7 +1472,8 @@ impl AudioEngine { transmit_active: self.transmit_gate.flag_arc(), frames_sent: self.frames_sent.clone(), mic_gain: self.mic_gain, - handler: self.audio_handler.clone(), + handler: AudioHandler::new(), + event_producer: self.audio_event_producer.clone(), output_gain: self.output_gain.clone(), output_muted: self.output_muted.clone(), voice_activity_selector: self.voice_activity_selector.clone(), @@ -1488,7 +1494,7 @@ impl AudioEngine { self.voice_out_tx.clone(), self.transmit_gate.clone(), self.frames_sent.clone(), - self.audio_handler.clone(), + self.audio_event_producer.clone(), self.output_gain.clone(), self.output_muted.clone(), self.voice_activity_selector.clone(), @@ -1655,20 +1661,42 @@ impl AudioEngine { /// `0.0..4.0`. pub fn set_client_volume(&self, client_id: u64, volume: f32) { let clamped = volume.clamp(0.0, 4.0); - match self.audio_handler.lock() { - Ok(mut h) => { - if let Some(q) = h.get_mut_queues().get_mut(&SessionAudioId(client_id)) { - q.volume = clamped; + #[cfg(target_os = "android")] + { + let mut cmd = AudioCommand::SetVolume(SessionAudioId(client_id), clamped); + for _ in 0..64 { + match self.audio_event_producer.push_control(cmd) { + Ok(()) => return, + Err(returned) => { + cmd = returned; + std::thread::yield_now(); + } } } - Err(e) => { - tracing::warn!( - target: "chanora_audio", - client_id, - volume = clamped, - error = %e, - "set_client_volume: audio_handler lock poisoned — volume not applied" - ); + tracing::warn!( + target: "chanora_audio", + client_id, + volume = clamped, + "set_client_volume: control queue full after 64 retries — volume not applied" + ); + } + #[cfg(not(target_os = "android"))] + { + match self.audio_handler.lock() { + Ok(mut h) => { + if let Some(q) = h.get_mut_queues().get_mut(&SessionAudioId(client_id)) { + q.volume = clamped; + } + } + Err(e) => { + tracing::warn!( + target: "chanora_audio", + client_id, + volume = clamped, + error = %e, + "set_client_volume: audio_handler lock poisoned — volume not applied" + ); + } } } } diff --git a/crates/chanora_audio/src/lib.rs b/crates/chanora_audio/src/lib.rs index f49c4e4..cf58813 100644 --- a/crates/chanora_audio/src/lib.rs +++ b/crates/chanora_audio/src/lib.rs @@ -28,6 +28,8 @@ #![warn(missing_docs)] +#[cfg(target_os = "android")] +mod audio_event_queue; pub mod audio_processing; pub mod debug_wav; mod engine; diff --git a/crates/chanora_audio/src/mobile_voice_backend.rs b/crates/chanora_audio/src/mobile_voice_backend.rs index 8a352cd..15754e8 100644 --- a/crates/chanora_audio/src/mobile_voice_backend.rs +++ b/crates/chanora_audio/src/mobile_voice_backend.rs @@ -67,7 +67,7 @@ pub type BackendEventTx = mpsc::UnboundedSender; pub type AudioSessionId = i32; /// Engine-owned state shared with mobile voice audio callbacks. -#[derive(Clone)] +#[cfg_attr(not(target_os = "android"), derive(Clone))] pub(crate) struct VoiceAudioParams { /// Opus-encoded voice packets sent on this channel toward the /// protocol layer. @@ -78,8 +78,15 @@ pub(crate) struct VoiceAudioParams { pub frames_sent: Arc, /// Pre-encode amplitude scale (1.0 = unity). pub mic_gain: f32, + /// AudioHandler owned by the Android output callback. + #[cfg(target_os = "android")] + pub handler: AudioHandler, + /// Producer used by Android engine tasks to feed the output callback. + #[cfg(target_os = "android")] + pub event_producer: crate::audio_event_queue::AudioEventProducer, /// AudioHandler that inbound decode+mix feeds into; the output /// callback pulls mixed stereo f32 from it. + #[cfg(not(target_os = "android"))] pub handler: Arc>>, /// Master output gain (f32 bits stored in AtomicU32 for lock-free /// cross-thread read from the realtime audio callback). diff --git a/docs/security/license-inventory.md b/docs/security/license-inventory.md index 3435a09..e4b0c18 100644 --- a/docs/security/license-inventory.md +++ b/docs/security/license-inventory.md @@ -16,7 +16,7 @@ those terms. | License | Crate count | |---------|-------------| -| `Apache License 2.0` | 331 | +| `Apache License 2.0` | 333 | | `MIT License` | 74 | | `Unicode License v3` | 19 | | `BSD 3-Clause "New" or "Revised" License` | 14 | @@ -117,6 +117,7 @@ those terms. | pin-utils | 0.1.0 | `Apache License 2.0` | | | ecdsa | 0.16.9 | `Apache License 2.0` | | | rfc6979 | 0.4.0 | `Apache License 2.0` | | +| crossbeam | 0.8.4 | `Apache License 2.0` | | | ppv-lite86 | 0.2.21 | `Apache License 2.0` | | | rustls-pki-types | 1.14.1 | `Apache License 2.0` | | | keyring | 3.6.3 | `Apache License 2.0` | | @@ -144,6 +145,7 @@ those terms. | critical-section | 1.2.0 | `Apache License 2.0` | | | crossbeam-channel | 0.5.15 | `Apache License 2.0` | | | crossbeam-epoch | 0.9.18 | `Apache License 2.0` | | +| crossbeam-queue | 0.3.12 | `Apache License 2.0` | | | crossbeam-utils | 0.8.21 | `Apache License 2.0` | | | dbus-secret-service | 4.1.0 | `Apache License 2.0` | | | displaydoc | 0.2.6 | `Apache License 2.0` | | @@ -4993,6 +4995,213 @@ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION END OF TERMS AND CONDITIONS +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2019 The Crossbeam Project Developers + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +``` + +### Apache License 2.0 + +``` + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following