Files
chanora/crates/chanora_audio/src/ios_voice_unit.rs
T
Edison Jwa b20e6b663a fix(audio): replace Mutex unwrap with poisoned-mutex recovery (TODO-006)
Replace 42 .lock().unwrap() calls with .unwrap_or_else(|e| e.into_inner())
across 7 files. Poisoned mutex recovery prevents panics in realtime audio
callbacks. Add SAFETY comment to WebRtcFallbackVad Send impl (TODO-007).
2026-06-11 09:46:51 +09:00

1215 lines
56 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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`.
//! `IosVoiceUnit` stores the unit as `Option<AudioUnit>` so initial
//! setup and lifecycle operations can dispatch CoreAudio
//! initialize/start/stop calls to `DispatchQueue::main()` while the
//! wrapper keeps ownership and preserves the render/input callback
//! state in the surrounding `Arc`s. `restart`, `pause`, and
//! `resume` may temporarily move the unit through that helper, and
//! `Drop` stops it if still present before those callback `Arc`s are
//! 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, 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 crossbeam::queue::ArrayQueue;
use tracing::{debug, error, info, warn};
use crate::mobile_voice_backend::VoiceAudioParams;
use crate::AudioError;
/// 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;
/// Enough room for the 160 ms VAD pre-roll plus a few jitter frames, without
/// growing inside the input callback.
const CAPTURE_ACCUM_CAPACITY_SAMPLES: usize = crate::frame::FRAME_10MS_SAMPLES * 20;
/// Fixed iOS render scratch capacity. Larger callback requests are truncated
/// to this capacity and the remaining output is silence.
#[cfg_attr(not(target_os = "ios"), allow(dead_code))]
const IOS_RENDER_SCRATCH_FRAMES: usize = 4096;
/// 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: crate::opus_voice::EncodedVoiceFrameSender,
transmit_active: Arc<AtomicBool>,
output_muted: Arc<AtomicBool>,
mic_gain: f32,
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
vad_detector: crate::vad::WebRtcFallbackVad,
silero_coreml_worker: Option<crate::vad::apple_coreml::AppleCoreMlVadWorker>,
/// Last VAD backend we configured — used to detect backend changes.
current_vad_backend: crate::VadBackend,
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,
}
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) -> Result<Self, AudioError> {
let encoder = crate::opus_voice::new_voip_encoder("ios VPIO")?;
Ok(Self {
encoder,
pcm_accum: Vec::with_capacity(CAPTURE_ACCUM_CAPACITY_SAMPLES),
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
voice_out_tx: crate::opus_voice::start_out_packet_worker(
params.voice_out_tx.clone(),
params.frames_sent.clone(),
"ios-vpio",
)?,
transmit_active: params.transmit_active.clone(),
output_muted: params.output_muted.clone(),
mic_gain: params.mic_gain,
voice_activity_selector: params.voice_activity_selector.clone(),
vad_detector: crate::vad::WebRtcFallbackVad::default(),
silero_coreml_worker: None,
current_vad_backend: crate::VadBackend::WebrtcVad,
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,
})
}
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.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);
// 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) = 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,
)
})
.unwrap_or((
false,
false,
true,
crate::VadBackend::WebrtcVad,
crate::voice_activity::VAD_HANGOVER_MS,
false,
));
// VPIO realtime callbacks cannot use WavDebugRecorder today: its push
// path allocates per frame. Debug WAV capture is intentionally disabled
// here until the recorder can hand off preallocated frames.
let _ = debug_wav_dump_enabled;
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_coreml_worker = None;
self.current_vad_backend = crate::VadBackend::Disabled;
self.fallback_warned_backend = None;
self.audio_processing_stats.set_vad_fallback_active(false);
}
if voice_activity_mode && vad_backend != self.current_vad_backend {
self.current_vad_backend = vad_backend;
self.fallback_warned_backend = None;
if vad_backend == crate::VadBackend::SileroOnnx {
self.silero_coreml_worker = None;
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
self.audio_processing_stats.set_vad_fallback_active(true);
} else {
self.silero_coreml_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_coreml_worker.as_ref() {
let enqueued = worker.try_send(capture_seq, &frame);
if !worker.is_stale(capture_seq) {
let p = worker.latest_probability();
crate::vad::VadOutput {
probability: p,
speech: p >= 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,
);
// 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;
if crate::capture_accumulator::append_i16_bounded(
&mut self.pcm_accum,
&self.pre_roll_buf[idx],
) {
self.audio_processing_stats.increment_callback_xrun();
break;
}
}
} 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;
}
if crate::capture_accumulator::append_i16_bounded(&mut self.pcm_accum, &pcm_frame) {
self.audio_processing_stats.increment_callback_xrun();
}
}
}
/// 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: Option<AudioUnit>,
// macOS-only: producer task (spawned in start_macos) polls
// this on every 20 ms tick and exits when set. Without it
// the tokio task captures `Arc<Mutex<AudioHandler>>` +
// `Arc<ArrayQueue<f32>>` and runs forever, leaking on every
// engine stop/restart cycle.
#[cfg(target_os = "macos")]
producer_shutdown: Arc<AtomicBool>,
}
impl IosVoiceUnit {
#[cfg(target_os = "ios")]
fn exec_main_queue_lifecycle(
&mut self,
op: impl FnOnce(&mut AudioUnit) -> Result<(), String> + Send + 'static,
) -> Result<(), AudioError> {
let unit = self
.unit
.take()
.ok_or_else(|| AudioError::Backend("vpio lifecycle: audio unit missing".to_string()))?;
let (tx, rx) = std::sync::mpsc::sync_channel::<Result<(), String>>(1);
let unit_arc = Arc::new(Mutex::new(Some(unit)));
let unit_arc2 = Arc::clone(&unit_arc);
dispatch2::DispatchQueue::main().exec_async(move || {
let mut guard = unit_arc2.lock().unwrap_or_else(|e| e.into_inner());
let unit = guard.as_mut().unwrap();
let _ = tx.send(op(unit));
});
let result = match rx.recv() {
Ok(result) => result,
Err(_) => Err("vpio lifecycle: main thread channel closed unexpectedly".to_string()),
};
self.unit = unit_arc.lock().unwrap_or_else(|e| e.into_inner()).take();
result.map_err(AudioError::Backend)
}
/// 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}")))?;
// VPIO defaults to maximum "duck others" — when our voice plays,
// every other app's audio (Music, Safari, Discord, ...) is heavily
// attenuated. That is correct for a phone call but wrong for a
// chat client running alongside music or game audio. There is no
// public "off" switch; the lowest publicly-exposed level is Min
// and disabling Advanced Ducking turns off the voice-activity-
// driven dynamic ducking. This matches what Hume, Moonshine, and
// similar open-source VoIP/voice apps configure.
// Apple ref: kAUVoiceIOProperty_OtherAudioDuckingConfiguration
// Property ID 2108, Global scope, Output element (= 0).
// Struct layout matches AUVoiceIOOtherAudioDuckingConfiguration
// from <AudioToolbox/AUVoiceIOOtherAudioDuckingConfiguration.h>:
// Boolean (u8) mEnableAdvancedDucking + AUVoiceIOOtherAudio-
// DuckingLevel (u32) mDuckingLevel, #[repr(C)] yields 8 bytes
// with the 3-byte natural alignment pad before the u32.
#[repr(C)]
struct AuVoiceIoOtherAudioDuckingConfiguration {
m_enable_advanced_ducking: u8,
m_ducking_level: u32,
}
const K_AU_VOICE_IO_PROPERTY_OTHER_AUDIO_DUCKING_CONFIGURATION: u32 = 2108;
const K_AU_VOICE_IO_OTHER_AUDIO_DUCKING_LEVEL_MIN: u32 = 10;
let ducking_config = AuVoiceIoOtherAudioDuckingConfiguration {
m_enable_advanced_ducking: 0,
m_ducking_level: K_AU_VOICE_IO_OTHER_AUDIO_DUCKING_LEVEL_MIN,
};
// Apple introduced this property in iOS 17 / macOS 14. On older
// OS versions VPIO returns kAudioUnitErr_InvalidProperty (-10879)
// — log it but never fail VPIO startup over a ducking knob.
if let Err(e) = unit.set_property(
K_AU_VOICE_IO_PROPERTY_OTHER_AUDIO_DUCKING_CONFIGURATION,
Scope::Global,
Element::Output,
Some(&ducking_config),
) {
tracing::debug!("vpio set OtherAudioDuckingConfiguration failed (older OS?): {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 mut capture_state = IosCaptureState::new(&params)?;
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 mono i16 with master gain, then copy that
// mono sample across every output channel the callback
// exposes. We still request mono Int16 from VPIO, but
// the callback must respect the actual channel count it
// receives. The handler itself produces stereo f32, so
// we average L+R 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 mono i16 replicated across the
// callback's actual output channels. Same as Linux/SDL,
// just stereo-f32 -> interleaved-i16 converted at the
// boundary.
// macOS cadence-fix (commit-f):
//
// The render callback is the wrong place to call AudioHandler::fill_buffer.
// macOS VPIO invokes us with `num_frames=512` (= 10.67 ms @ 48 kHz) which
// is NOT a multiple of the 20 ms Opus frame size that AudioHandler expects
// inside fill_buffer. The mismatch (a) leaves fill_buffer unable to
// satisfy the request on most calls and (b) advances MAX_PACKET_LOSSES,
// which removes the talker and starts PLC silence/decay.
//
// Converged fix across Mumble (Speex jitter buffer + decoded PCM FIFO),
// WebRTC NetEQ (adaptive 80ms prebuffer + sync buffer), Songbird (Fill→
// Drain playout buffer), cpal/rodio (SPSC ring + zero-fill on underrun),
// tsclientlib's own SDL example, and the upstream tsclientlib::audio
// contract: separate ingress quantum (20 ms decoded PCM = 1920 stereo
// f32) from egress quantum (whatever VPIO asks for), connected by a
// lock-free ring of decoded PCM.
//
// Layout:
// * macOS: spawn a tokio producer task paced at 20 ms; each tick
// try-locks AudioHandler, calls fill_buffer(1920), pushes 1920
// f32 into the ring. The render callback only pops — no lock,
// no Opus decode, no allocator on the audio thread. 60 ms
// prebuffer (3 × 20 ms) is held before the callback starts
// draining, matching Mumble's playout margin and WebRTC's
// kStartDelayMs order of magnitude. Crossbeam ArrayQueue
// is used because we already depend on crossbeam-queue.
// * iOS: keep the existing direct fill_buffer path — VPIO on iOS
// requests 480-frame slices that ARE 20 ms aligned so the
// cadence mismatch does not arise there.
#[cfg(target_os = "macos")]
let producer_shutdown = Arc::new(AtomicBool::new(false));
#[cfg(target_os = "macos")]
{
// 100 ms capacity = 9600 stereo f32. Sized so that the 60 ms
// prebuffer plus a few jitter spikes fit without forcing the
// producer to drop frames. crossbeam ArrayQueue is fixed-cap
// and lock-free SPSC-ish (MPMC but wait-free per end); for
// single producer + single consumer it's effectively SPSC.
const RING_CAPACITY: usize = 12000;
const PULL_SAMPLES: usize = 1920; // one 20 ms Opus frame, stereo
const PREBUFFER_SAMPLES: usize = 9600; // 100 ms @ 48 kHz stereo
let pcm_ring: Arc<ArrayQueue<f32>> = Arc::new(ArrayQueue::new(RING_CAPACITY));
let pcm_ring_producer = pcm_ring.clone();
let pcm_ring_consumer = pcm_ring.clone();
let handler_for_producer = params.handler.clone();
let output_gain_for_render = params.output_gain.clone();
let output_muted_for_render = params.output_muted.clone();
let producer_shutdown_for_task = producer_shutdown.clone();
tokio::spawn(async move {
let mut pull_scratch: Vec<f32> = vec![0.0; PULL_SAMPLES];
let mut interval = tokio::time::interval(std::time::Duration::from_millis(20));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
interval.tick().await;
if producer_shutdown_for_task.load(Ordering::Relaxed) {
break;
}
match handler_for_producer.try_lock() {
Ok(mut h) => {
pull_scratch.fill(0.0);
let _ = h.fill_buffer(&mut pull_scratch[..]);
for &s in &pull_scratch {
pcm_ring_producer.force_push(s);
}
}
Err(std::sync::TryLockError::WouldBlock) => {}
Err(std::sync::TryLockError::Poisoned(e)) => {
warn!(target: "chanora_audio",
"producer: AudioHandler mutex poisoned: {e}");
break;
}
}
}
});
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
let render_callback::Args {
data, num_frames, ..
} = args;
let out: &mut [i16] = data.buffer;
let out_channels = data.channels;
let needed = num_frames * out_channels;
if pcm_ring_consumer.len() < PREBUFFER_SAMPLES {
for sample in &mut out[..needed] {
*sample = 0;
}
return Ok(());
}
// Pop L,R as a pair per frame. The pairing is
// load-bearing — `written += 2` half-buffer bug.
let mut written_frames: usize = 0;
while written_frames < num_frames {
let l = match pcm_ring_consumer.pop() {
Some(v) => v,
None => break,
};
let r = pcm_ring_consumer.pop().unwrap_or(0.0);
let mut frame = [l, r];
crate::voice_render::limit_peak_inplace(&mut frame, 0.99);
let (l_lim, r_lim) = (frame[0], frame[1]);
let base = written_frames * out_channels;
if out_channels == 1 {
let mono = (l_lim + r_lim) * 0.5;
out[base] = (mono.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
} else {
out[base] = (l_lim.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
out[base + 1] = (r_lim.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
}
written_frames += 1;
}
if written_frames < num_frames {
let remaining = num_frames - written_frames;
for f in 0..remaining {
let base = (written_frames + f) * out_channels;
for c in 0..out_channels {
out[base + c] = 0;
}
}
}
let gain = f32::from_bits(output_gain_for_render.load(Ordering::Relaxed));
let muted = output_muted_for_render.load(Ordering::Relaxed);
if muted {
for sample in &mut out[..needed] {
*sample = 0;
}
} else if gain != 1.0 {
for sample in &mut out[..needed] {
*sample = (((*sample as f32) * gain)
.clamp(i16::MIN as f32, i16::MAX as f32))
as i16;
}
}
Ok(())
})
.map_err(|e| AudioError::Backend(format!("audio unit set render callback: {e}")))?;
}
// iOS path: direct fill_buffer in callback. iOS VPIO
// requests 480-frame slices that align with tsclientlib's
// 20 ms Opus frame, so the cadence mismatch that macOS
// hits does not arise here. macOS has its own cfg-gated
// producer-task path above.
#[cfg(target_os = "ios")]
{
let mut scratch_stereo: Vec<f32> = vec![0.0; IOS_RENDER_SCRATCH_FRAMES * 2];
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();
// Level meter decimation: the render callback fires ~93
// times/sec, but the bridge consumer reads at ~30 Hz.
let mut render_level_decimation: u32 = 0;
// Debug WAV render-reference capture is intentionally unavailable
// on iOS VPIO callbacks until WavDebugRecorder supports a
// preallocated handoff; its current push path allocates per frame.
// Diagnostic counters sampled every 100 callbacks.
let mut cb_count: u64 = 0;
let mut last_num_frames: usize = 0;
let mut num_frames_changes: u64 = 0;
let mut callbacks_with_audio: u64 = 0;
let mut callbacks_with_silence: u64 = 0;
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
let render_callback::Args {
data, num_frames, ..
} = args;
let out: &mut [i16] = data.buffer;
let out_channels = data.channels;
let process_frames = num_frames.min(IOS_RENDER_SCRATCH_FRAMES);
if process_frames < num_frames {
audio_processing_stats_for_render.increment_callback_xrun();
}
// AudioHandler produces 48 kHz stereo f32 (= frames * 2 floats).
let needed = process_frames * 2;
// 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);
match handler_for_render.try_lock() {
Ok(mut h) => {
let _ = 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}");
}
}
// Peak limiter — multi-client mixes can sum past 0 dBFS;
// without this the downmix helper would hard-clip to i16::MAX.
crate::voice_render::limit_peak_inplace(&mut scratch_stereo[..needed], 0.99);
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_interleaved_i16(
&scratch_stereo[..needed],
out,
out_channels,
gain,
muted,
);
if mix_stats.clipped_samples > 0 {
audio_processing_stats_for_render
.add_clipped_samples(mix_stats.clipped_samples);
}
render_level_decimation = render_level_decimation.wrapping_add(1);
if render_level_decimation % 3 == 0 {
audio_processing_stats_for_render.update_render(
crate::frame::dbfs(&scratch_stereo[..needed]),
num_frames as u32,
);
}
// Track audio-vs-silence for the diagnostic.
if mix_stats.peak_i16 > 0 {
callbacks_with_audio = callbacks_with_audio.wrapping_add(1);
} else {
callbacks_with_silence = callbacks_with_silence.wrapping_add(1);
// Muted output writes intentional silence (peak_i16 == 0 by
// design), not a starved render path. Gate on !muted to avoid
// counting deliberate silence as an output underrun.
if !muted {
audio_processing_stats_for_render.increment_output_underrun();
}
}
// 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) {
debug!(
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}")))?;
} // end #[cfg(target_os = "ios")] block
// 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_or_else(|e| e.into_inner());
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_or_else(|e| e.into_inner()).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: Some(unit),
#[cfg(target_os = "macos")]
producer_shutdown,
})
}
/// 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.
///
#[cfg(target_os = "ios")]
pub fn restart(&mut self) -> Result<(), AudioError> {
self.exec_main_queue_lifecycle(|unit| {
unit.stop().map_err(|e| format!("vpio restart stop: {e}"))?;
unit.uninitialize()
.map_err(|e| format!("vpio restart uninit: {e}"))?;
unit.initialize()
.map_err(|e| format!("vpio restart init: {e}"))?;
unit.start()
.map_err(|e| format!("vpio restart start: {e}"))?;
Ok(())
})?;
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.exec_main_queue_lifecycle(|unit| {
unit.stop().map_err(|e| format!("vpio pause stop: {e}"))
})
}
/// Resume the audio unit after an interruption.
#[cfg(target_os = "ios")]
pub fn resume(&mut self) -> Result<(), AudioError> {
self.exec_main_queue_lifecycle(|unit| {
unit.start().map_err(|e| format!("vpio resume start: {e}"))
})
}
}
impl Drop for IosVoiceUnit {
fn drop(&mut self) {
// Signal the macOS producer task to exit on its next tick
// (up to 20 ms) so it releases its handler / ring clones.
#[cfg(target_os = "macos")]
self.producer_shutdown.store(true, Ordering::Relaxed);
// Stop the audio unit so the render callback no longer
// fires. The coreaudio-rs wrapper's own Drop calls
// AudioComponentInstanceDispose afterwards.
if let Some(unit) = self.unit.as_mut() {
if let Err(e) = unit.stop() {
warn!(target: "chanora_audio", error = %e, "ios audio unit stop on drop failed");
} else {
info!(target: "chanora_audio", "ios audio unit stopped");
}
}
}
}