feat(audio,ios): wire IosVoiceUnit into AudioEngine, cfg-gate cpal away on iOS (commit 2/5)
Split AudioEngine::start_with_gate into two backends:
* start_with_gate_cpal — non-iOS path, the existing cpal + (SDL on
Linux) flow, renamed verbatim, no
behavioural change.
* start_with_gate_ios — iOS path, constructs a single
IosVoiceUnit (VoiceProcessingIO via
coreaudio-rs) for combined mic + speaker.
Spawns the same inbound forwarder task
that pumps Opus packets into AudioHandler.
The public entry point start_with_gate dispatches at the top via
cfg(target_os = "ios") so callers stay backend-agnostic.
Struct field changes:
* _input_stream : cfg-gated to not(ios)
* _output_stream : cfg-gated to not(ios), keeps the
Linux=SdlOutput / else=cpal::Stream split
* _ios_voice_unit: new field, cfg-gated to ios, owns the VPIO
AudioUnit for the engine's lifetime.
Module-level cfg-gating:
* All cpal-only helpers (try_open_capture, build_input_stream,
build_output_stream, CaptureState + impl, ToF32 / FromF32 traits
and impls, PlaybackResampleState) are now wrapped with
#[cfg(not(target_os = "ios"))]. Same for the audiopus
encoder + cpal trait imports — iOS doesn't pull libopus into the
engine yet (commit 3 will, once the VPIO input callback wires
into CaptureState).
Behaviour on iOS for THIS commit:
* AudioEngine starts cleanly, IosVoiceUnit::start succeeds (VPIO
unit allocates + initialises + starts).
* Mic capture is dropped (the input callback is a no-op stub).
* Output emits silence (the render callback fills the buffer with
zeros).
* Inbound forwarder still runs and pushes Opus packets into
AudioHandler — they accumulate in the jitter buffer but no
fill_buffer drain happens (commit 4 fixes that), so the buffer
will grow up to MAX_BUFFER_TIME (~0.5 s) and then tsclientlib
starts dropping the oldest frames. This is fine for now — the
point of this commit is verifying the AudioUnit constructs +
starts cleanly on the device. Audible silence is the expected
state until commits 3/4 land.
Build verify (Linux host): cargo check -p chanora_audio clean in
0.53s. iOS-side compile happens on the Mac via the Xcode build
the user will trigger next.
This commit is contained in:
@@ -8,12 +8,17 @@
|
|||||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "ios"))]
|
||||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||||
|
#[cfg(not(target_os = "ios"))]
|
||||||
use cpal::{SampleFormat, SizedSample};
|
use cpal::{SampleFormat, SizedSample};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "ios"))]
|
||||||
|
#[cfg(not(target_os = "ios"))]
|
||||||
use audiopus::coder::Encoder as OpusEncoder;
|
use audiopus::coder::Encoder as OpusEncoder;
|
||||||
|
#[cfg(not(target_os = "ios"))]
|
||||||
use audiopus::{
|
use audiopus::{
|
||||||
Application as OpusApp, Bitrate as OpusBitrate, Channels as OpusChannels,
|
Application as OpusApp, Bitrate as OpusBitrate, Channels as OpusChannels,
|
||||||
SampleRate as OpusSampleRate,
|
SampleRate as OpusSampleRate,
|
||||||
@@ -103,13 +108,20 @@ pub struct AudioEngine {
|
|||||||
// cpal's Stream isn't Send on some backends; we keep them in an
|
// 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
|
// Option wrapped by Mutex so stop() can move them out. On Linux
|
||||||
// the output side is `crate::sdl_output::SdlOutput` instead of a
|
// the output side is `crate::sdl_output::SdlOutput` instead of a
|
||||||
// cpal Stream (see the SDD note inside `sdl_output.rs`); the same
|
// cpal Stream (see the SDD note inside `sdl_output.rs`); the
|
||||||
// unsafe Send/Sync impl below covers both.
|
// 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<Option<cpal::Stream>>,
|
_input_stream: Mutex<Option<cpal::Stream>>,
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
_output_stream: Mutex<Option<crate::sdl_output::SdlOutput>>,
|
_output_stream: Mutex<Option<crate::sdl_output::SdlOutput>>,
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(all(not(target_os = "linux"), not(target_os = "ios")))]
|
||||||
_output_stream: Mutex<Option<cpal::Stream>>,
|
_output_stream: Mutex<Option<cpal::Stream>>,
|
||||||
|
#[cfg(target_os = "ios")]
|
||||||
|
_ios_voice_unit: Mutex<Option<crate::ios_voice_unit::IosVoiceUnit>>,
|
||||||
// Hand the inbound-voice forwarder task a shutdown signal.
|
// Hand the inbound-voice forwarder task a shutdown signal.
|
||||||
shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
|
shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
|
||||||
/// True if the capture stream actually opened. If false (typical
|
/// True if the capture stream actually opened. If false (typical
|
||||||
@@ -170,6 +182,38 @@ impl AudioEngine {
|
|||||||
/// upstream (typically [`crate::TransmitModeSelector`]) is the
|
/// upstream (typically [`crate::TransmitModeSelector`]) is the
|
||||||
/// authoritative writer of `transmit_active`. See SAD-083.
|
/// authoritative writer of `transmit_active`. See SAD-083.
|
||||||
pub fn start_with_gate(
|
pub fn start_with_gate(
|
||||||
|
cfg: AudioEngineConfig,
|
||||||
|
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||||
|
voice_in_rx: mpsc::Receiver<InboundVoice>,
|
||||||
|
transmit_gate: crate::ptt::AudioTransmitGate,
|
||||||
|
) -> Result<Self, AudioError> {
|
||||||
|
// 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,
|
cfg: AudioEngineConfig,
|
||||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||||
mut voice_in_rx: mpsc::Receiver<InboundVoice>,
|
mut voice_in_rx: mpsc::Receiver<InboundVoice>,
|
||||||
@@ -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<OutPacket>,
|
||||||
|
mut voice_in_rx: mpsc::Receiver<InboundVoice>,
|
||||||
|
transmit_gate: crate::ptt::AudioTransmitGate,
|
||||||
|
) -> Result<Self, AudioError> {
|
||||||
|
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<Mutex<AudioHandler<SessionAudioId>>> =
|
||||||
|
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<crate::ptt::MissedKeyUpWatchdog> = 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.
|
/// Stop the engine. Idempotent.
|
||||||
pub fn stop(&mut self) {
|
pub fn stop(&mut self) {
|
||||||
if let Some(tx) = self.shutdown_tx.take() {
|
if let Some(tx) = self.shutdown_tx.take() {
|
||||||
@@ -596,6 +752,7 @@ impl Drop for AudioEngine {
|
|||||||
|
|
||||||
// ---------- Capture pipeline ----------
|
// ---------- Capture pipeline ----------
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "ios"))]
|
||||||
fn try_open_capture(
|
fn try_open_capture(
|
||||||
in_dev: &cpal::Device,
|
in_dev: &cpal::Device,
|
||||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||||
@@ -707,6 +864,7 @@ fn try_open_capture(
|
|||||||
Ok(stream)
|
Ok(stream)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "ios"))]
|
||||||
struct CaptureState {
|
struct CaptureState {
|
||||||
encoder: OpusEncoder,
|
encoder: OpusEncoder,
|
||||||
in_sample_rate: u32,
|
in_sample_rate: u32,
|
||||||
@@ -731,6 +889,7 @@ struct CaptureState {
|
|||||||
frames_sent: Arc<AtomicU32>,
|
frames_sent: Arc<AtomicU32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "ios"))]
|
||||||
impl CaptureState {
|
impl CaptureState {
|
||||||
fn new(
|
fn new(
|
||||||
encoder: OpusEncoder,
|
encoder: OpusEncoder,
|
||||||
@@ -878,25 +1037,30 @@ impl CaptureState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Per-sample format conversion to f32 in the range [-1.0, 1.0].
|
/// Per-sample format conversion to f32 in the range [-1.0, 1.0].
|
||||||
|
#[cfg(not(target_os = "ios"))]
|
||||||
trait ToF32 {
|
trait ToF32 {
|
||||||
fn to_f32_sample(self) -> f32;
|
fn to_f32_sample(self) -> f32;
|
||||||
}
|
}
|
||||||
|
#[cfg(not(target_os = "ios"))]
|
||||||
impl ToF32 for f32 {
|
impl ToF32 for f32 {
|
||||||
fn to_f32_sample(self) -> f32 {
|
fn to_f32_sample(self) -> f32 {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#[cfg(not(target_os = "ios"))]
|
||||||
impl ToF32 for i16 {
|
impl ToF32 for i16 {
|
||||||
fn to_f32_sample(self) -> f32 {
|
fn to_f32_sample(self) -> f32 {
|
||||||
f32::from(self) / f32::from(i16::MAX)
|
f32::from(self) / f32::from(i16::MAX)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#[cfg(not(target_os = "ios"))]
|
||||||
impl ToF32 for u16 {
|
impl ToF32 for u16 {
|
||||||
fn to_f32_sample(self) -> f32 {
|
fn to_f32_sample(self) -> f32 {
|
||||||
(f32::from(self) - f32::from(i16::MAX) - 1.0) / f32::from(i16::MAX)
|
(f32::from(self) - f32::from(i16::MAX) - 1.0) / f32::from(i16::MAX)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "ios"))]
|
||||||
fn build_input_stream<T>(
|
fn build_input_stream<T>(
|
||||||
device: &cpal::Device,
|
device: &cpal::Device,
|
||||||
config: &cpal::StreamConfig,
|
config: &cpal::StreamConfig,
|
||||||
@@ -924,6 +1088,7 @@ where
|
|||||||
// ---------- Playback pipeline ----------
|
// ---------- Playback pipeline ----------
|
||||||
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
#[cfg(not(target_os = "ios"))]
|
||||||
fn build_output_stream<T>(
|
fn build_output_stream<T>(
|
||||||
device: &cpal::Device,
|
device: &cpal::Device,
|
||||||
config: &cpal::StreamConfig,
|
config: &cpal::StreamConfig,
|
||||||
@@ -1113,6 +1278,7 @@ where
|
|||||||
/// Resampler state carried across output cpal callbacks. See
|
/// Resampler state carried across output cpal callbacks. See
|
||||||
/// `build_output_stream` for the rationale.
|
/// `build_output_stream` for the rationale.
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
#[cfg(not(target_os = "ios"))]
|
||||||
struct PlaybackResampleState {
|
struct PlaybackResampleState {
|
||||||
pos: f64,
|
pos: f64,
|
||||||
last_l: f32,
|
last_l: f32,
|
||||||
@@ -1120,22 +1286,26 @@ struct PlaybackResampleState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
#[cfg(not(target_os = "ios"))]
|
||||||
trait FromF32 {
|
trait FromF32 {
|
||||||
fn from_f32_sample(v: f32) -> Self;
|
fn from_f32_sample(v: f32) -> Self;
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
#[cfg(not(target_os = "ios"))]
|
||||||
impl FromF32 for f32 {
|
impl FromF32 for f32 {
|
||||||
fn from_f32_sample(v: f32) -> Self {
|
fn from_f32_sample(v: f32) -> Self {
|
||||||
v
|
v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
#[cfg(not(target_os = "ios"))]
|
||||||
impl FromF32 for i16 {
|
impl FromF32 for i16 {
|
||||||
fn from_f32_sample(v: f32) -> Self {
|
fn from_f32_sample(v: f32) -> Self {
|
||||||
(v.clamp(-1.0, 1.0) * f32::from(i16::MAX)) as i16
|
(v.clamp(-1.0, 1.0) * f32::from(i16::MAX)) as i16
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
#[cfg(not(target_os = "ios"))]
|
||||||
impl FromF32 for u16 {
|
impl FromF32 for u16 {
|
||||||
fn from_f32_sample(v: f32) -> Self {
|
fn from_f32_sample(v: f32) -> Self {
|
||||||
let s = (v.clamp(-1.0, 1.0) * f32::from(i16::MAX)) as i32;
|
let s = (v.clamp(-1.0, 1.0) * f32::from(i16::MAX)) as i32;
|
||||||
|
|||||||
Reference in New Issue
Block a user