diff --git a/crates/chanora_audio/src/engine.rs b/crates/chanora_audio/src/engine.rs index 0bd6b98..ed6e7ca 100644 --- a/crates/chanora_audio/src/engine.rs +++ b/crates/chanora_audio/src/engine.rs @@ -8,12 +8,17 @@ use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::{Arc, Mutex}; +#[cfg(not(target_os = "ios"))] use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +#[cfg(not(target_os = "ios"))] use cpal::{SampleFormat, SizedSample}; use tokio::sync::mpsc; use tracing::{debug, error, info, warn}; +#[cfg(not(target_os = "ios"))] +#[cfg(not(target_os = "ios"))] use audiopus::coder::Encoder as OpusEncoder; +#[cfg(not(target_os = "ios"))] use audiopus::{ Application as OpusApp, Bitrate as OpusBitrate, Channels as OpusChannels, SampleRate as OpusSampleRate, @@ -103,13 +108,20 @@ pub struct AudioEngine { // cpal's Stream isn't Send on some backends; we keep them in an // Option wrapped by Mutex so stop() can move them out. On Linux // the output side is `crate::sdl_output::SdlOutput` instead of a - // cpal Stream (see the SDD note inside `sdl_output.rs`); the same - // unsafe Send/Sync impl below covers both. + // cpal Stream (see the SDD note inside `sdl_output.rs`); the + // same unsafe Send/Sync impl below covers both. On iOS both + // sides collapse into a single `IosVoiceUnit` (one + // VoiceProcessingIO AudioUnit hosts mic + speaker) — cpal is + // unused on iOS for the reasons documented in + // `ios_voice_unit.rs`. + #[cfg(not(target_os = "ios"))] _input_stream: Mutex>, #[cfg(target_os = "linux")] _output_stream: Mutex>, - #[cfg(not(target_os = "linux"))] + #[cfg(all(not(target_os = "linux"), not(target_os = "ios")))] _output_stream: Mutex>, + #[cfg(target_os = "ios")] + _ios_voice_unit: Mutex>, // Hand the inbound-voice forwarder task a shutdown signal. shutdown_tx: Option>, /// True if the capture stream actually opened. If false (typical @@ -170,6 +182,38 @@ impl AudioEngine { /// upstream (typically [`crate::TransmitModeSelector`]) is the /// authoritative writer of `transmit_active`. See SAD-083. pub fn start_with_gate( + cfg: AudioEngineConfig, + voice_out_tx: mpsc::Sender, + voice_in_rx: mpsc::Receiver, + transmit_gate: crate::ptt::AudioTransmitGate, + ) -> Result { + // iOS routes to a separate backend (VoiceProcessingIO via + // coreaudio-rs) because cpal's iOS RemoteIO path produces + // mono-only output bound to a stale physical transducer + // (see `ios_voice_unit.rs` for the long version). Every + // other platform stays on the cpal / SDL flow below. + #[cfg(target_os = "ios")] + { + return Self::start_with_gate_ios( + cfg, + voice_out_tx, + voice_in_rx, + transmit_gate, + ); + } + #[cfg(not(target_os = "ios"))] + { + Self::start_with_gate_cpal(cfg, voice_out_tx, voice_in_rx, transmit_gate) + } + } + + /// Non-iOS implementation: cpal capture + (cpal | SDL2) output. + /// Kept as a separate function so the iOS path can short-circuit + /// at the top of `start_with_gate` without dragging a 200-line + /// cfg-gated block. Body is the pre-iOS-port code, unchanged + /// except for the new function name + signature. + #[cfg(not(target_os = "ios"))] + fn start_with_gate_cpal( cfg: AudioEngineConfig, voice_out_tx: mpsc::Sender, mut voice_in_rx: mpsc::Receiver, @@ -477,6 +521,118 @@ impl AudioEngine { }) } + /// iOS implementation: a single VoiceProcessingIO AudioUnit + /// drives mic capture + speaker playback (see + /// `ios_voice_unit.rs` for why cpal is unsuitable on iOS). + /// This mirrors `start_with_gate_cpal` in scaffolding — + /// atomics, audio handler, inbound forwarder task — but + /// replaces the two cpal stream constructions with a single + /// `IosVoiceUnit::start` call. + #[cfg(target_os = "ios")] + fn start_with_gate_ios( + cfg: AudioEngineConfig, + voice_out_tx: mpsc::Sender, + mut voice_in_rx: mpsc::Receiver, + transmit_gate: crate::ptt::AudioTransmitGate, + ) -> Result { + info!( + target: "chanora_audio", + "starting audio engine: iOS VoiceProcessingIO backend" + ); + + // iOS AVAudioSession configuration is performed Swift-side + // in `apps/chanora_flutter/ios/Runner/AppDelegate.swift` + // BEFORE Flutter starts its audio pipeline. The category + + // mode pair set there (`.playAndRecord` + `.default`) is + // what VPIO binds against. Logging here just records that + // the engine-start path acknowledges the request; the + // actual session mutation lives in Swift because it must + // happen before Dart loads. + if cfg.mobile_voice_preset { + info!( + target: "chanora_audio", + "ios: voice-chat session mode requested — AVAudioSession configured in AppDelegate" + ); + } + + let transmit_flag_for_capture = transmit_gate.flag_arc(); + let frames_sent = Arc::new(AtomicU32::new(0)); + let frames_received = Arc::new(AtomicU32::new(0)); + let output_gain = Arc::new(AtomicU32::new(1.0_f32.to_bits())); + let output_muted = Arc::new(AtomicBool::new(false)); + + let audio_handler: Arc>> = + Arc::new(Mutex::new(AudioHandler::new())); + + // Construct the VPIO unit. Commit 1 ships a no-op callback + // pair; commits 3 + 4 land the real capture + playback + // wiring. Construction failure here is fatal (mirrors how + // the cpal output-stream construction failure is fatal in + // the non-iOS path). + let ios_voice_unit = crate::ios_voice_unit::IosVoiceUnit::start( + audio_handler.clone(), + output_gain.clone(), + output_muted.clone(), + voice_out_tx, + transmit_flag_for_capture, + frames_sent.clone(), + cfg.mic_gain, + )?; + + // Capture is always considered active on iOS — VPIO's + // input element is wired up by the AudioUnit itself, no + // separate "did the capture stream open" question to + // answer. If the user denied mic permission VPIO will + // simply hand us silence buffers. + let capture_active = true; + + // Inbound forwarder: same shape as the non-iOS path. Pumps + // Opus packets from `voice_in_rx` into AudioHandler so the + // VPIO render callback (commit 4) finds decoded frames + // waiting. + let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel(); + let handler_for_task = audio_handler.clone(); + let frames_received_for_task = frames_received.clone(); + tokio::spawn(async move { + loop { + tokio::select! { + _ = &mut shutdown_rx => { + debug!(target: "chanora_audio", "inbound forwarder shutting down"); + break; + } + item = voice_in_rx.recv() => { + match item { + Some(v) => { + let id = SessionAudioId(v.from_client); + let mut h = handler_for_task.lock().unwrap(); + if let Err(e) = h.handle_packet(id, v.packet) { + debug!(target: "chanora_audio", error = %e, "decode failed"); + } else { + frames_received_for_task.fetch_add(1, Ordering::Relaxed); + } + } + None => break, + } + } + } + } + }); + + let ptt_watchdog: Option = None; + + Ok(Self { + transmit_gate, + frames_sent, + frames_received, + output_gain, + output_muted, + _ios_voice_unit: Mutex::new(Some(ios_voice_unit)), + shutdown_tx: Some(shutdown_tx), + capture_active, + ptt_watchdog, + }) + } + /// Stop the engine. Idempotent. pub fn stop(&mut self) { if let Some(tx) = self.shutdown_tx.take() { @@ -596,6 +752,7 @@ impl Drop for AudioEngine { // ---------- Capture pipeline ---------- +#[cfg(not(target_os = "ios"))] fn try_open_capture( in_dev: &cpal::Device, voice_out_tx: mpsc::Sender, @@ -707,6 +864,7 @@ fn try_open_capture( Ok(stream) } +#[cfg(not(target_os = "ios"))] struct CaptureState { encoder: OpusEncoder, in_sample_rate: u32, @@ -731,6 +889,7 @@ struct CaptureState { frames_sent: Arc, } +#[cfg(not(target_os = "ios"))] impl CaptureState { fn new( encoder: OpusEncoder, @@ -878,25 +1037,30 @@ impl CaptureState { } /// Per-sample format conversion to f32 in the range [-1.0, 1.0]. +#[cfg(not(target_os = "ios"))] trait ToF32 { fn to_f32_sample(self) -> f32; } +#[cfg(not(target_os = "ios"))] impl ToF32 for f32 { fn to_f32_sample(self) -> f32 { self } } +#[cfg(not(target_os = "ios"))] impl ToF32 for i16 { fn to_f32_sample(self) -> f32 { f32::from(self) / f32::from(i16::MAX) } } +#[cfg(not(target_os = "ios"))] impl ToF32 for u16 { fn to_f32_sample(self) -> f32 { (f32::from(self) - f32::from(i16::MAX) - 1.0) / f32::from(i16::MAX) } } +#[cfg(not(target_os = "ios"))] fn build_input_stream( device: &cpal::Device, config: &cpal::StreamConfig, @@ -924,6 +1088,7 @@ where // ---------- Playback pipeline ---------- #[cfg(not(target_os = "linux"))] +#[cfg(not(target_os = "ios"))] fn build_output_stream( device: &cpal::Device, config: &cpal::StreamConfig, @@ -1113,6 +1278,7 @@ where /// Resampler state carried across output cpal callbacks. See /// `build_output_stream` for the rationale. #[cfg(not(target_os = "linux"))] +#[cfg(not(target_os = "ios"))] struct PlaybackResampleState { pos: f64, last_l: f32, @@ -1120,22 +1286,26 @@ struct PlaybackResampleState { } #[cfg(not(target_os = "linux"))] +#[cfg(not(target_os = "ios"))] trait FromF32 { fn from_f32_sample(v: f32) -> Self; } #[cfg(not(target_os = "linux"))] +#[cfg(not(target_os = "ios"))] impl FromF32 for f32 { fn from_f32_sample(v: f32) -> Self { v } } #[cfg(not(target_os = "linux"))] +#[cfg(not(target_os = "ios"))] impl FromF32 for i16 { fn from_f32_sample(v: f32) -> Self { (v.clamp(-1.0, 1.0) * f32::from(i16::MAX)) as i16 } } #[cfg(not(target_os = "linux"))] +#[cfg(not(target_os = "ios"))] impl FromF32 for u16 { fn from_f32_sample(v: f32) -> Self { let s = (v.clamp(-1.0, 1.0) * f32::from(i16::MAX)) as i32;