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:
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user