macOS realtime audio was suffering buffer underruns on CoreAudio's VPIO output callback. Root cause was twofold: AudioHandler was decoded under a Mutex held across the realtime callback, and the render path hard-coded mono i16 output regardless of the channel count the callback actually exposed (CoreAudio occasionally hands the callback stereo or quad output buffers, in which case writing only every Nth sample produced silence + clicks). This change brings macOS in line with the lock-free Android audio architecture introduced for output stutter elimination: * chanora_audio: AudioPacket / AudioCommand / AudioEventQueue (previously gated to `target_os = "android"`) are now compiled on macOS too. The decode loop in AudioEngine pushes inbound packets into the queue; the VPIO render callback owns AudioHandler outright and drains the queue, so the realtime thread never blocks on a cross-thread mutex. set_client_volume also routes through the command queue on macOS instead of locking the handler. * voice_render.rs: new downmix_stereo_f32_to_interleaved_i16 helper downmixes stereo f32 from AudioHandler to mono i16 and replicates that mono sample across every output channel the callback exposes. The existing downmix_stereo_f32_to_mono_i16 helper is retained for iOS, where VPIO is reliably configured for single-channel output via the AudioUnit stream format we pin at unit-create time. Compile-gated to ios + test so the macos build doesn't warn on dead code. * ios_voice_unit.rs: render callback reads data.channels from the args struct and forwards it to the new interleaved helper, so the macOS path tolerates whatever channel count CoreAudio assigns. A level decimation counter avoids running sqrt+log10 on every callback (~93 Hz) when the Flutter consumer only reads at 30 Hz; same regression class as the capture-side fix already in engine.rs. * mobile_voice_backend.rs: VoiceAudioParams now carries event_producer on macOS, and the AudioHandler is no longer wrapped in Arc<Mutex<…>> on macOS because ownership moves into the render callback. iOS keeps Arc<Mutex<…>> because its callback design shares the handler with the decode task. * lib.rs: audio_event_queue module is now compiled on macOS in addition to android. apps/chanora_flutter/lib/main.dart wraps the home tree in a Stack and overlays AudioDebugStatsPanel on macOS so the live engine counters (callback rate, drift, queue depth) used to diagnose the underrun are visible while iterating on this code. iOS and other platforms are unaffected. apps/chanora_flutter/macos/Frameworks/chanora_bridge.framework binary is rebuilt with these changes so flutter run on macOS picks up the new realtime path without requiring developers to rebuild the Rust crate locally. cargo check -p chanora_audio passes on macOS host.
185 lines
6.0 KiB
Rust
185 lines
6.0 KiB
Rust
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.
|
|
#[derive(Debug)]
|
|
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.
|
|
#[derive(Debug)]
|
|
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.
|
|
#[allow(dead_code)]
|
|
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())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn empty_packet(id: u64) -> AudioPacket {
|
|
let audio = chanora_protocol::AudioData::S2C {
|
|
codec: chanora_protocol::CodecType::OpusVoice,
|
|
id: 0x1234,
|
|
from: 0x5678,
|
|
data: &[1, 2, 3],
|
|
};
|
|
let out = chanora_protocol::OutAudio::new(&audio);
|
|
AudioPacket {
|
|
client_id: SessionAudioId(id),
|
|
data: InAudioBuf::try_new(chanora_protocol::Direction::S2C, out.data().to_vec())
|
|
.unwrap(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn packet_overflow_increments_drop_counter() {
|
|
let queue = AudioEventQueue::new();
|
|
let producer = AudioEventQueue::producer(&queue);
|
|
|
|
for i in 0..PACKET_QUEUE_CAPACITY {
|
|
let packet = empty_packet(i as u64);
|
|
assert!(producer.push_packet(packet).is_ok());
|
|
}
|
|
|
|
let overflow = empty_packet(999);
|
|
assert!(producer.push_packet(overflow).is_err());
|
|
assert_eq!(queue.packets_dropped.load(Ordering::Relaxed), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn consumer_drains_packets_and_controls() {
|
|
let queue = AudioEventQueue::new();
|
|
let producer = AudioEventQueue::producer(&queue);
|
|
let consumer = AudioEventQueue::consumer(&queue);
|
|
|
|
producer
|
|
.push_control(AudioCommand::SetVolume(SessionAudioId(7), 0.5))
|
|
.unwrap();
|
|
producer.push_packet(empty_packet(42)).unwrap();
|
|
|
|
let packets: Vec<_> = consumer.drain_packets(8).collect();
|
|
assert_eq!(packets.len(), 1);
|
|
assert_eq!(packets[0].client_id, SessionAudioId(42));
|
|
|
|
let controls: Vec<_> = consumer.drain_controls().collect();
|
|
assert_eq!(controls.len(), 1);
|
|
match controls[0] {
|
|
AudioCommand::SetVolume(id, vol) => {
|
|
assert_eq!(id, SessionAudioId(7));
|
|
assert_eq!(vol, 0.5);
|
|
}
|
|
AudioCommand::RemoveClient(_) => panic!("unexpected remove-client command"),
|
|
}
|
|
}
|
|
}
|