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
+93 -53
View File
@@ -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<Mutex<AudioHandler<SessionAudioId>>>,
handler: AudioHandler<SessionAudioId>,
event_consumer: crate::audio_event_queue::AudioEventConsumer,
output_gain: Arc<AtomicU32>,
output_muted: Arc<AtomicBool>,
event_tx: BackendEventTx,
scratch: Arc<Mutex<Vec<f32>>>,
render_reference: Arc<RenderReferenceBuffer>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
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::<OboeInput>()
@@ -766,8 +779,8 @@ impl AndroidVoiceUnit {
let output_builder = AudioStreamBuilder::default()
.set_direction::<OboeOutput>()
.set_sample_rate(cfg.sample_rate as i32)
.set_channel_count::<Mono>()
.set_format::<i16>()
.set_channel_count::<Stereo>()
.set_format::<f32>()
.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<Mutex<AudioHandler<SessionAudioId>>>,
handler: AudioHandler<SessionAudioId>,
event_consumer: crate::audio_event_queue::AudioEventConsumer,
output_gain: Arc<AtomicU32>,
output_muted: Arc<AtomicBool>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
scratch: Arc<Mutex<Vec<f32>>>,
render_reference: Arc<RenderReferenceBuffer>,
) -> Result<AudioStreamAsync<OboeOutput, OutputCallback>, 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::<OboeOutput>()
.set_sample_rate(cfg.sample_rate as i32)
.set_channel_count::<Mono>()
.set_format::<i16>()
.set_channel_count::<Stereo>()
.set_format::<f32>()
.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()