From 3f9ea4f7b8d5c77f1cd56d06ea7dd33ada4ef69d Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Wed, 10 Jun 2026 00:47:09 +0900 Subject: [PATCH 1/3] refactor(audio): remove experimental iOS RemoteIO+WebRTC APM path Delete ios_raw_unit.rs (538 lines) and all SonoraExperimental references from the core audio crate. The experimental RemoteIO path that bypassed Apple VPIO in favor of software WebRTC APM was never shipped and is no longer needed. VPIO is the sole production iOS audio backend. - Delete ios_raw_unit.rs entirely - Remove Raw variant from IosVoiceBackend enum in engine.rs - Remove SonoraExperimental from IosVoiceProcessingMode enum - Simplify validate_for_ios() (single-variant enum, no mode check) - Remove 2 SonoraExperimental validation tests - Remove ios_raw_unit module declaration from lib.rs - Remove include_str!-based debug_wav test for the deleted file --- crates/chanora_audio/src/audio_processing.rs | 56 +- crates/chanora_audio/src/debug_wav.rs | 9 - crates/chanora_audio/src/engine.rs | 41 +- crates/chanora_audio/src/ios_raw_unit.rs | 538 ------------------- crates/chanora_audio/src/lib.rs | 5 +- 5 files changed, 16 insertions(+), 633 deletions(-) delete mode 100644 crates/chanora_audio/src/ios_raw_unit.rs diff --git a/crates/chanora_audio/src/audio_processing.rs b/crates/chanora_audio/src/audio_processing.rs index 6e56b17..f166c3b 100644 --- a/crates/chanora_audio/src/audio_processing.rs +++ b/crates/chanora_audio/src/audio_processing.rs @@ -56,10 +56,8 @@ impl AudioRoute { /// iOS voice-processing mode. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum IosVoiceProcessingMode { - /// Shipping default: Apple VoiceProcessingIO owns AEC/NS/AGC. + /// Apple VoiceProcessingIO owns AEC/NS/AGC. PlatformVoiceProcessing, - /// Experimental raw capture-processing path. - SonoraExperimental, } /// Processing backend selected by policy/config. @@ -195,28 +193,20 @@ impl AudioProcessingConfig { "bluetooth_a2dp is output-only and cannot transmit duplex voice".to_string(), )); } - if self.ios_mode == IosVoiceProcessingMode::PlatformVoiceProcessing - && (self.processing_backend == AudioBackend::Sonora - || self.processing_backend == AudioBackend::WebrtcApm - || self.aec == EffectOwner::Sonora - || self.aec == EffectOwner::WebrtcApm - || self.ns == EffectOwner::Sonora - || self.ns == EffectOwner::WebrtcApm - || self.agc == EffectOwner::Sonora - || self.agc == EffectOwner::WebrtcApm) + if self.processing_backend == AudioBackend::Sonora + || self.processing_backend == AudioBackend::WebrtcApm + || self.aec == EffectOwner::Sonora + || self.aec == EffectOwner::WebrtcApm + || self.ns == EffectOwner::Sonora + || self.ns == EffectOwner::WebrtcApm + || self.agc == EffectOwner::Sonora + || self.agc == EffectOwner::WebrtcApm { return Err(AudioError::InvalidAudioProcessingConfig( "software audio processing cannot be enabled with iOS VoiceProcessingIO" .to_string(), )); } - if self.ios_mode == IosVoiceProcessingMode::SonoraExperimental - && self.processing_backend != AudioBackend::WebrtcApm - { - return Err(AudioError::InvalidAudioProcessingConfig( - "ios raw processing mode requires the WebRTC APM backend".to_string(), - )); - } Ok(()) } @@ -262,34 +252,6 @@ mod tests { assert!(config.validate_for_ios().is_err()); } - #[test] - fn raw_processing_allows_full_webrtc_apm_chain() { - let config = AudioProcessingConfig { - ios_mode: IosVoiceProcessingMode::SonoraExperimental, - processing_backend: AudioBackend::WebrtcApm, - aec: EffectOwner::WebrtcApm, - ns: EffectOwner::WebrtcApm, - agc: EffectOwner::WebrtcApm, - ..AudioProcessingConfig::default() - }; - - assert!(config.validate_for_ios().is_ok()); - } - - #[test] - fn raw_processing_rejects_non_webrtc_apm_backend() { - let config = AudioProcessingConfig { - ios_mode: IosVoiceProcessingMode::SonoraExperimental, - processing_backend: AudioBackend::PlatformVoiceProcessing, - aec: EffectOwner::WebrtcApm, - ns: EffectOwner::WebrtcApm, - agc: EffectOwner::WebrtcApm, - ..AudioProcessingConfig::default() - }; - - assert!(config.validate_for_ios().is_err()); - } - #[test] fn disable_failed_vad_backend_demotes_to_webrtc() { let mut config = AudioProcessingConfig { diff --git a/crates/chanora_audio/src/debug_wav.rs b/crates/chanora_audio/src/debug_wav.rs index 613803f..1865141 100644 --- a/crates/chanora_audio/src/debug_wav.rs +++ b/crates/chanora_audio/src/debug_wav.rs @@ -314,15 +314,6 @@ mod tests { rec.stop(); } - #[test] - fn ios_raw_debug_wav_does_not_push_from_realtime_callback() { - let src = include_str!("ios_raw_unit.rs"); - assert!( - !src.contains("push_raw_mic") && !src.contains("push_processed_mic"), - "ios raw callbacks must not call WavDebugRecorder push_*_mic until it has a preallocated handoff" - ); - } - #[test] fn wav_header_is_44_bytes() { // Write to a temp file to test the header. diff --git a/crates/chanora_audio/src/engine.rs b/crates/chanora_audio/src/engine.rs index 24de24f..c87a3fe 100644 --- a/crates/chanora_audio/src/engine.rs +++ b/crates/chanora_audio/src/engine.rs @@ -395,8 +395,6 @@ unsafe impl Sync for AudioEngine {} #[cfg(any(target_os = "ios", target_os = "macos"))] enum IosVoiceBackend { Vpio(crate::ios_voice_unit::IosVoiceUnit), - #[cfg(target_os = "ios")] - Raw(crate::ios_raw_unit::IosRawUnit), } #[cfg(any(target_os = "ios", target_os = "macos"))] @@ -406,14 +404,12 @@ impl IosVoiceBackend { { match self { Self::Vpio(unit) => unit.restart(), - Self::Raw(unit) => unit.restart(), } } #[cfg(target_os = "macos")] { - match self { - Self::Vpio(_unit) => Ok(()), - } + let _ = self; + Ok(()) } } @@ -422,14 +418,12 @@ impl IosVoiceBackend { { match self { Self::Vpio(unit) => unit.pause(), - Self::Raw(unit) => unit.pause(), } } #[cfg(target_os = "macos")] { - match self { - Self::Vpio(_unit) => Ok(()), - } + let _ = self; + Ok(()) } } @@ -438,14 +432,12 @@ impl IosVoiceBackend { { match self { Self::Vpio(unit) => unit.resume(), - Self::Raw(unit) => unit.resume(), } } #[cfg(target_os = "macos")] { - match self { - Self::Vpio(_unit) => Ok(()), - } + let _ = self; + Ok(()) } } } @@ -454,27 +446,6 @@ impl IosVoiceBackend { fn open_ios_voice_backend( params: crate::mobile_voice_backend::VoiceAudioParams, ) -> Result { - let _cfg = params.audio_processing_config.lock().unwrap().clone(); - #[cfg(target_os = "ios")] - { - if _cfg.ios_mode == crate::IosVoiceProcessingMode::SonoraExperimental { - let raw_params = params.clone(); - match crate::ios_raw_unit::IosRawUnit::start(raw_params) { - Ok(unit) => { - info!(target: "chanora_audio", "ios: RemoteIO/WebRTC APM backend selected"); - return Ok(IosVoiceBackend::Raw(unit)); - } - Err(e) => { - warn!( - target: "chanora_audio", - error = %e, - "ios: RemoteIO/WebRTC APM backend failed; falling back to VoiceProcessingIO" - ); - } - } - } - } - let unit = crate::ios_voice_unit::IosVoiceUnit::start(params)?; Ok(IosVoiceBackend::Vpio(unit)) } diff --git a/crates/chanora_audio/src/ios_raw_unit.rs b/crates/chanora_audio/src/ios_raw_unit.rs deleted file mode 100644 index 54cb367..0000000 --- a/crates/chanora_audio/src/ios_raw_unit.rs +++ /dev/null @@ -1,538 +0,0 @@ -//! Optional raw iOS RemoteIO path for the WebRTC APM experimental mode. -//! -//! Provides an alternative to `ios_voice_unit.rs` for the -//! `SonoraExperimental` processing mode. Instead of -//! `kAudioUnitSubType_VoiceProcessingIO` (which owns AEC/NS/AGC), it -//! opens `kAudioUnitSubType_RemoteIO` with voice processing explicitly -//! disabled so WebRTC APM can own the full signal path. -//! -//! ## Hard invariants enforced here -//! -//! * INV_009: Rust AEC only active when platform AEC is disabled. -//! * INV_010: VoiceProcessingIO and WebRTC APM AEC are mutually exclusive. -//! * INV_011: Software AEC backend receives both capture and render-reference. -//! * INV_012: Render reference is copied from decoded/mixed remote PCM -//! before playout. -//! -//! ## Fallback -//! -//! If RemoteIO construction fails, the caller falls back to `IosVoiceUnit` -//! (VPIO) and logs the error. -//! -//! ## Status -//! -//! Experimental / disabled by default. Only activated when the user -//! explicitly selects `SonoraExperimental` mode via the bridge API. -//! -//! ## Platform -//! -//! `kAudioUnitSubType_RemoteIO` is only available in the iOS SDK. -//! This module is gated to `target_os = "ios"`. - -#[cfg(target_os = "ios")] -pub use inner::IosRawUnit; - -#[cfg(target_os = "ios")] -mod inner { - 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 tracing::{info, warn}; - - use crate::mobile_voice_backend::VoiceAudioParams; - use crate::processor::AudioProcessor; - use crate::AudioError; - - const SAMPLE_RATE_HZ: f64 = 48_000.0; - - // ------------------------------------------------------------------ // - // Render-reference ring buffer // - // ------------------------------------------------------------------ // - - /// 4-slot ring buffer shared between the render callback (writer) and - /// the capture callback (reader for Sonora AEC3). Capacity: 4 × 10 ms - /// = 40 ms of headroom. - /// - /// If the capture callback runs before the render callback has written - /// a frame it reads zeros (silence reference), which is safe — Sonora - /// AEC3 simply skips cancellation for that frame. - type RenderReferenceBuffer = crate::render_reference::RenderReferenceBuffer<480, 4>; - type RenderReferenceFrameAccumulator = - crate::render_reference::RenderReferenceFrameAccumulator<480>; - const RAW_RENDER_SCRATCH_FRAMES: usize = 1024; - - // ------------------------------------------------------------------ // - // Capture pipeline state // - // ------------------------------------------------------------------ // - - struct RawCaptureState { - encoder: OpusEncoder, - pcm_accum: Vec, - opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME], - voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender, - transmit_active: Arc, - output_muted: Arc, - mic_gain: f32, - voice_activity_selector: Option>, - vad_detector: crate::vad::WebRtcFallbackVad, - silero_coreml_worker: Option, - current_vad_backend: crate::VadBackend, - capture_frame_seq: u64, - vad_state: crate::voice_activity::VoiceActivityStateMachine, - /// Processing config — retained for route-change reloads. - audio_processing_config: Arc>, - webrtc_apm_processor: crate::processor::WebRtcApmProcessor, - audio_processing_stats: Arc, - render_reference: Arc, - pending_10ms: [i16; crate::frame::FRAME_10MS_SAMPLES], - pending_10ms_len: usize, - fallback_warned_backend: Option, - } - - impl RawCaptureState { - fn new( - params: &VoiceAudioParams, - render_reference: Arc, - ) -> Result { - let encoder = crate::opus_voice::new_voip_encoder("ios raw")?; - let webrtc_apm_config = params - .audio_processing_config - .lock() - .map(|cfg| crate::processor::webrtc_apm::WebRtcApmConfig::from_audio_config(&cfg)) - .unwrap_or_default(); - 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: crate::opus_voice::start_out_packet_worker( - params.voice_out_tx.clone(), - params.frames_sent.clone(), - "ios-raw", - )?, - 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, - capture_frame_seq: 0, - vad_state: crate::voice_activity::VoiceActivityStateMachine::default(), - audio_processing_config: params.audio_processing_config.clone(), - webrtc_apm_processor: crate::processor::WebRtcApmProcessor::with_config( - webrtc_apm_config, - )?, - audio_processing_stats: params.audio_processing_stats.clone(), - render_reference, - pending_10ms: [0_i16; crate::frame::FRAME_10MS_SAMPLES], - pending_10ms_len: 0, - fallback_warned_backend: None, - }) - } - - fn mark_vad_fallback_active(&mut self, failed_backend: crate::VadBackend) { - if self.fallback_warned_backend != Some(failed_backend) { - 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" - ); - } - } - } - - fn ingest_i16(&mut self, samples: &[i16]) { - // Accumulate into 10 ms frames for VAD / Sonora processing. - 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.encode_complete_20ms_frames(); - self.pending_10ms_len = 0; - } - } - - if !self.transmit_active.load(Ordering::Relaxed) { - self.pcm_accum.clear(); - return; - } - - self.encode_complete_20ms_frames(); - } - - fn encode_complete_20ms_frames(&mut self) { - while self.pcm_accum.len() >= crate::frame::FRAME_20MS_SAMPLES { - 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 raw: voice_out queue full; dropping frame" - ); - }, - || {}, - ); - } - Err(e) => { - tracing::error!(target: "chanora_audio", - error = %e, "ios raw 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); - - // Debug WAV mic taps are intentionally unavailable on iOS raw - // realtime callbacks until WavDebugRecorder supports a - // preallocated handoff path; the current recorder push path - // allocates per frame. - - // Feed render reference to WebRTC APM before capture so AEC can adapt. - let render_ref = self.render_reference.read_latest(); - self.webrtc_apm_processor.process_render(&render_ref); - self.webrtc_apm_processor.process_capture(&mut frame); - - // Processed-mic debug WAV capture is disabled for the same - // realtime allocation reason as the raw-mic tap above. - - 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); - } - - let (vad_backend, vad_hangover) = self - .audio_processing_config - .try_lock() - .map(|cfg| (cfg.vad_backend, cfg.vad_hangover_ms)) - .unwrap_or(( - crate::VadBackend::WebrtcVad, - crate::voice_activity::VAD_HANGOVER_MS, - )); - if voice_activity_mode { - self.vad_state.configure( - crate::voice_activity::VAD_OPEN_AFTER_MS, - vad_hangover, - crate::voice_activity::VAD_MIN_TX_MS, - ); - } - - 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); - } - self.vad_state.reset(); - } - - let (vad_probability, active) = 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 { - match crate::vad::callback_vad_worker_policy( - voice_activity_mode, - vad_backend, - self.silero_coreml_worker.is_some(), - ) { - crate::vad::VadWorkerPolicy::UseWorker => { - let worker = self - .silero_coreml_worker - .as_ref() - .expect("policy checked worker"); - 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(vad_backend); - crate::vad::VoiceActivityDetector::process_10ms( - &mut self.vad_detector, - &frame, - ) - } - } - crate::vad::VadWorkerPolicy::UseFallback => { - used_fallback_vad = true; - self.mark_vad_fallback_active(vad_backend); - crate::vad::VoiceActivityDetector::process_10ms( - &mut self.vad_detector, - &frame, - ) - } - crate::vad::VadWorkerPolicy::NotModelBacked => crate::vad::VadOutput { - probability: 1.0, - speech: true, - }, - } - } 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) - }; - if let Some(sel) = &self.voice_activity_selector { - sel.set_voice_activity_open(voice_activity_mode && active); - } - self.audio_processing_stats.update_capture( - input_dbfs, - crate::frame::dbfs(&frame), - vad_probability, - voice_activity_mode && active, - self.transmit_active.load(Ordering::Relaxed), - ); - - if !self.transmit_active.load(Ordering::Relaxed) { - return; - } - - if crate::capture_accumulator::append_processed_i16_bounded( - &mut self.pcm_accum, - &frame, - self.mic_gain, - ) { - self.audio_processing_stats.increment_callback_xrun(); - } - } - } - - // ------------------------------------------------------------------ // - // IosRawUnit // - // ------------------------------------------------------------------ // - - /// Raw iOS RemoteIO audio unit for the Sonora experimental path. - pub struct IosRawUnit { - unit: AudioUnit, - } - - impl IosRawUnit { - /// Open a RemoteIO AudioUnit, install render + input callbacks, start. - pub(crate) fn start(params: VoiceAudioParams) -> Result { - // INV_010: reject if config requests VPIO (that's IosVoiceUnit's job). - { - let cfg = params.audio_processing_config.lock().unwrap(); - if cfg.ios_mode == crate::IosVoiceProcessingMode::PlatformVoiceProcessing { - return Err(AudioError::InvalidAudioProcessingConfig( - "IosRawUnit requires raw WebRTC APM mode".to_string(), - )); - } - } - - let mut unit = AudioUnit::new_uninitialized(IOType::RemoteIO) - .map_err(|e| AudioError::Backend(format!("remoteio new: {e}")))?; - - // Enable input on bus 1. - const ENABLE_IO: u32 = 2003; - let enable: u32 = 1; - unit.set_property(ENABLE_IO, Scope::Input, Element::Input, Some(&enable)) - .map_err(|e| AudioError::Backend(format!("remoteio enable input: {e}")))?; - - // 48 kHz Int16 mono on both buses. - let fmt = StreamFormat { - sample_rate: SAMPLE_RATE_HZ, - sample_format: SampleFormat::I16, - flags: LinearPcmFlags::IS_SIGNED_INTEGER | LinearPcmFlags::IS_PACKED, - channels: 1, - }; - unit.set_stream_format(fmt, Scope::Input, Element::Output) - .map_err(|e| AudioError::StreamConfig(format!("remoteio fmt output: {e}")))?; - unit.set_stream_format(fmt, Scope::Output, Element::Input) - .map_err(|e| AudioError::StreamConfig(format!("remoteio fmt input: {e}")))?; - - // Shared render-reference buffer (INV_011 / INV_012). - let render_ref_buf = RenderReferenceBuffer::new(); - let render_ref_for_capture = render_ref_buf.clone(); - - let mut capture_state = RawCaptureState::new(¶ms, render_ref_for_capture)?; - - unit.set_input_callback(move |args: render_callback::Args>| { - capture_state.ingest_i16(args.data.buffer); - Ok(()) - }) - .map_err(|e| AudioError::Backend(format!("remoteio input cb: {e}")))?; - - let mut scratch = [0.0_f32; RAW_RENDER_SCRATCH_FRAMES * 2]; - let mut mono = [0.0_f32; RAW_RENDER_SCRATCH_FRAMES]; - let mut render_ref_accum = RenderReferenceFrameAccumulator::new(); - let handler = params.handler.clone(); - let output_gain = params.output_gain.clone(); - let output_muted = params.output_muted.clone(); - let stats_render = params.audio_processing_stats.clone(); - - unit.set_render_callback(move |args: render_callback::Args>| { - let out = args.data.buffer; - let n = out.len(); - let process_n = n.min(RAW_RENDER_SCRATCH_FRAMES); - let stereo_n = process_n * 2; - if n > RAW_RENDER_SCRATCH_FRAMES { - stats_render.increment_callback_xrun(); - } - scratch[..stereo_n].fill(0.0); - - match handler.try_lock() { - Ok(mut h) => { - let _ = h.fill_buffer(&mut scratch[..stereo_n]); - } - Err(std::sync::TryLockError::WouldBlock) => { - stats_render.increment_callback_xrun(); - } - Err(std::sync::TryLockError::Poisoned(e)) => { - warn!(target: "chanora_audio", - "AudioHandler poisoned (raw render): {e}"); - } - } - - // INV_012: copy render reference BEFORE playout. - crate::voice_render::downmix_stereo_f32_to_mono_f32( - &scratch[..stereo_n], - &mut mono[..process_n], - ); - render_ref_accum.push_mono_samples(&mono[..process_n], |frame| { - render_ref_buf.write(frame); - }); - - let gain = f32::from_bits(output_gain.load(Ordering::Relaxed)); - let muted = output_muted.load(Ordering::Relaxed); - let mix_stats = crate::voice_render::downmix_stereo_f32_to_interleaved_i16( - &scratch[..stereo_n], - &mut out[..process_n], - 1, - gain, - muted, - ); - if process_n < n { - out[process_n..].fill(0); - } - if mix_stats.clipped_samples > 0 { - stats_render.add_clipped_samples(mix_stats.clipped_samples); - } - stats_render.update_render(crate::frame::dbfs(&scratch[..stereo_n]), n as u32); - Ok(()) - }) - .map_err(|e| AudioError::Backend(format!("remoteio render cb: {e}")))?; - - unit.initialize() - .map_err(|e| AudioError::Backend(format!("remoteio init: {e}")))?; - unit.start() - .map_err(|e| AudioError::Backend(format!("remoteio start: {e}")))?; - - info!( - target: "chanora_audio", - sample_rate_hz = SAMPLE_RATE_HZ, - "ios RemoteIO (Sonora experimental) started" - ); - Ok(Self { unit }) - } - - /// Restart the unit after a route change (stop → uninit → init → start). - pub fn restart(&mut self) -> Result<(), AudioError> { - self.unit - .stop() - .map_err(|e| AudioError::Backend(format!("remoteio restart stop: {e}")))?; - self.unit - .uninitialize() - .map_err(|e| AudioError::Backend(format!("remoteio restart uninit: {e}")))?; - self.unit - .initialize() - .map_err(|e| AudioError::Backend(format!("remoteio restart init: {e}")))?; - self.unit - .start() - .map_err(|e| AudioError::Backend(format!("remoteio restart start: {e}")))?; - info!(target: "chanora_audio", "ios RemoteIO restarted"); - Ok(()) - } - - /// Pause the unit during an AVAudioSession interruption. - pub fn pause(&mut self) -> Result<(), AudioError> { - self.unit - .stop() - .map_err(|e| AudioError::Backend(format!("remoteio pause: {e}"))) - } - - /// Resume the unit after an interruption ends. - pub fn resume(&mut self) -> Result<(), AudioError> { - self.unit - .start() - .map_err(|e| AudioError::Backend(format!("remoteio resume: {e}"))) - } - } - - impl Drop for IosRawUnit { - fn drop(&mut self) { - if let Err(e) = self.unit.stop() { - warn!(target: "chanora_audio", error = %e, - "ios RemoteIO stop on drop failed"); - } else { - info!(target: "chanora_audio", "ios RemoteIO stopped"); - } - } - } -} diff --git a/crates/chanora_audio/src/lib.rs b/crates/chanora_audio/src/lib.rs index 8fe91e1..fddf881 100644 --- a/crates/chanora_audio/src/lib.rs +++ b/crates/chanora_audio/src/lib.rs @@ -65,10 +65,7 @@ pub(crate) mod voice_render; mod sdl_output; #[cfg(any(target_os = "ios", target_os = "macos"))] -mod ios_voice_unit; - -#[cfg(target_os = "ios")] -pub mod ios_raw_unit; + mod ios_voice_unit; #[cfg(target_os = "android")] pub mod android_voice_unit; From 413f2473786d4c8e49d9a704c40d4424ff053ba5 Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Wed, 10 Jun 2026 00:47:30 +0900 Subject: [PATCH 2/3] fix(audio): inline VAD worker policy for iOS capture callback PR #37 inlined VadWorkerPolicy into the desktop capture path but missed the iOS files. Instead of restoring the deleted types, inline the same direct if-let-Some pattern into ios_voice_unit.rs (the only remaining iOS backend) and permanently remove VadWorkerPolicy and callback_vad_worker_policy from vad/mod.rs. iOS uses Option with &mut self (no Mutex), so the inline is simpler than the desktop's try_lock pattern. --- crates/chanora_audio/src/ios_voice_unit.rs | 55 ++++++++-------------- 1 file changed, 20 insertions(+), 35 deletions(-) diff --git a/crates/chanora_audio/src/ios_voice_unit.rs b/crates/chanora_audio/src/ios_voice_unit.rs index 44a4e2d..b730325 100644 --- a/crates/chanora_audio/src/ios_voice_unit.rs +++ b/crates/chanora_audio/src/ios_voice_unit.rs @@ -406,38 +406,20 @@ impl IosCaptureState { speech: true, } } else if vad_backend == crate::VadBackend::SileroOnnx { - match crate::vad::callback_vad_worker_policy( - voice_activity_mode, - vad_backend, - self.silero_coreml_worker.is_some(), - ) { - crate::vad::VadWorkerPolicy::UseWorker => { - let worker = self - .silero_coreml_worker - .as_ref() - .expect("policy checked worker"); - 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, - ) + 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, } - } - crate::vad::VadWorkerPolicy::UseFallback => { + } 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( @@ -445,10 +427,13 @@ impl IosCaptureState { &frame, ) } - crate::vad::VadWorkerPolicy::NotModelBacked => crate::vad::VadOutput { - probability: 1.0, - speech: true, - }, + } 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) From 2b285491d00c64da080e5a4d41cc6d9c73151687 Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Wed, 10 Jun 2026 00:51:52 +0900 Subject: [PATCH 3/3] refactor(bridge): remove SonoraExperimental from bridge API and regenerate FRB Remove SonoraExperimental variant from BridgeIosVoiceProcessingMode and collapse all match arms in the bridge config builder. Regenerate flutter_rust_bridge bindings and update Podfile.lock. --- apps/chanora_flutter/ios/Podfile.lock | 2 +- apps/chanora_flutter/lib/src/rust/api.dart | 5 +--- crates/chanora_bridge/src/api.rs | 28 ++++------------------ crates/chanora_bridge/src/frb_generated.rs | 3 --- 4 files changed, 7 insertions(+), 31 deletions(-) diff --git a/apps/chanora_flutter/ios/Podfile.lock b/apps/chanora_flutter/ios/Podfile.lock index 42af664..0cf879c 100644 --- a/apps/chanora_flutter/ios/Podfile.lock +++ b/apps/chanora_flutter/ios/Podfile.lock @@ -23,7 +23,7 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/haptic_kit/ios" SPEC CHECKSUMS: - chanora_bridge: 26252acdf9ca660ce9c132ad25cd5ad5af467b16 + chanora_bridge: 27a03592058709f6f38701343eb51c3a55b02da0 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_foreground_task: a159d2c2173b33699ddb3e6c2a067045d7cebb89 haptic_kit: b22c4fbb2aa7b0d66f2891f81a9e950ad2de5758 diff --git a/apps/chanora_flutter/lib/src/rust/api.dart b/apps/chanora_flutter/lib/src/rust/api.dart index 57aaef5..3072244 100644 --- a/apps/chanora_flutter/lib/src/rust/api.dart +++ b/apps/chanora_flutter/lib/src/rust/api.dart @@ -1339,11 +1339,8 @@ sealed class BridgeEvent with _$BridgeEvent { /// Bridge iOS voice-processing mode. enum BridgeIosVoiceProcessingMode { - /// Shipping VPIO path. + /// Apple VoiceProcessingIO path. platformVoiceProcessing, - - /// Experimental Sonora path. - sonoraExperimental, } @freezed diff --git a/crates/chanora_bridge/src/api.rs b/crates/chanora_bridge/src/api.rs index 4c85c4f..6cde524 100644 --- a/crates/chanora_bridge/src/api.rs +++ b/crates/chanora_bridge/src/api.rs @@ -1062,10 +1062,8 @@ pub enum BridgeAudioRoute { /// Bridge iOS voice-processing mode. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BridgeIosVoiceProcessingMode { - /// Shipping VPIO path. + /// Apple VoiceProcessingIO path. PlatformVoiceProcessing, - /// Experimental Sonora path. - SonoraExperimental, } /// Bridge processing backend. @@ -1223,7 +1221,6 @@ impl From for chanora_core::IosVoiceProcessingMode fn from(mode: BridgeIosVoiceProcessingMode) -> Self { match mode { BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => Self::PlatformVoiceProcessing, - BridgeIosVoiceProcessingMode::SonoraExperimental => Self::SonoraExperimental, } } } @@ -1234,7 +1231,6 @@ impl From for BridgeIosVoiceProcessingMode chanora_core::IosVoiceProcessingMode::PlatformVoiceProcessing => { Self::PlatformVoiceProcessing } - chanora_core::IosVoiceProcessingMode::SonoraExperimental => Self::SonoraExperimental, } } } @@ -2361,25 +2357,11 @@ pub async fn set_ios_voice_processing_mode( let config = BridgeAudioProcessingConfig { route: BridgeAudioRoute::Speaker, ios_mode: mode, - processing_backend: match mode { - BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => { - BridgeAudioBackend::PlatformVoiceProcessing - } - BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeAudioBackend::WebrtcApm, - }, + processing_backend: BridgeAudioBackend::PlatformVoiceProcessing, vad_backend: BridgeVadBackend::SileroOnnx, - aec: match mode { - BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => BridgeEffectOwner::Platform, - BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeEffectOwner::WebrtcApm, - }, - ns: match mode { - BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => BridgeEffectOwner::Platform, - BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeEffectOwner::WebrtcApm, - }, - agc: match mode { - BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => BridgeEffectOwner::Platform, - BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeEffectOwner::WebrtcApm, - }, + aec: BridgeEffectOwner::Platform, + ns: BridgeEffectOwner::Platform, + agc: BridgeEffectOwner::Platform, hpf_enabled: true, limiter_enabled: true, vad_hangover_ms: 500, diff --git a/crates/chanora_bridge/src/frb_generated.rs b/crates/chanora_bridge/src/frb_generated.rs index b349fbc..efb1d87 100644 --- a/crates/chanora_bridge/src/frb_generated.rs +++ b/crates/chanora_bridge/src/frb_generated.rs @@ -2409,7 +2409,6 @@ impl SseDecode for crate::api::BridgeIosVoiceProcessingMode { let mut inner = ::sse_decode(deserializer); return match inner { 0 => crate::api::BridgeIosVoiceProcessingMode::PlatformVoiceProcessing, - 1 => crate::api::BridgeIosVoiceProcessingMode::SonoraExperimental, _ => unreachable!( "Invalid variant for BridgeIosVoiceProcessingMode: {}", inner @@ -3445,7 +3444,6 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeIosVoiceProcessingMode fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { match self { Self::PlatformVoiceProcessing => 0.into_dart(), - Self::SonoraExperimental => 1.into_dart(), _ => unreachable!(), } } @@ -4215,7 +4213,6 @@ impl SseEncode for crate::api::BridgeIosVoiceProcessingMode { ::sse_encode( match self { crate::api::BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => 0, - crate::api::BridgeIosVoiceProcessingMode::SonoraExperimental => 1, _ => { unimplemented!(""); }