fix(audio): eliminate Android output stutter via Oboe config + lock-free callback (#20)

* 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<Vec<f32>>; 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<Mutex<>> on Android)
- Inbound forwarder pushes packets via AudioEventProducer (no mutex)
- set_client_volume pushes control commands via event queue on Android
- iOS/desktop Arc<Mutex<AudioHandler>> 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
This commit is contained in:
Edison Jwa
2026-06-05 13:57:53 +09:00
committed by GitHub
parent fb2a8e0a80
commit 2902a8bcd5
8 changed files with 510 additions and 82 deletions
+55 -27
View File
@@ -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<AtomicBool>,
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
#[cfg(not(target_os = "android"))]
audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
#[cfg(target_os = "android")]
audio_event_producer: crate::audio_event_queue::AudioEventProducer,
#[cfg(target_os = "android")]
voice_out_tx: mpsc::Sender<OutPacket>,
#[cfg(target_os = "android")]
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
@@ -482,7 +487,7 @@ impl AudioEngine {
voice_out_tx: mpsc::Sender<OutPacket>,
transmit_gate: crate::ptt::AudioTransmitGate,
frames_sent: Arc<AtomicU32>,
audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
event_producer: crate::audio_event_queue::AudioEventProducer,
output_gain: Arc<AtomicU32>,
output_muted: Arc<AtomicBool>,
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
@@ -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<Mutex<AudioHandler<SessionAudioId>>> =
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"
);
}
}
}
}