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
@@ -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<AudioPacket>,
/// Bounded reliable queue for control commands (volume, client removal).
/// On overflow, the caller retries. Capacity: 32 commands.
pub control_queue: ArrayQueue<AudioCommand>,
/// 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<Self> {
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<Self>) -> AudioEventProducer {
AudioEventProducer {
queue: Arc::clone(queue),
}
}
/// Create a consumer handle sharing this queue.
pub fn consumer(queue: &Arc<Self>) -> AudioEventConsumer {
AudioEventConsumer {
queue: Arc::clone(queue),
}
}
}
/// Producer side used by the inbound forwarder and engine control methods.
#[derive(Clone)]
pub struct AudioEventProducer {
queue: Arc<AudioEventQueue>,
}
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<AudioEventQueue> {
Arc::clone(&self.queue)
}
}
/// Consumer side used by the Android output callback.
pub struct AudioEventConsumer {
queue: Arc<AudioEventQueue>,
}
impl AudioEventConsumer {
/// Pop up to `cap` queued packets.
pub fn drain_packets(&self, cap: usize) -> impl Iterator<Item = AudioPacket> + '_ {
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<Item = AudioCommand> + '_ {
std::iter::from_fn(move || self.queue.control_queue.pop())
}
}