Files
chanora/crates/chanora_audio/src/ios_voice_unit.rs
T

1056 lines
47 KiB
Rust

//! Apple output + capture stream via the **VoiceProcessingIO**
//! AudioUnit (`kAudioUnitSubType_VoiceProcessingIO`, a.k.a. VPIO).
//!
//! ## Why not cpal on Apple voice paths
//!
//! cpal's Apple backend does not expose the voice-processing unit controls
//! Chanora needs for a VoIP client. On iOS, cpal opens
//! `kAudioUnitSubType_RemoteIO` with no
//! control over the stream format, buffer size, or channel count;
//! on iPhone 16 Pro running iOS 18 it reports the output element as
//! **mono 48 kHz** even when the session category is `.playAndRecord`
//! with mode `.default`. More importantly, RemoteIO opened by cpal
//! stays bound to the route that was active at construction time:
//! a later `AVAudioSession.overrideOutputAudioPort(.speaker)` flips
//! the session route metadata (visible in
//! `AVAudioSession.currentRoute`) but the underlying AudioUnit
//! keeps writing to the original transducer. End-user symptom: the
//! Speaker / Receiver toggle in our picker shows the route change
//! in logs but produces no audible difference — the audio is still
//! coming out the earpiece.
//!
//! Production VoIP clients on Apple platforms drive VPIO directly instead
//! of treating CoreAudio as a generic music-playback device. VPIO is Apple's
//! native voice unit: it ships hardware AEC + AGC + NS and accepts explicit
//! stream-format requests on bus 0 (output) and bus 1 (input).
//!
//! This file replaces the cpal capture + playback streams on iOS and macOS.
//! Linux uses SDL2 for output (see `sdl_output.rs`) and cpal for capture;
//! Windows uses cpal's WASAPI backend.
//!
//! ## What VPIO gives us
//!
//! * Pinned **48 kHz Int16 mono** stream format on both bus 0
//! (output to hardware) and bus 1 (input from hardware). 48 kHz
//! matches the Opus encoder + `tsclientlib::AudioHandler` mix
//! rate exactly, so no resampling is needed inside the audio
//! callback. Int16 is Apple's documented canonical iOS sample
//! format for AudioUnits (see Audio Unit Hosting Guide for iOS
//! §"Canonical formats").
//! * Hardware **AEC** (acoustic echo cancellation), **AGC**
//! (automatic gain control), and **NS** (noise suppression) ran
//! in the secure-enclave-adjacent voice processor. Free DSP that
//! we'd otherwise need to ship as software (DEC-007/008/009).
//! * **Route-change-correct** physical binding: tapping Speaker or
//! Receiver in our picker now actually moves the audio.
//!
//! ## Threading & lifecycle
//!
//! `coreaudio::audio_unit::AudioUnit` is `Send` but `!Sync` — the
//! AudioUnit internally holds the C `AudioUnit` opaque pointer and
//! the wrapper's destructor calls `AudioComponentInstanceDispose`,
//! which (per Apple's threading rules) must be called from the
//! thread that owns the unit. We open the unit on the same thread
//! that calls `Self::start` (the tokio worker that runs
//! `chanora_core::ChanoraSession::start_audio`, the same pattern
//! cpal + SDL use) and never move it. The outer `AudioEngine`
//! already carries an `unsafe impl Send` to satisfy the same
//! constraint for cpal's `!Send` Stream type; that impl covers
//! VPIO too.
//!
//! Dropping `IosVoiceUnit` calls `audio_unit.stop()` via the
//! wrapper's `Drop`, which detaches the render + input callbacks
//! and stops the unit. The AudioHandler + CaptureState `Arc`s the
//! callbacks held are then released.
//!
//! ## What this file does NOT do
//!
//! * Route-change observation — that lives in Swift
//! (`AppDelegate.handleRouteChange`) and bounces the unit via a
//! future FRB call. Tracked as Commit 5 of the VPIO rollout.
//! * AVAudioSession category / mode configuration — Swift owns the
//! session (it must be set up before Flutter loads).
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
use audiopus::coder::Encoder as OpusEncoder;
use coreaudio::audio_unit::audio_format::LinearPcmFlags;
use coreaudio::audio_unit::render_callback::{self, data};
use coreaudio::audio_unit::IOType;
use coreaudio::audio_unit::{AudioUnit, Element, SampleFormat, Scope, StreamFormat};
use tokio::sync::mpsc;
use tracing::{debug, error, info, warn};
use crate::mobile_voice_backend::VoiceAudioParams;
use crate::AudioError;
use chanora_protocol::OutPacket;
/// Sample rate every layer above us assumes. Matches the Opus
/// encoder rate, the `tsclientlib::AudioHandler` mix rate, and the
/// sample rate we ask iOS to give us via VPIO's StreamFormat.
const SAMPLE_RATE_HZ: f64 = 48_000.0;
/// Output element (bus 0) of an `IOType::VoiceProcessingIO` unit
/// drives the hardware speaker / receiver / AirPods / BT. The
/// stream format we set on `Scope::Input` of this element is the
/// format **we** push samples in; VPIO converts internally to
/// whatever the hardware needs.
const OUTPUT_BUS: Element = Element::Output;
/// Input element (bus 1) of an `IOType::VoiceProcessingIO` unit
/// pulls from the hardware microphone. The stream format we set on
/// `Scope::Output` of this element is the format **we** receive
/// samples in.
const INPUT_BUS: Element = Element::Input;
/// Pre-roll buffer capacity: 160 ms / 10 ms = 16 frames.
/// Stores processed i16 frames so the first syllable is not lost
/// when the VAD gate opens (VAD_004 / pre_roll_ms=160).
const PRE_ROLL_FRAMES: usize = 16;
/// Capture pipeline state owned by the VPIO input callback. The
/// AudioUnit hands us 48 kHz signed-int16 mono PCM directly (no
/// downmix or resample needed — VPIO's hardware-side mix-down
/// from whatever the route's native format is happens before we
/// see the samples). All this struct does is gate on PTT, scale
/// by mic_gain, accumulate to a 20 ms / 960-sample frame, encode
/// to Opus, and try-send the resulting packet on the protocol
/// queue.
///
/// Mirrors the cpal-side `CaptureState` in engine.rs but is
/// type-specialised to i16 (the cpal version is generic over
/// `T: ToF32` to handle arbitrary HAL formats). Same Opus VoIP
/// tuning (32 kbps, complexity 10, inband FEC, 5% packet-loss
/// budget) lifted verbatim from `try_open_capture` so iOS audio
/// quality matches every other platform.
///
/// This struct is moved into the VPIO `set_input_callback` closure
/// and is therefore `'static + Send`. The OpusEncoder + Vec + arrays
/// are all owned; the two atomics + sender are `Arc<...>` clones
/// shared with `AudioEngine`.
struct IosCaptureState {
encoder: OpusEncoder,
/// 48 kHz mono PCM scratch accumulating to FRAME_20MS_SAMPLES
/// per encode. Capacity 2x to absorb cpal-style buffer-size
/// jitter without reallocating.
pcm_accum: Vec<i16>,
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
voice_out_tx: mpsc::Sender<OutPacket>,
transmit_active: Arc<AtomicBool>,
output_muted: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
mic_gain: f32,
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
vad_detector: crate::vad::WebRtcFallbackVad,
/// Background Silero worker — enqueues frames off the realtime
/// callback and publishes the latest probability atomically.
silero_vad_worker: Option<crate::vad::silero_onnx::SileroOnnxVadWorker>,
/// Last VAD backend we configured — used to detect backend changes.
current_vad_backend: crate::VadBackend,
/// Last observed configured Silero model epoch.
silero_model_epoch: u64,
fallback_warned_backend: Option<crate::VadBackend>,
vad_state: crate::voice_activity::VoiceActivityStateMachine,
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
sonora_processor: crate::processor::SonoraProcessor,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
pending_10ms: [i16; crate::frame::FRAME_10MS_SAMPLES],
pending_10ms_len: usize,
pre_roll_buf: [[i16; crate::frame::FRAME_10MS_SAMPLES]; PRE_ROLL_FRAMES],
pre_roll_head: usize,
pre_roll_count: usize,
pre_roll_flushed: bool,
capture_frame_seq: u64,
wav_recorder: Arc<Mutex<Option<Arc<crate::debug_wav::WavDebugRecorder>>>>,
}
impl IosCaptureState {
/// Build a VoIP-tuned Opus encoder + the capture-state wrapper.
/// Encoder configuration is the same as cpal-side
/// `try_open_capture` (engine.rs) so audio quality is platform-
/// neutral.
fn new(
params: &VoiceAudioParams,
wav_recorder: Arc<Mutex<Option<Arc<crate::debug_wav::WavDebugRecorder>>>>,
) -> Result<Self, AudioError> {
let encoder = crate::opus_voice::new_voip_encoder("ios VPIO")?;
Ok(Self {
encoder,
pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
voice_out_tx: params.voice_out_tx.clone(),
transmit_active: params.transmit_active.clone(),
output_muted: params.output_muted.clone(),
frames_sent: params.frames_sent.clone(),
mic_gain: params.mic_gain,
voice_activity_selector: params.voice_activity_selector.clone(),
vad_detector: crate::vad::WebRtcFallbackVad::default(),
silero_vad_worker: None,
current_vad_backend: crate::VadBackend::WebrtcVad,
silero_model_epoch: crate::vad::silero_model_epoch(),
fallback_warned_backend: None,
vad_state: crate::voice_activity::VoiceActivityStateMachine::default(),
audio_processing_config: params.audio_processing_config.clone(),
sonora_processor: crate::processor::SonoraProcessor::new(),
audio_processing_stats: params.audio_processing_stats.clone(),
pending_10ms: [0_i16; crate::frame::FRAME_10MS_SAMPLES],
pending_10ms_len: 0,
pre_roll_buf: [[0_i16; crate::frame::FRAME_10MS_SAMPLES]; PRE_ROLL_FRAMES],
pre_roll_head: 0,
pre_roll_count: 0,
pre_roll_flushed: false,
capture_frame_seq: 0,
wav_recorder,
})
}
fn mark_vad_fallback_active(&mut self, failed_backend: crate::VadBackend) {
if self.fallback_warned_backend == Some(failed_backend) {
return;
}
self.fallback_warned_backend = Some(failed_backend);
if self.capture_frame_seq < 128 {
tracing::info!(
target: "chanora_audio",
backend = failed_backend.as_str(),
seq = self.capture_frame_seq,
"VAD backend warming up; using WebRTC fallback"
);
} else {
tracing::warn!(
target: "chanora_audio",
backend = failed_backend.as_str(),
"VAD backend unavailable; using WebRTC fallback for runtime detection"
);
}
}
/// Consume the i16 mono buffer delivered by VPIO, accumulate
/// to a 20 ms frame boundary, encode + send when PTT is held.
///
/// VPIO's input element delivers samples already at the
/// stream format we pinned (48 kHz Int16 mono interleaved).
/// In practice "interleaved mono" is the same byte layout as
/// "planar mono" so we just take the buffer as-is.
fn ingest_i16(&mut self, samples: &[i16]) {
let mut offset = 0;
while offset < samples.len() {
let remaining = crate::frame::FRAME_10MS_SAMPLES - self.pending_10ms_len;
let take = remaining.min(samples.len() - offset);
self.pending_10ms[self.pending_10ms_len..self.pending_10ms_len + take]
.copy_from_slice(&samples[offset..offset + take]);
self.pending_10ms_len += take;
offset += take;
if self.pending_10ms_len == crate::frame::FRAME_10MS_SAMPLES {
let frame = self.pending_10ms;
self.process_10ms_capture_frame(&frame);
self.pending_10ms_len = 0;
}
}
if !self.transmit_active.load(Ordering::Relaxed) {
// Drain accumulator while muted so we don't pop on the
// PTT release edge. Matches cpal-side behaviour.
self.pcm_accum.clear();
return;
}
// Drain complete 20 ms frames out of the accumulator, encode
// each, send the resulting Opus packet on the protocol
// queue. The `while` covers the case where a single VPIO
// callback delivers more than one frame's worth (rare on
// iOS where the HW IO buffer duration aligns with the
// Opus frame, but always possible during route changes).
while self.pcm_accum.len() >= crate::frame::FRAME_20MS_SAMPLES {
// Use a stack-allocated frame buffer to avoid the
// per-callback allocation a `drain(..N).collect()`
// would incur. The encoder doesn't need ownership.
let mut frame = [0i16; crate::frame::FRAME_20MS_SAMPLES];
frame.copy_from_slice(&self.pcm_accum[..crate::frame::FRAME_20MS_SAMPLES]);
self.pcm_accum.drain(..crate::frame::FRAME_20MS_SAMPLES);
match self.encoder.encode(&frame, &mut self.opus_out[..]) {
Ok(len) => {
crate::opus_voice::send_voip_frame(
&self.voice_out_tx,
&self.frames_sent,
&self.opus_out,
len,
|| {
warn!(
target: "chanora_audio",
"ios VPIO: voice_out queue full; dropping frame"
);
},
|| {
debug!(
target: "chanora_audio",
"ios VPIO: voice_out closed; capture pipeline stopping"
);
},
);
}
Err(e) => {
error!(target: "chanora_audio", error = %e, "ios VPIO opus encode failed");
}
}
}
}
fn process_10ms_capture_frame(&mut self, samples: &[i16; crate::frame::FRAME_10MS_SAMPLES]) {
let mut frame = [0.0_f32; crate::frame::FRAME_10MS_SAMPLES];
for (dst, src) in frame.iter_mut().zip(samples.iter().copied()) {
*dst = crate::frame::i16_to_f32(src);
}
let input_dbfs = crate::frame::dbfs(&frame);
// WAV tap: raw mic (before processing, DIAG_002).
if let Ok(guard) = self.wav_recorder.try_lock() {
if let Some(rec) = guard.as_ref() {
rec.push_raw_mic(&frame);
}
}
// Read config once per frame (try_lock: non-blocking, falls back to
// last-known values if the lock is contended — safe to miss one frame).
let (
run_ns,
run_agc,
run_hpf,
vad_backend,
vad_hangover,
debug_wav_dump_enabled,
route,
processing_backend,
) = self
.audio_processing_config
.try_lock()
.map(|cfg| {
let ns =
cfg.ns != crate::EffectOwner::Off && cfg.ns != crate::EffectOwner::Platform;
let agc =
cfg.agc != crate::EffectOwner::Off && cfg.agc != crate::EffectOwner::Platform;
let hpf = cfg.hpf_enabled;
(
ns,
agc,
hpf,
cfg.vad_backend,
cfg.vad_hangover_ms,
cfg.debug_wav_dump_enabled,
cfg.route,
cfg.processing_backend,
)
})
.unwrap_or((
false,
false,
true,
crate::VadBackend::SileroOnnx,
crate::voice_activity::VAD_HANGOVER_MS,
false,
crate::AudioRoute::Unknown,
crate::AudioBackend::PlatformVoiceProcessing,
));
let voice_activity_mode = self
.voice_activity_selector
.as_ref()
.map(|selector| selector.mode() == crate::TransmitMode::VoiceActivity)
.unwrap_or(false);
if !voice_activity_mode {
self.silero_vad_worker = None;
self.current_vad_backend = crate::VadBackend::Disabled;
self.fallback_warned_backend = None;
self.audio_processing_stats.set_vad_fallback_active(false);
}
// Switch VAD backend only while VoiceActivity mode is active.
let silero_model_epoch = crate::vad::silero_model_epoch();
let silero_model_changed = voice_activity_mode
&& vad_backend == crate::VadBackend::SileroOnnx
&& silero_model_epoch != self.silero_model_epoch;
if let Ok(mut recorder_guard) = self.wav_recorder.try_lock() {
if debug_wav_dump_enabled {
if recorder_guard.is_none() {
*recorder_guard = Some(crate::debug_wav::WavDebugRecorder::start(
route,
processing_backend,
));
}
} else if let Some(recorder) = recorder_guard.take() {
recorder.stop();
}
}
if voice_activity_mode && (vad_backend != self.current_vad_backend || silero_model_changed)
{
self.current_vad_backend = vad_backend;
self.fallback_warned_backend = None;
self.silero_model_epoch = silero_model_epoch;
match vad_backend {
crate::VadBackend::SileroOnnx => {
// Attempt to load Silero model from the well-known
// bundle path. The actual inference runs on a
// background worker; the callback only enqueues
// 10 ms frames and falls back to WebRTC if the
// worker is missing or stale.
let model_path = crate::vad::silero_model_bundle_path();
self.silero_vad_worker =
crate::vad::silero_onnx::SileroOnnxVadWorker::try_new(&model_path);
if self.silero_vad_worker.is_none() {
warn!(
target: "chanora_audio",
"Silero VAD model not found at {model_path}; falling back to WebRTC VAD"
);
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
}
self.audio_processing_stats
.set_vad_fallback_active(self.silero_vad_worker.is_none());
}
_ => {
self.silero_vad_worker = None;
self.audio_processing_stats.set_vad_fallback_active(false);
}
}
// Reset VAD state machine timers on backend switch.
self.vad_state = crate::voice_activity::VoiceActivityStateMachine::new(
crate::voice_activity::VAD_OPEN_AFTER_MS,
vad_hangover,
crate::voice_activity::VAD_MIN_TX_MS,
);
self.vad_state.reset();
}
let transmit_active = self.transmit_active.load(Ordering::Relaxed);
if voice_activity_mode {
// Keep the VAD state machine aligned with the active config only
// while VoiceActivity mode owns the transmit gate.
self.vad_state.configure(
crate::voice_activity::VAD_OPEN_AFTER_MS,
vad_hangover,
crate::voice_activity::VAD_MIN_TX_MS,
);
}
// VPIO owns AEC. Keep the legacy non-AEC conditioning path here until
// the raw WebRTC APM path is explicitly selected.
if run_ns || run_agc || run_hpf {
use crate::processor::sonora::SonoraConfig;
use crate::processor::AudioProcessor;
let new_cfg = SonoraConfig {
hpf: run_hpf,
aec3: false,
ns: run_ns,
agc2: run_agc,
};
if new_cfg != *self.sonora_processor.config() {
self.sonora_processor.apply_config(new_cfg);
}
self.sonora_processor.process_capture(&mut frame);
}
// VAD: only evaluate while VoiceActivity mode is active.
let (vad_probability, gate_open) = if voice_activity_mode {
self.capture_frame_seq = self.capture_frame_seq.wrapping_add(1);
let capture_seq = self.capture_frame_seq;
let mut used_fallback_vad = false;
let vad = if vad_backend == crate::VadBackend::Disabled {
crate::vad::VadOutput {
probability: 1.0,
speech: true,
}
} else if vad_backend == crate::VadBackend::SileroOnnx {
if let Some(worker) = self.silero_vad_worker.as_ref() {
let enqueued = worker.try_send(capture_seq, &frame);
if !worker.is_stale(capture_seq) {
let probability = worker.latest_probability();
crate::vad::VadOutput {
probability,
speech: probability >= 0.5,
}
} else if enqueued {
crate::vad::VadOutput {
probability: 0.0,
speech: false,
}
} else {
used_fallback_vad = true;
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
crate::vad::VoiceActivityDetector::process_10ms(
&mut self.vad_detector,
&frame,
)
}
} else {
used_fallback_vad = true;
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
}
} else {
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
};
self.audio_processing_stats
.set_vad_fallback_active(used_fallback_vad);
(vad.probability, self.vad_state.update(vad.speech))
} else {
(0.0, false)
};
let output_muted = self.output_muted.load(Ordering::Relaxed);
if let Some(selector) = &self.voice_activity_selector {
selector.set_voice_activity_open(voice_activity_mode && gate_open && !output_muted);
}
self.audio_processing_stats.update_capture(
input_dbfs,
crate::frame::dbfs(&frame),
vad_probability,
voice_activity_mode && gate_open && !output_muted,
transmit_active,
);
// WAV tap: processed mic (after Rust DSP, DIAG_002).
if let Ok(guard) = self.wav_recorder.try_lock() {
if let Some(rec) = guard.as_ref() {
rec.push_processed_mic(&frame);
}
}
// Convert to i16 for accumulation.
let mut pcm_frame = [0_i16; crate::frame::FRAME_10MS_SAMPLES];
if (self.mic_gain - 1.0).abs() < f32::EPSILON {
for (dst, src) in pcm_frame.iter_mut().zip(frame.iter().copied()) {
*dst = crate::frame::f32_to_i16(src);
}
} else {
let gain = self.mic_gain;
for (dst, src) in pcm_frame.iter_mut().zip(frame.iter().copied()) {
let scaled = crate::frame::f32_to_i16(src) as f32 * gain;
*dst = scaled.clamp(i16::MIN as f32, i16::MAX as f32) as i16;
}
}
// Update pre-roll ring buffer (VAD_004: preserve first syllable).
let slot_idx = self.pre_roll_head % PRE_ROLL_FRAMES;
self.pre_roll_buf[slot_idx] = pcm_frame;
self.pre_roll_head = (self.pre_roll_head + 1) % PRE_ROLL_FRAMES;
if self.pre_roll_count < PRE_ROLL_FRAMES {
self.pre_roll_count += 1;
}
// If the transmit gate just opened and we haven't flushed the
// pre-roll yet, drain it into the accumulator.
if transmit_active && !self.pre_roll_flushed {
self.pre_roll_flushed = true;
// The oldest frame in the ring is at
// (pre_roll_head + PRE_ROLL_FRAMES - pre_roll_count) % PRE_ROLL_FRAMES.
// We emit frames in chronological order (oldest first), excluding
// the frame we just wrote (which goes into pcm_accum normally below).
let oldest =
(self.pre_roll_head + PRE_ROLL_FRAMES - self.pre_roll_count) % PRE_ROLL_FRAMES;
// Emit pre_roll_count - 1 frames (the -1 excludes the current frame
// which will be added below in the normal path).
let pre_roll_to_emit = self.pre_roll_count.saturating_sub(1);
for i in 0..pre_roll_to_emit {
let idx = (oldest + i) % PRE_ROLL_FRAMES;
self.pcm_accum.extend_from_slice(&self.pre_roll_buf[idx]);
}
} else if !transmit_active {
// Gate closed — reset the flush flag so pre-roll fires again
// on the next gate open.
self.pre_roll_flushed = false;
}
if !transmit_active {
return;
}
self.pcm_accum.extend_from_slice(&pcm_frame);
}
}
/// Live iOS audio unit wrapper. Construct + start = audio
/// flowing; drop = audio stopped.
pub struct IosVoiceUnit {
// Drop = stop the audio unit (severs render callback). The
// wrapper's own Drop calls AudioComponentInstanceDispose
// after stop returns.
unit: AudioUnit,
}
impl IosVoiceUnit {
/// Open a VoiceProcessingIO AudioUnit, pin its stream format
/// to 48 kHz Int16 mono on both buses, install render + input
/// callbacks, and start it. The unit begins pumping audio
/// immediately on return — input callback fires when the mic
/// captures samples, render callback fires when the hardware
/// needs samples to play.
///
/// Parameters mirror the capture + playback inputs the cpal
/// and SDL backends accept so engine.rs can swap backends with
/// a `cfg`.
///
/// * `handler` — shared AudioHandler the inbound forwarder
/// feeds Opus packets into. The output render callback pulls
/// decoded f32 frames from it (48 kHz stereo) and downmixes
/// to the i16 mono buffer VPIO expects.
/// * `output_gain` / `output_muted` — same atomics the cpal
/// and SDL output paths read on every callback so the master
/// volume + local-mute UI works identically across backends.
/// * `voice_out_tx` — channel the capture pipeline sends
/// encoded `OutPacket`s on.
/// * `transmit_active` — PTT gate flag the capture pipeline
/// consults before encoding.
/// * `frames_sent` — counter the bridge stats surface reads.
/// * `mic_gain` — pre-encode amplitude scale.
///
/// Capture wiring landed in commit 3; playback wiring landed
/// in commit 4. Route-change observation is commit 5.
pub(crate) fn start(params: VoiceAudioParams) -> Result<Self, AudioError> {
// Construct the VoiceProcessingIO AudioUnit. cpal exposes
// `Default::default()` which on iOS picks the inferior
// RemoteIO unit; we explicitly pick VPIO. `coreaudio-rs`
// returns an already-initialized unit from `new`, but we
// need to set properties before init so use
// `new_uninitialized` and call `initialize` ourselves
// after the property set is complete.
let mut unit = AudioUnit::new_uninitialized(IOType::VoiceProcessingIO)
.map_err(|e| AudioError::Backend(format!("vpio audio unit new: {e}")))?;
// Enable input I/O on element 1. VPIO's input element is
// OFF by default — without this `kAudioOutputUnitProperty_EnableIO`
// toggle no audio flows in and the input callback never
// fires. The constant is 2003 per Apple's headers
// (`AudioUnitProperties.h`). The value is a u32 with
// 1 = enabled. Element::Input on `Scope::Input` is the
// mic side of the unit (element 1 of bus 1; despite the
// confusing nomenclature, `Scope::Input` here means
// "input to the unit" i.e. mic samples coming IN).
//
// Output element 0 is enabled by default for any
// `kAudioUnitType_Output` subtype (which VPIO is), so we
// don't need to toggle anything for playback.
//
// Apple's documented sequence for VPIO setup:
// 1. AudioComponentInstanceNew (= AudioUnit::new_uninitialized)
// 2. EnableIO on element 1 (= this set_property call)
// 3. Set stream format on both elements
// 4. Install callbacks
// 5. AudioUnitInitialize (= unit.initialize)
// 6. AudioOutputUnitStart (= unit.start)
const K_AUDIO_OUTPUT_UNIT_PROPERTY_ENABLE_IO: u32 = 2003;
let enable: u32 = 1;
unit.set_property(
K_AUDIO_OUTPUT_UNIT_PROPERTY_ENABLE_IO,
Scope::Input,
Element::Input,
Some(&enable),
)
.map_err(|e| AudioError::Backend(format!("vpio enable input I/O: {e}")))?;
// Note: we keep VPIO's voice processing chain ENABLED
// (AEC + AGC + NS on the mic path) because it gives us
// clean capture for free. The historical playback
// breakage we attributed to this chain (commit c16318c
// tried to bypass it) was actually caused by the
// AVAudioSession mode .voiceChat ducking output to the
// earpiece \u2014 fixed in AppDelegate.swift by switching
// to .default + .defaultToSpeaker. With the session mode
// correct, voice processing can stay on.
// Stream format. Apple's iOS canonical format for
// AudioUnits is Linear PCM, 16-bit signed integer samples
// (see "Canonical formats" in the Audio Unit Hosting Guide
// for iOS). Mono channel matches the Opus encoder + the
// mic input pipe. 48 kHz matches every layer above us.
//
// The StreamFormat is set on:
// * OUTPUT_BUS (bus 0), Scope::Input — the format WE
// push to the unit, i.e. what fill_buffer writes.
// * INPUT_BUS (bus 1), Scope::Output — the format we
// RECEIVE from the unit, i.e. what the mic input
// callback hands us.
// This pairing is documented in Audio Unit Hosting Guide
// for iOS §"Specifying the Audio Stream Format".
let stream_format = StreamFormat {
sample_rate: SAMPLE_RATE_HZ,
sample_format: SampleFormat::I16,
flags: LinearPcmFlags::IS_SIGNED_INTEGER | LinearPcmFlags::IS_PACKED,
channels: 1,
};
unit.set_stream_format(stream_format, Scope::Input, OUTPUT_BUS)
.map_err(|e| {
AudioError::StreamConfig(format!(
"vpio set output stream format (bus 0 input scope): {e}"
))
})?;
unit.set_stream_format(stream_format, Scope::Output, INPUT_BUS)
.map_err(|e| {
AudioError::StreamConfig(format!(
"vpio set input stream format (bus 1 output scope): {e}"
))
})?;
// Build the capture pipeline and move it into the input
// callback. The Opus encoder + accumulator + opus_out
// scratch are owned by the closure — no Mutex needed
// because the input callback is the sole writer/reader on
// the audio thread.
let wav_recorder = Arc::new(Mutex::new({
let cfg = params.audio_processing_config.lock().unwrap().clone();
if cfg.debug_wav_dump_enabled {
Some(crate::debug_wav::WavDebugRecorder::start(
cfg.route,
cfg.processing_backend,
))
} else {
None
}
}));
let mut capture_state = IosCaptureState::new(&params, wav_recorder.clone())?;
unit.set_input_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
// VPIO with our pinned stream format delivers
// interleaved Int16 mono. `args.data.buffer` is a
// `&mut [i16]` of length num_frames * channels = N * 1.
// coreaudio-rs handles the AudioBufferList plumbing +
// the AudioUnitRender call internally before invoking
// this closure.
capture_state.ingest_i16(args.data.buffer);
Ok(())
})
.map_err(|e| AudioError::Backend(format!("vpio set input callback: {e}")))?;
// Install the render callback that drives playback. The
// buffer iOS hands us is uninitialised — we MUST fill it
// (writing silence if we have nothing, never leaving stale
// frames).
//
// Pipeline per callback:
// 1. Lock the AudioHandler, ask it to fill a scratch
// f32 stereo buffer (length = 2 * num_frames). The
// handler runs Opus decode + per-client jitter
// buffer + mix. Same primitive cpal + SDL output
// paths use; this is the platform-neutral playback
// contract from `tsclientlib::audio::AudioHandler`.
// 2. Downmix to i16 mono with master gain. VPIO expects
// mono int16 (the stream format we pinned above);
// the handler produces stereo f32. We average L+R
// to a single mono channel rather than dropping R —
// the cpal-side mono-output path made the same
// mistake briefly (commit 6a4dbad / fix) and lost
// half the spatial mix.
// 3. Local-mute zeroes the output but STILL drains
// AudioHandler in step 1 so its jitter buffer
// doesn't grow unbounded while muted. This is the
// contract every other backend follows (matches
// SdlOutput::callback and the cpal output stream).
//
// Build the playback pipeline (direct fill_buffer in
// render callback; matches tsclientlib's reference SDL
// example at
// tsclientlib/examples/audio_utils/ts_to_audio.rs).
//
// The earlier ring-buffer attempt (rc.8+73..+74) decoupled
// AudioHandler from the render callback via a 50 Hz
// producer task + SPSC ring buffer. The +74 diagnostics
// showed that approach was making things worse: the
// producer drained AudioHandler at 50 Hz, but iOS VPIO
// calls our render callback at ~43.5 Hz (consuming 1440
// mono samples per 23 ms call). With consumer slightly
// slower than producer in chunks-per-second but each
// consumer pull being larger, the ring averaged out empty
// — fill_buffer was returning silence 65-84% of ticks
// because we drained it too aggressively before packets
// arrived. Linux/SDL's same pattern works fine because
// SDL calls fill_buffer at exactly the device callback
// rate.
//
// Revert to direct call: render callback locks
// AudioHandler, asks for `num_frames` stereo frames, and
// immediately downmixes to i16 mono into the output
// buffer. Same as Linux/SDL, just stereo-f32 -> mono-i16
// converted at the boundary.
let mut scratch_stereo: Vec<f32> = Vec::with_capacity(2048);
let handler_for_render = params.handler.clone();
let output_gain_for_render = params.output_gain.clone();
let output_muted_for_render = params.output_muted.clone();
let audio_processing_stats_for_render = params.audio_processing_stats.clone();
let wav_recorder_for_render = wav_recorder.clone();
// Diagnostic counters (sampled every 100 callbacks ~= 2 s).
let mut cb_count: u64 = 0;
let mut last_num_frames: usize = 0;
let mut num_frames_changes: u32 = 0;
let mut callbacks_with_audio: u64 = 0;
let mut callbacks_with_silence: u64 = 0;
let mut render_ref_accum = [0.0_f32; crate::frame::FRAME_10MS_SAMPLES];
let mut render_ref_len: usize = 0;
let mut render_recorder_active = false;
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
let out: &mut [i16] = args.data.buffer;
let num_frames = out.len();
// AudioHandler produces 48 kHz stereo f32 (= num_frames * 2 floats).
let needed = num_frames * 2;
if scratch_stereo.len() < needed {
scratch_stereo.resize(needed, 0.0);
}
// Zero the live slice. AudioHandler::fill_buffer is
// additive (does NOT clear); residual values from
// earlier callbacks (when scratch was bigger) would
// leak through otherwise.
scratch_stereo[..needed].fill(0.0);
// Non-blocking fill on the realtime callback thread.
// If the inbound forwarder currently owns this mutex,
// emit this period as silence instead of blocking and
// risking an AudioUnit underrun pop/click.
match handler_for_render.try_lock() {
Ok(mut h) => {
let _removed = h.fill_buffer(&mut scratch_stereo[..needed]);
}
Err(std::sync::TryLockError::WouldBlock) => {
audio_processing_stats_for_render.increment_callback_xrun();
// scratch_stereo is already zeroed above.
}
Err(std::sync::TryLockError::Poisoned(e)) => {
// Never panic on the realtime IO thread.
warn!(target: "chanora_audio", "AudioHandler mutex poisoned: {e}");
}
}
let gain = f32::from_bits(output_gain_for_render.load(Ordering::Relaxed));
let muted = output_muted_for_render.load(Ordering::Relaxed);
let mix_stats = crate::voice_render::downmix_stereo_f32_to_mono_i16(
&scratch_stereo[..needed],
out,
gain,
muted,
);
if mix_stats.clipped_samples > 0 {
audio_processing_stats_for_render.add_clipped_samples(mix_stats.clipped_samples);
}
audio_processing_stats_for_render.update_render(
crate::frame::dbfs(&scratch_stereo[..needed]),
num_frames as u32,
);
if let Ok(guard) = wav_recorder_for_render.try_lock() {
if let Some(rec) = guard.as_ref() {
if !render_recorder_active {
render_ref_len = 0;
render_ref_accum.fill(0.0);
render_recorder_active = true;
}
let mut idx = 0;
while idx + 1 < needed {
let mono = (scratch_stereo[idx] + scratch_stereo[idx + 1]) * 0.5;
render_ref_accum[render_ref_len] = mono;
render_ref_len += 1;
idx += 2;
if render_ref_len == crate::frame::FRAME_10MS_SAMPLES {
rec.push_render_reference(&render_ref_accum);
render_ref_len = 0;
}
}
} else {
render_recorder_active = false;
}
} else {
render_recorder_active = false;
}
// Track audio-vs-silence for the diagnostic.
if mix_stats.peak_i16 > 0 {
callbacks_with_audio = callbacks_with_audio.wrapping_add(1);
} else {
audio_processing_stats_for_render.increment_output_underrun();
callbacks_with_silence = callbacks_with_silence.wrapping_add(1);
}
// Diagnostic sampling.
if last_num_frames != 0 && last_num_frames != num_frames {
num_frames_changes = num_frames_changes.wrapping_add(1);
}
last_num_frames = num_frames;
cb_count = cb_count.wrapping_add(1);
if cb_count.is_multiple_of(100) {
info!(
target: "chanora_audio",
cb = cb_count,
num_frames,
frames_changes = num_frames_changes,
callbacks_with_audio,
callbacks_with_silence,
peak_out_i16 = mix_stats.peak_i16,
gain,
"ios audio unit render callback diagnostic sample (direct fill_buffer)"
);
}
Ok(())
})
.map_err(|e| AudioError::Backend(format!("audio unit set render callback: {e}")))?;
// Finalise the unit — allocates internal buffers per the
// stream formats we set above. After initialize() most
// property changes are rejected (you have to uninitialize +
// re-initialize), which is why the property set must come
// first.
//
// AudioUnit::initialize() issues an RPC to the CoreAudio server.
// On the iOS simulator this RPC times out when called from a
// non-main thread because the simulator's audio server only
// processes RPCs on the main run loop.
//
// Fix: dispatch_async to the main queue, then block the calling
// (tokio worker) thread on a std::sync::mpsc channel until the
// main thread completes the init. This is safe because:
// 1. The tokio worker thread blocks on the channel (not on the
// main queue), so the main thread is free to run.
// 2. AudioUnit is Send (coreaudio-rs marks it unsafe impl Send).
// 3. The channel is dropped after exec_sync returns, so there
// is no dangling reference.
{
let (tx, rx) = std::sync::mpsc::sync_channel::<Result<(), String>>(1);
// Move unit into the Arc so it can cross thread boundaries.
let unit_arc = std::sync::Arc::new(std::sync::Mutex::new(Some(unit)));
let unit_arc2 = unit_arc.clone();
dispatch2::DispatchQueue::main().exec_async(move || {
let mut guard = unit_arc2.lock().unwrap();
let u = guard.as_mut().unwrap();
let result = u
.initialize()
.map_err(|e| format!("vpio initialize: {e}"))
.and_then(|_| u.start().map_err(|e| format!("vpio start: {e}")));
let _ = tx.send(result);
});
// Block the tokio worker thread until the main thread finishes.
// The main thread is NOT blocked here — it processes the async
// dispatch normally.
match rx.recv() {
Ok(Ok(())) => {}
Ok(Err(msg)) => return Err(AudioError::Backend(msg)),
Err(_) => {
return Err(AudioError::Backend(
"vpio init: main thread channel closed unexpectedly".to_string(),
))
}
}
unit = unit_arc.lock().unwrap().take().unwrap();
}
info!(
target: "chanora_audio",
sample_rate_hz = SAMPLE_RATE_HZ,
channels = stream_format.channels,
sample_format = ?stream_format.sample_format,
"ios VPIO audio unit started"
);
// Read back the ACTUAL stream format VPIO accepted on each
// bus (iOS sometimes substitutes its own format if the
// hardware can't satisfy our preference) and the actual
// AVAudioSession sample rate + IO buffer duration. Without
// these we can't tell whether our 48 kHz Int16 mono format
// was honoured or silently downgraded to e.g. 44.1 kHz
// Float32 (which would cause our render callback to write
// i16 values into a buffer iOS interprets as f32 = severe
// distortion). Diagnostic prompted by external review
// pointing out that 'preferredSampleRate' is a hint, not
// a guarantee \u2014 must verify post-init.
match unit.output_stream_format() {
Ok(fmt) => info!(
target: "chanora_audio",
sample_rate = fmt.sample_rate,
channels = fmt.channels,
sample_format = ?fmt.sample_format,
flags = ?fmt.flags,
"ios VPIO actual OUTPUT stream format (post-init)"
),
Err(e) => warn!(
target: "chanora_audio",
error = %e,
"ios VPIO output_stream_format read failed"
),
}
match unit.input_stream_format() {
Ok(fmt) => info!(
target: "chanora_audio",
sample_rate = fmt.sample_rate,
channels = fmt.channels,
sample_format = ?fmt.sample_format,
flags = ?fmt.flags,
"ios VPIO actual INPUT stream format (post-init)"
),
Err(e) => warn!(
target: "chanora_audio",
error = %e,
"ios VPIO input_stream_format read failed"
),
}
Ok(Self { unit })
}
/// Restart the audio unit after route change handling.
///
/// Route rebinding on iOS is most reliable when we bounce the
/// VoiceProcessingIO unit through an uninitialize/reinitialize
/// cycle, then start again.
///
/// Called from the Flutter method channel handler which runs on
/// the main isolate — that runs on the main thread — so the
/// CoreAudio RPC is already on the correct thread here.
#[cfg(target_os = "ios")]
pub fn restart(&mut self) -> Result<(), AudioError> {
self.unit
.stop()
.map_err(|e| AudioError::Backend(format!("vpio restart stop: {e}")))?;
self.unit
.uninitialize()
.map_err(|e| AudioError::Backend(format!("vpio restart uninit: {e}")))?;
self.unit
.initialize()
.map_err(|e| AudioError::Backend(format!("vpio restart init: {e}")))?;
self.unit
.start()
.map_err(|e| AudioError::Backend(format!("vpio restart start: {e}")))?;
info!(target: "chanora_audio", "ios VPIO audio unit restarted");
Ok(())
}
/// Pause the audio unit during an interruption.
#[cfg(target_os = "ios")]
pub fn pause(&mut self) -> Result<(), AudioError> {
self.unit
.stop()
.map_err(|e| AudioError::Backend(format!("vpio pause stop: {e}")))
}
/// Resume the audio unit after an interruption.
#[cfg(target_os = "ios")]
pub fn resume(&mut self) -> Result<(), AudioError> {
self.unit
.start()
.map_err(|e| AudioError::Backend(format!("vpio resume start: {e}")))
}
}
impl Drop for IosVoiceUnit {
fn drop(&mut self) {
// Stop the audio unit so the render callback no longer
// fires. The coreaudio-rs wrapper's own Drop calls
// AudioComponentInstanceDispose afterwards.
if let Err(e) = self.unit.stop() {
warn!(target: "chanora_audio", error = %e, "ios audio unit stop on drop failed");
} else {
info!(target: "chanora_audio", "ios audio unit stopped");
}
}
}