feat(audio): prefer native voice backends

This commit is contained in:
Edison Jwa
2026-05-20 14:52:33 +09:00
parent 7d6d56e330
commit c87b47f064
6 changed files with 127 additions and 119 deletions
+16 -22
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "chanora_audio" name = "chanora_audio"
description = "Chanora audio subsystem — cpal-based capture/playback, audiopus encode, tsclientlib AudioHandler for decode + jitter buffer + mix. DEC-011, DEC-011.1." description = "Chanora audio subsystem — platform-native capture/playback, audiopus encode, tsclientlib AudioHandler for decode + jitter buffer + mix. DEC-011, DEC-011.1."
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
@@ -14,39 +14,32 @@ chanora_protocol = { path = "../chanora_protocol" }
thiserror.workspace = true thiserror.workspace = true
tracing.workspace = true tracing.workspace = true
# Cross-platform audio I/O (DEC-011.1).
cpal = "0.17.3"
# Opus encoder. tsclientlib already pulls this; we depend explicitly so # Opus encoder. tsclientlib already pulls this; we depend explicitly so
# this crate can compile against it without going through tsclientlib. # this crate can compile against it without going through tsclientlib.
audiopus = "0.3.0-rc.0" audiopus = "0.3.0-rc.0"
# AudioHandler lives in the tsclientlib crate behind the `audio` # AudioHandler lives in the tsclientlib crate behind the `audio`
# feature. We import the crate just for the AudioHandler type; the # feature. We import the crate just for the AudioHandler type; the
# Connection type stays inside chanora_protocol. # Connection type stays inside chanora_protocol.
tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491", default-features = false, features = ["audio"] } tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491", default-features = false, features = ["audio"] }
tokio = { version = "1", features = ["sync", "rt", "macros", "time"] } tokio = { version = "1", features = ["sync", "rt", "macros", "time"] }
[target.'cfg(all(not(target_os = "android"), not(target_os = "ios"), not(target_os = "macos")))'.dependencies]
# Desktop audio I/O for Windows capture/playback and Linux capture.
# Linux playback uses SDL2; Apple platforms use direct VoiceProcessingIO
# AudioUnits via `coreaudio-rs` for the voice path.
cpal = "0.17.3"
[target.'cfg(not(target_os = "android"))'.dependencies] [target.'cfg(not(target_os = "android"))'.dependencies]
# Desktop/iOS: native TLS maps to the platform TLS backend (Security.framework # Desktop/iOS: native TLS maps to the platform TLS backend (Security.framework
# on Apple, SChannel on Windows, system OpenSSL on Linux/BSD). # on Apple, SChannel on Windows, system OpenSSL on Linux/BSD).
reqwest = { version = "0.13", default-features = false, features = ["charset", "http2", "native-tls"] } reqwest = { version = "0.13", default-features = false, features = ["charset", "http2", "native-tls"] }
[target.'cfg(target_os = "ios")'.dependencies] [target.'cfg(any(target_os = "ios", target_os = "macos"))'.dependencies]
# Direct CoreAudio AudioUnit access on iOS (DEC-011.x follow-up). # Direct CoreAudio AudioUnit access on Apple platforms (DEC-011 follow-up).
# cpal's iOS backend is unsuitable for VoIP: it opens # cpal's Apple path does not expose the voice-processing controls Chanora
# kAudioUnitSubType_RemoteIO with a mono-only output element and no # needs for VoIP. Use `kAudioUnitSubType_VoiceProcessingIO` directly via
# control over buffer size / sample rate, AND its AudioUnit stays # `coreaudio-rs` so capture/playback share Apple's native AEC + AGC + NS
# bound to the route present at construction time so user-driven # voice unit on both iOS and macOS.
# `overrideOutputAudioPort` flips do not actually move audio to the
# new transducer. Every production iOS VoIP client (Linphone, Mumble
# iOS, Signal, Jitsi, WebRTC reference) instead drives
# `kAudioUnitSubType_VoiceProcessingIO` (a.k.a. VPIO) directly. VPIO
# is Apple's recommended voice unit; it ships hardware AEC + AGC + NS
# and honours route changes natively because it IS the canonical
# voice unit on iOS. `coreaudio-rs` (RustAudio org, same maintainers
# as `cpal`, 8.6M downloads) gives us a safe wrapper around the
# AudioUnit C API. We use it on iOS only; cpal stays on macOS where
# its CoreAudio backend works well against HAL units.
# #
# Default features keep `audio_toolbox` + `core_audio`, both required # Default features keep `audio_toolbox` + `core_audio`, both required
# for AudioUnit construction + property access. # for AudioUnit construction + property access.
@@ -137,8 +130,9 @@ futures-util = { version = "0.3", default-features = false, features = ["std"] }
# options. The portal recommends fresh tokens to scope its own # options. The portal recommends fresh tokens to scope its own
# object paths per call. # object paths per call.
rand = "0.8" rand = "0.8"
# SDL2 audio for Linux. Replaces the cpal capture / playback paths # SDL2 audio for Linux. Replaces the cpal playback path on Linux only;
# on Linux only; cpal stays in use on Windows/macOS. Rationale: the # cpal stays in use for Linux capture and Windows capture/playback.
# Apple platforms use direct VoiceProcessingIO AudioUnits. Rationale: the
# cpal Linux backend opens raw ALSA `default`, which on most Arch # cpal Linux backend opens raw ALSA `default`, which on most Arch
# / Fedora / Debian installs routes through `dmix` + `plug` with # / Fedora / Debian installs routes through `dmix` + `plug` with
# nearest-neighbour resampling and very small period sizes — the # nearest-neighbour resampling and very small period sizes — the
@@ -42,10 +42,7 @@ use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use audiopus::coder::Encoder as OpusEncoder; use audiopus::coder::Encoder as OpusEncoder;
use audiopus::{ use audiopus::{Application as OpusApp, Channels as OpusChannels, SampleRate as OpusSampleRate};
Application as OpusApp, Bitrate as OpusBitrate, Channels as OpusChannels,
SampleRate as OpusSampleRate,
};
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
use crate::mobile_voice_backend::{ use crate::mobile_voice_backend::{
@@ -340,9 +337,7 @@ pub struct AndroidVoiceUnit {
output_perf: AchievedPerformanceMode, output_perf: AchievedPerformanceMode,
output_share: AchievedSharingMode, output_share: AchievedSharingMode,
input_sample_rate: i32, input_sample_rate: i32,
output_sample_rate: i32,
input_frames_per_burst: i32, input_frames_per_burst: i32,
output_frames_per_burst: i32,
session_id: Option<AudioSessionId>, session_id: Option<AudioSessionId>,
@@ -397,7 +392,7 @@ impl AndroidVoiceUnit {
let scratch = Arc::new(Mutex::new(Vec::with_capacity(8192))); let scratch = Arc::new(Mutex::new(Vec::with_capacity(8192)));
// --- Open input stream (SDD-112) --------------------------- // --- Open input stream (SDD-112) ---------------------------
let mut input_builder = AudioStreamBuilder::default() let input_builder = AudioStreamBuilder::default()
.set_direction::<OboeInput>() .set_direction::<OboeInput>()
.set_sample_rate(cfg.sample_rate as i32) .set_sample_rate(cfg.sample_rate as i32)
.set_channel_count::<Mono>() .set_channel_count::<Mono>()
@@ -583,9 +578,7 @@ impl AndroidVoiceUnit {
output_perf, output_perf,
output_share, output_share,
input_sample_rate, input_sample_rate,
output_sample_rate,
input_frames_per_burst, input_frames_per_burst,
output_frames_per_burst,
session_id, session_id,
hw_effects, hw_effects,
event_tx, event_tx,
+86 -63
View File
@@ -8,9 +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(all(not(target_os = "ios"), not(target_os = "android")))] #[cfg(all(
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
#[cfg(all(not(target_os = "ios"), not(target_os = "android")))] #[cfg(all(
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
use cpal::{SampleFormat, SizedSample}; use cpal::{SampleFormat, SizedSample};
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tracing::{debug, info}; use tracing::{debug, info};
@@ -18,15 +26,27 @@ use tracing::{debug, info};
// playback paths (`build_input_stream`, `build_output_stream`, // playback paths (`build_input_stream`, `build_output_stream`,
// `try_open_capture` log lines). Cfg-gate the imports too so iOS // `try_open_capture` log lines). Cfg-gate the imports too so iOS
// builds don't carry an unused-imports warning. // builds don't carry an unused-imports warning.
#[cfg(all(not(target_os = "ios"), not(target_os = "android")))] #[cfg(all(
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
use tracing::{error, warn}; use tracing::{error, warn};
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
use tracing::warn; use tracing::warn;
#[cfg(all(not(target_os = "ios"), not(target_os = "android")))] #[cfg(all(
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
use audiopus::coder::Encoder as OpusEncoder; use audiopus::coder::Encoder as OpusEncoder;
#[cfg(all(not(target_os = "ios"), not(target_os = "android")))] #[cfg(all(
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
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,
@@ -41,7 +61,11 @@ use tsclientlib::audio::AudioHandler;
// iOS too, and `OutPacket` flows out of the capture pipeline once // iOS too, and `OutPacket` flows out of the capture pipeline once
// commit 3 lands. Cfg-gate the cpal-only ones to keep iOS warnings // commit 3 lands. Cfg-gate the cpal-only ones to keep iOS warnings
// clean. // clean.
#[cfg(all(not(target_os = "ios"), not(target_os = "android")))] #[cfg(all(
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
use chanora_protocol::{AudioData, CodecType, OutAudio}; use chanora_protocol::{AudioData, CodecType, OutAudio};
use chanora_protocol::{InboundVoice, OutPacket}; use chanora_protocol::{InboundVoice, OutPacket};
@@ -87,13 +111,11 @@ pub struct AudioEngineConfig {
/// ignored — the DSP chain stays a no-op and we use the /// ignored — the DSP chain stays a no-op and we use the
/// default ALSA/PipeWire source. /// default ALSA/PipeWire source.
/// ///
/// Beta status: the *config flag* is plumbed through every /// Android status: this is enforced by the Oboe-only backend,
/// layer; the *Android-side preset switch* is documented but /// which opens input with `VoiceCommunication` and output with
/// not yet wired through cpal, which currently uses the /// voice-communication usage / speech content. Setting `false`
/// AAudio default input. RISK-AUDIO-MOBILE-001 tracks this gap. /// is rejected on Android because the P0 path intentionally has
/// Setting `true` is a forward-compatible no-op for Beta and /// no generic mobile-audio fallback.
/// will become active once cpal exposes input-preset hooks (or
/// when Chanora ships an Oboe-based fork).
pub mobile_voice_preset: bool, pub mobile_voice_preset: bool,
} }
@@ -139,25 +161,28 @@ pub struct AudioEngine {
// VoiceProcessingIO AudioUnit hosts mic + speaker) — cpal is // VoiceProcessingIO AudioUnit hosts mic + speaker) — cpal is
// unused on iOS for the reasons documented in // unused on iOS for the reasons documented in
// `ios_voice_unit.rs`. // `ios_voice_unit.rs`.
#[cfg(all(not(target_os = "ios"), not(target_os = "android")))] #[cfg(all(
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
_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(all( #[cfg(all(
not(target_os = "linux"), not(target_os = "linux"),
not(target_os = "ios"), not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android") not(target_os = "android")
))] ))]
_output_stream: Mutex<Option<cpal::Stream>>, _output_stream: Mutex<Option<cpal::Stream>>,
#[cfg(target_os = "ios")] #[cfg(any(target_os = "ios", target_os = "macos"))]
_ios_voice_unit: Mutex<Option<crate::ios_voice_unit::IosVoiceUnit>>, _ios_voice_unit: Mutex<Option<crate::ios_voice_unit::IosVoiceUnit>>,
/// SDD-111..SDD-115: Android voice backend held parallel to the /// SDD-111..SDD-115: Android Oboe voice backend. Owns the input
/// cpal streams. Owns the SDD-113 hardware-effect handles and /// and output streams, SDD-113 hardware-effect handles, and the
/// the foreground-service lifecycle; tearing it down on engine /// foreground-service lifecycle; tearing it down on engine drop
/// drop unbinds effects and stops the service in SDD-115 /// unbinds effects and stops the service in SDD-115 reverse
/// reverse order. The cpal capture/playback pair continues to /// order.
/// carry voice frames for the transitional period — replacing
/// the data path with the Oboe streams is a follow-up.
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
_android_voice_unit: Mutex<Option<crate::android_voice_unit::AndroidVoiceUnit>>, _android_voice_unit: Mutex<Option<crate::android_voice_unit::AndroidVoiceUnit>>,
/// SDD-108 §1/§2: refcount-composable audio-mode controller. /// SDD-108 §1/§2: refcount-composable audio-mode controller.
@@ -234,12 +259,11 @@ impl AudioEngine {
voice_in_rx: mpsc::Receiver<InboundVoice>, voice_in_rx: mpsc::Receiver<InboundVoice>,
transmit_gate: crate::ptt::AudioTransmitGate, transmit_gate: crate::ptt::AudioTransmitGate,
) -> Result<Self, AudioError> { ) -> Result<Self, AudioError> {
// iOS routes to a separate backend (VoiceProcessingIO via // Apple platforms route to a separate backend (VoiceProcessingIO
// coreaudio-rs) because cpal's iOS RemoteIO path produces // via coreaudio-rs) because cpal does not expose the native
// mono-only output bound to a stale physical transducer // voice-processing AudioUnit controls Chanora needs for VoIP.
// (see `ios_voice_unit.rs` for the long version). Every // Windows and Linux stay on the cpal / SDL flow below.
// other platform stays on the cpal / SDL flow below. #[cfg(any(target_os = "ios", target_os = "macos"))]
#[cfg(target_os = "ios")]
{ {
return Self::start_with_gate_ios(cfg, voice_out_tx, voice_in_rx, transmit_gate); return Self::start_with_gate_ios(cfg, voice_out_tx, voice_in_rx, transmit_gate);
} }
@@ -247,18 +271,18 @@ impl AudioEngine {
{ {
return Self::start_with_gate_android(cfg, voice_out_tx, voice_in_rx, transmit_gate); return Self::start_with_gate_android(cfg, voice_out_tx, voice_in_rx, transmit_gate);
} }
#[cfg(not(any(target_os = "ios", target_os = "android")))] #[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
{ {
Self::start_with_gate_cpal(cfg, voice_out_tx, voice_in_rx, transmit_gate) Self::start_with_gate_cpal(cfg, voice_out_tx, voice_in_rx, transmit_gate)
} }
} }
/// Non-iOS implementation: cpal capture + (cpal | SDL2) output. /// Non-Apple/non-Android implementation: cpal capture + (cpal | SDL2) output.
/// Kept as a separate function so the iOS path can short-circuit /// Kept as a separate function so the Apple and Android paths can
/// at the top of `start_with_gate` without dragging a 200-line /// 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 /// cfg-gated block. Body is the pre-iOS-port code, unchanged
/// except for the new function name + signature. /// except for the new function name + signature.
#[cfg(not(any(target_os = "ios", target_os = "android")))] #[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
fn start_with_gate_cpal( fn start_with_gate_cpal(
cfg: AudioEngineConfig, cfg: AudioEngineConfig,
voice_out_tx: mpsc::Sender<OutPacket>, voice_out_tx: mpsc::Sender<OutPacket>,
@@ -394,13 +418,8 @@ impl AudioEngine {
// frames (~46 ms @ 44.1 kHz) gives the Opus decode // frames (~46 ms @ 44.1 kHz) gives the Opus decode
// callback enough headroom while still being well // callback enough headroom while still being well
// under voice-chat latency tolerance. // under voice-chat latency tolerance.
// * macOS (CoreAudio via cpal): the default period // * Apple platforms do not reach this cpal path; they
// is fine and the OS picks a HAL-friendly size. // use direct VoiceProcessingIO AudioUnits.
// * iOS (CoreAudio via cpal): RemoteIO units reject
// arbitrary buffer-size requests and surface them
// as `build_output_stream: The requested stream
// configuration is not supported by the device`.
// Must use BufferSize::Default.
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
let buffer_size = cpal::BufferSize::Fixed(2048); let buffer_size = cpal::BufferSize::Fixed(2048);
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
@@ -667,14 +686,14 @@ impl AudioEngine {
}) })
} }
/// iOS implementation: a single VoiceProcessingIO AudioUnit /// Apple implementation: a single VoiceProcessingIO AudioUnit
/// drives mic capture + speaker playback (see /// drives mic capture + speaker playback (see
/// `ios_voice_unit.rs` for why cpal is unsuitable on iOS). /// `ios_voice_unit.rs` for why cpal is unsuitable on iOS).
/// This mirrors `start_with_gate_cpal` in scaffolding — /// This mirrors `start_with_gate_cpal` in scaffolding —
/// atomics, audio handler, inbound forwarder task — but /// atomics, audio handler, inbound forwarder task — but
/// replaces the two cpal stream constructions with a single /// replaces the two cpal stream constructions with a single
/// `IosVoiceUnit::start` call. /// `IosVoiceUnit::start` call.
#[cfg(target_os = "ios")] #[cfg(any(target_os = "ios", target_os = "macos"))]
fn start_with_gate_ios( fn start_with_gate_ios(
cfg: AudioEngineConfig, cfg: AudioEngineConfig,
voice_out_tx: mpsc::Sender<OutPacket>, voice_out_tx: mpsc::Sender<OutPacket>,
@@ -683,10 +702,10 @@ impl AudioEngine {
) -> Result<Self, AudioError> { ) -> Result<Self, AudioError> {
info!( info!(
target: "chanora_audio", target: "chanora_audio",
"starting audio engine: iOS VoiceProcessingIO backend" "starting audio engine: Apple VoiceProcessingIO backend"
); );
// iOS AVAudioSession configuration is performed Swift-side // Apple audio-session configuration is performed Swift-side
// in `apps/chanora_flutter/ios/Runner/AppDelegate.swift` // in `apps/chanora_flutter/ios/Runner/AppDelegate.swift`
// BEFORE Flutter starts its audio pipeline. The category + // BEFORE Flutter starts its audio pipeline. The category +
// mode pair set there (`.playAndRecord` + `.default`) is // mode pair set there (`.playAndRecord` + `.default`) is
@@ -796,12 +815,16 @@ impl AudioEngine {
// audio. iOS collapses input + output into one // audio. iOS collapses input + output into one
// `IosVoiceUnit` (see `ios_voice_unit.rs`); every other // `IosVoiceUnit` (see `ios_voice_unit.rs`); every other
// platform has separate cpal input + cpal/SDL output. // platform has separate cpal input + cpal/SDL output.
#[cfg(all(not(target_os = "ios"), not(target_os = "android")))] #[cfg(all(
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
{ {
let _ = self._input_stream.lock().unwrap().take(); let _ = self._input_stream.lock().unwrap().take();
let _ = self._output_stream.lock().unwrap().take(); let _ = self._output_stream.lock().unwrap().take();
} }
#[cfg(target_os = "ios")] #[cfg(any(target_os = "ios", target_os = "macos"))]
{ {
let _ = self._ios_voice_unit.lock().unwrap().take(); let _ = self._ios_voice_unit.lock().unwrap().take();
} }
@@ -1036,7 +1059,7 @@ impl Drop for AudioEngine {
// ---------- Capture pipeline ---------- // ---------- Capture pipeline ----------
#[cfg(not(any(target_os = "ios", target_os = "android")))] #[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
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>,
@@ -1144,7 +1167,7 @@ fn try_open_capture(
Ok(stream) Ok(stream)
} }
#[cfg(not(any(target_os = "ios", target_os = "android")))] #[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
struct CaptureState { struct CaptureState {
encoder: OpusEncoder, encoder: OpusEncoder,
in_sample_rate: u32, in_sample_rate: u32,
@@ -1182,7 +1205,7 @@ struct CaptureState {
frame_scratch: Vec<f32>, frame_scratch: Vec<f32>,
} }
#[cfg(not(any(target_os = "ios", target_os = "android")))] #[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
impl CaptureState { impl CaptureState {
fn new( fn new(
encoder: OpusEncoder, encoder: OpusEncoder,
@@ -1366,30 +1389,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(any(target_os = "ios", target_os = "android")))] #[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
trait ToF32 { trait ToF32 {
fn to_f32_sample(self) -> f32; fn to_f32_sample(self) -> f32;
} }
#[cfg(not(any(target_os = "ios", target_os = "android")))] #[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
impl ToF32 for f32 { impl ToF32 for f32 {
fn to_f32_sample(self) -> f32 { fn to_f32_sample(self) -> f32 {
self self
} }
} }
#[cfg(not(any(target_os = "ios", target_os = "android")))] #[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
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(any(target_os = "ios", target_os = "android")))] #[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
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(any(target_os = "ios", target_os = "android")))] #[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
fn build_input_stream<T>( fn build_input_stream<T>(
device: &cpal::Device, device: &cpal::Device,
config: &cpal::StreamConfig, config: &cpal::StreamConfig,
@@ -1417,7 +1440,7 @@ where
// ---------- Playback pipeline ---------- // ---------- Playback pipeline ----------
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
#[cfg(not(any(target_os = "ios", target_os = "android")))] #[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
fn build_output_stream<T>( fn build_output_stream<T>(
device: &cpal::Device, device: &cpal::Device,
config: &cpal::StreamConfig, config: &cpal::StreamConfig,
@@ -1606,7 +1629,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(any(target_os = "ios", target_os = "android")))] #[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
struct PlaybackResampleState { struct PlaybackResampleState {
pos: f64, pos: f64,
last_l: f32, last_l: f32,
@@ -1614,26 +1637,26 @@ struct PlaybackResampleState {
} }
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
#[cfg(not(any(target_os = "ios", target_os = "android")))] #[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
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(any(target_os = "ios", target_os = "android")))] #[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
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(any(target_os = "ios", target_os = "android")))] #[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
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(any(target_os = "ios", target_os = "android")))] #[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
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;
@@ -1822,10 +1845,10 @@ pub fn android_set_audio_mode(mode: i32) -> Result<(), AudioModeError> {
// can drive the same realtime capture code path the production cpal // can drive the same realtime capture code path the production cpal
// callback uses, without re-implementing CaptureState in the bench file. // callback uses, without re-implementing CaptureState in the bench file.
// Marked `#[doc(hidden)]` so the public API surface is unaffected; this // Marked `#[doc(hidden)]` so the public API surface is unaffected; this
// is not a supported external API. Only compiled on non-iOS targets // is not a supported external API. Only compiled on cpal-capture targets
// because `CaptureState` itself is gated on `cfg(not(target_os = "ios"))`. // because Apple and Android use native voice backends instead.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
#[cfg(not(any(target_os = "ios", target_os = "android")))] #[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
#[doc(hidden)] #[doc(hidden)]
pub mod bench_seam { pub mod bench_seam {
use super::{ use super::{
+15 -16
View File
@@ -1,9 +1,11 @@
//! iOS output + capture stream via the **VoiceProcessingIO** //! Apple output + capture stream via the **VoiceProcessingIO**
//! AudioUnit (`kAudioUnitSubType_VoiceProcessingIO`, a.k.a. VPIO). //! AudioUnit (`kAudioUnitSubType_VoiceProcessingIO`, a.k.a. VPIO).
//! //!
//! ## Why not cpal on iOS //! ## Why not cpal on Apple voice paths
//! //!
//! cpal's iOS backend opens `kAudioUnitSubType_RemoteIO` with no //! 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; //! control over the stream format, buffer size, or channel count;
//! on iPhone 16 Pro running iOS 18 it reports the output element as //! on iPhone 16 Pro running iOS 18 it reports the output element as
//! **mono 48 kHz** even when the session category is `.playAndRecord` //! **mono 48 kHz** even when the session category is `.playAndRecord`
@@ -17,20 +19,14 @@
//! in logs but produces no audible difference — the audio is still //! in logs but produces no audible difference — the audio is still
//! coming out the earpiece. //! coming out the earpiece.
//! //!
//! Every production iOS VoIP client (Mumble iOS, Linphone / //! Production VoIP clients on Apple platforms drive VPIO directly instead
//! mediastreamer2, Signal-iOS, Jitsi, the WebRTC reference impl) //! of treating CoreAudio as a generic music-playback device. VPIO is Apple's
//! avoids RemoteIO and drives VPIO directly instead. VPIO is //! native voice unit: it ships hardware AEC + AGC + NS and accepts explicit
//! Apple's recommended voice unit: it ships hardware AEC + AGC + //! stream-format requests on bus 0 (output) and bus 1 (input).
//! NS, it accepts arbitrary stream-format requests on bus 0
//! (output) and bus 1 (input), and it re-binds its underlying HAL
//! transducer correctly when the AVAudioSession route changes,
//! because it IS the canonical voice unit on iOS — Apple's own
//! FaceTime audio path runs through it.
//! //!
//! This file replaces the cpal capture + playback streams on iOS //! This file replaces the cpal capture + playback streams on iOS and macOS.
//! only. macOS continues to use cpal's CoreAudio HAL backend (which //! Linux uses SDL2 for output (see `sdl_output.rs`) and cpal for capture;
//! works correctly for desktop audio). Linux uses SDL2 (see //! Windows uses cpal's WASAPI backend.
//! `sdl_output.rs`). Windows uses cpal's WASAPI backend.
//! //!
//! ## What VPIO gives us //! ## What VPIO gives us
//! //!
@@ -663,6 +659,7 @@ impl IosVoiceUnit {
/// Route rebinding on iOS is most reliable when we bounce the /// Route rebinding on iOS is most reliable when we bounce the
/// VoiceProcessingIO unit through an uninitialize/reinitialize /// VoiceProcessingIO unit through an uninitialize/reinitialize
/// cycle, then start again. /// cycle, then start again.
#[cfg(target_os = "ios")]
pub fn restart(&mut self) -> Result<(), AudioError> { pub fn restart(&mut self) -> Result<(), AudioError> {
self.unit self.unit
.stop() .stop()
@@ -681,6 +678,7 @@ impl IosVoiceUnit {
} }
/// Pause the audio unit during an interruption. /// Pause the audio unit during an interruption.
#[cfg(target_os = "ios")]
pub fn pause(&mut self) -> Result<(), AudioError> { pub fn pause(&mut self) -> Result<(), AudioError> {
self.unit self.unit
.stop() .stop()
@@ -688,6 +686,7 @@ impl IosVoiceUnit {
} }
/// Resume the audio unit after an interruption. /// Resume the audio unit after an interruption.
#[cfg(target_os = "ios")]
pub fn resume(&mut self) -> Result<(), AudioError> { pub fn resume(&mut self) -> Result<(), AudioError> {
self.unit self.unit
.start() .start()
+5 -6
View File
@@ -5,12 +5,13 @@
//! //!
//! ## What's wired in this Beta //! ## What's wired in this Beta
//! //!
//! * Default-input capture via `cpal` (DEC-011.1) //! * Default-input capture via platform audio backends (Oboe on Android,
//! VoiceProcessingIO on Apple platforms, cpal/SDL elsewhere)
//! * Frame-aligned 20 ms / 48 kHz mono Opus encoding via `audiopus` //! * Frame-aligned 20 ms / 48 kHz mono Opus encoding via `audiopus`
//! * Forward encoded frames to the protocol crate as `OutPacket`s //! * Forward encoded frames to the protocol crate as `OutPacket`s
//! * Inbound voice packets fed to `tsclientlib::audio::AudioHandler` //! * Inbound voice packets fed to `tsclientlib::audio::AudioHandler`
//! which owns Opus decode + per-client jitter buffer + mix //! which owns Opus decode + per-client jitter buffer + mix
//! * Mixed f32 PCM pulled by the cpal output callback at 48 kHz stereo //! * Mixed f32 PCM pulled by the platform output callback at 48 kHz stereo
//! * Push-to-talk: capture stream is permanently open; encoding is //! * Push-to-talk: capture stream is permanently open; encoding is
//! gated by an atomic `ptt_active` flag //! gated by an atomic `ptt_active` flag
//! //!
@@ -19,8 +20,6 @@
//! * AEC / AGC / NS / HPF DSP chain (DEC-007/008/009/010 — Beta+ //! * AEC / AGC / NS / HPF DSP chain (DEC-007/008/009/010 — Beta+
//! work; the toggles in `AudioEffects` are honoured by *naming* //! work; the toggles in `AudioEffects` are honoured by *naming*
//! but the filters are no-ops) //! but the filters are no-ops)
//! * Mobile audio paths (DEC-011.1 desktop + Android proven; this
//! integration is desktop-only for v0.2.0-beta.1)
//! * Hot-plug device-change handling //! * Hot-plug device-change handling
//! * Sample-rate adaptation if the device cannot do 48 kHz / mono in //! * Sample-rate adaptation if the device cannot do 48 kHz / mono in
//! the format we request (returns `AudioError::StreamConfig`) //! the format we request (returns `AudioError::StreamConfig`)
@@ -40,7 +39,7 @@ pub mod transmit_selector;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
mod sdl_output; mod sdl_output;
#[cfg(target_os = "ios")] #[cfg(any(target_os = "ios", target_os = "macos"))]
mod ios_voice_unit; mod ios_voice_unit;
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
@@ -52,7 +51,7 @@ pub use engine::{AudioEngine, AudioEngineConfig};
// bench harness under `crates/chanora_audio/benches/` can construct a // bench harness under `crates/chanora_audio/benches/` can construct a
// CaptureState and drive `ingest` without re-implementing the engine. // CaptureState and drive `ingest` without re-implementing the engine.
// Not part of the supported public API. // Not part of the supported public API.
#[cfg(not(any(target_os = "ios", target_os = "android")))] #[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
#[doc(hidden)] #[doc(hidden)]
pub use engine::bench_seam; pub use engine::bench_seam;
pub use ptt::{AudioTransmitGate, MissedKeyUpWatchdog, PttBackendDescriptor, PttCapabilityLevel}; pub use ptt::{AudioTransmitGate, MissedKeyUpWatchdog, PttBackendDescriptor, PttCapabilityLevel};
+3 -3
View File
@@ -26,9 +26,9 @@
//! //!
//! This file replaces the cpal output path on Linux only. The //! This file replaces the cpal output path on Linux only. The
//! capture path stays on cpal until we have a reason to swap it //! capture path stays on cpal until we have a reason to swap it
//! (the user reports outbound is currently fine). Windows / //! (the user reports outbound is currently fine). Windows continues
//! macOS continue to use cpal because cpal's WASAPI and //! to use cpal's WASAPI backend; Apple platforms use direct
//! CoreAudio backends do not have this problem. //! VoiceProcessingIO AudioUnits for the voice path.
//! //!
//! ## Threading & lifecycle //! ## Threading & lifecycle
//! //!