feat(audio): prefer native voice backends
This commit is contained in:
@@ -8,9 +8,17 @@
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
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};
|
||||
#[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 tokio::sync::mpsc;
|
||||
use tracing::{debug, info};
|
||||
@@ -18,15 +26,27 @@ use tracing::{debug, info};
|
||||
// playback paths (`build_input_stream`, `build_output_stream`,
|
||||
// `try_open_capture` log lines). Cfg-gate the imports too so iOS
|
||||
// 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};
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
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;
|
||||
#[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::{
|
||||
Application as OpusApp, Bitrate as OpusBitrate, Channels as OpusChannels,
|
||||
SampleRate as OpusSampleRate,
|
||||
@@ -41,7 +61,11 @@ use tsclientlib::audio::AudioHandler;
|
||||
// iOS too, and `OutPacket` flows out of the capture pipeline once
|
||||
// commit 3 lands. Cfg-gate the cpal-only ones to keep iOS warnings
|
||||
// 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::{InboundVoice, OutPacket};
|
||||
|
||||
@@ -87,13 +111,11 @@ pub struct AudioEngineConfig {
|
||||
/// ignored — the DSP chain stays a no-op and we use the
|
||||
/// default ALSA/PipeWire source.
|
||||
///
|
||||
/// Beta status: the *config flag* is plumbed through every
|
||||
/// layer; the *Android-side preset switch* is documented but
|
||||
/// not yet wired through cpal, which currently uses the
|
||||
/// AAudio default input. RISK-AUDIO-MOBILE-001 tracks this gap.
|
||||
/// Setting `true` is a forward-compatible no-op for Beta and
|
||||
/// will become active once cpal exposes input-preset hooks (or
|
||||
/// when Chanora ships an Oboe-based fork).
|
||||
/// Android status: this is enforced by the Oboe-only backend,
|
||||
/// which opens input with `VoiceCommunication` and output with
|
||||
/// voice-communication usage / speech content. Setting `false`
|
||||
/// is rejected on Android because the P0 path intentionally has
|
||||
/// no generic mobile-audio fallback.
|
||||
pub mobile_voice_preset: bool,
|
||||
}
|
||||
|
||||
@@ -139,25 +161,28 @@ pub struct AudioEngine {
|
||||
// VoiceProcessingIO AudioUnit hosts mic + speaker) — cpal is
|
||||
// unused on iOS for the reasons documented in
|
||||
// `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>>,
|
||||
#[cfg(target_os = "linux")]
|
||||
_output_stream: Mutex<Option<crate::sdl_output::SdlOutput>>,
|
||||
#[cfg(all(
|
||||
not(target_os = "linux"),
|
||||
not(target_os = "ios"),
|
||||
not(target_os = "macos"),
|
||||
not(target_os = "android")
|
||||
))]
|
||||
_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>>,
|
||||
/// SDD-111..SDD-115: Android voice backend held parallel to the
|
||||
/// cpal streams. Owns the SDD-113 hardware-effect handles and
|
||||
/// the foreground-service lifecycle; tearing it down on engine
|
||||
/// drop unbinds effects and stops the service in SDD-115
|
||||
/// reverse order. The cpal capture/playback pair continues to
|
||||
/// carry voice frames for the transitional period — replacing
|
||||
/// the data path with the Oboe streams is a follow-up.
|
||||
/// SDD-111..SDD-115: Android Oboe voice backend. Owns the input
|
||||
/// and output streams, SDD-113 hardware-effect handles, and the
|
||||
/// foreground-service lifecycle; tearing it down on engine drop
|
||||
/// unbinds effects and stops the service in SDD-115 reverse
|
||||
/// order.
|
||||
#[cfg(target_os = "android")]
|
||||
_android_voice_unit: Mutex<Option<crate::android_voice_unit::AndroidVoiceUnit>>,
|
||||
/// SDD-108 §1/§2: refcount-composable audio-mode controller.
|
||||
@@ -234,12 +259,11 @@ impl AudioEngine {
|
||||
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")]
|
||||
// Apple platforms route to a separate backend (VoiceProcessingIO
|
||||
// via coreaudio-rs) because cpal does not expose the native
|
||||
// voice-processing AudioUnit controls Chanora needs for VoIP.
|
||||
// Windows and Linux stay on the cpal / SDL flow below.
|
||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||
{
|
||||
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);
|
||||
}
|
||||
#[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)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// Non-Apple/non-Android implementation: cpal capture + (cpal | SDL2) output.
|
||||
/// Kept as a separate function so the Apple and Android paths 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(any(target_os = "ios", target_os = "android")))]
|
||||
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||
fn start_with_gate_cpal(
|
||||
cfg: AudioEngineConfig,
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
@@ -394,13 +418,8 @@ impl AudioEngine {
|
||||
// frames (~46 ms @ 44.1 kHz) gives the Opus decode
|
||||
// callback enough headroom while still being well
|
||||
// under voice-chat latency tolerance.
|
||||
// * macOS (CoreAudio via cpal): the default period
|
||||
// is fine and the OS picks a HAL-friendly size.
|
||||
// * 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.
|
||||
// * Apple platforms do not reach this cpal path; they
|
||||
// use direct VoiceProcessingIO AudioUnits.
|
||||
#[cfg(target_os = "windows")]
|
||||
let buffer_size = cpal::BufferSize::Fixed(2048);
|
||||
#[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
|
||||
/// `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")]
|
||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||
fn start_with_gate_ios(
|
||||
cfg: AudioEngineConfig,
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
@@ -683,10 +702,10 @@ impl AudioEngine {
|
||||
) -> Result<Self, AudioError> {
|
||||
info!(
|
||||
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`
|
||||
// BEFORE Flutter starts its audio pipeline. The category +
|
||||
// mode pair set there (`.playAndRecord` + `.default`) is
|
||||
@@ -796,12 +815,16 @@ impl AudioEngine {
|
||||
// audio. iOS collapses input + output into one
|
||||
// `IosVoiceUnit` (see `ios_voice_unit.rs`); every other
|
||||
// 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._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();
|
||||
}
|
||||
@@ -1036,7 +1059,7 @@ impl Drop for AudioEngine {
|
||||
|
||||
// ---------- 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(
|
||||
in_dev: &cpal::Device,
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
@@ -1144,7 +1167,7 @@ fn try_open_capture(
|
||||
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 {
|
||||
encoder: OpusEncoder,
|
||||
in_sample_rate: u32,
|
||||
@@ -1182,7 +1205,7 @@ struct CaptureState {
|
||||
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 {
|
||||
fn new(
|
||||
encoder: OpusEncoder,
|
||||
@@ -1366,30 +1389,30 @@ impl CaptureState {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
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 {
|
||||
fn to_f32_sample(self) -> f32 {
|
||||
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 {
|
||||
fn to_f32_sample(self) -> f32 {
|
||||
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 {
|
||||
fn to_f32_sample(self) -> f32 {
|
||||
(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>(
|
||||
device: &cpal::Device,
|
||||
config: &cpal::StreamConfig,
|
||||
@@ -1417,7 +1440,7 @@ where
|
||||
// ---------- Playback pipeline ----------
|
||||
|
||||
#[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>(
|
||||
device: &cpal::Device,
|
||||
config: &cpal::StreamConfig,
|
||||
@@ -1606,7 +1629,7 @@ where
|
||||
/// Resampler state carried across output cpal callbacks. See
|
||||
/// `build_output_stream` for the rationale.
|
||||
#[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 {
|
||||
pos: f64,
|
||||
last_l: f32,
|
||||
@@ -1614,26 +1637,26 @@ struct PlaybackResampleState {
|
||||
}
|
||||
|
||||
#[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 {
|
||||
fn from_f32_sample(v: f32) -> Self;
|
||||
}
|
||||
#[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 {
|
||||
fn from_f32_sample(v: f32) -> Self {
|
||||
v
|
||||
}
|
||||
}
|
||||
#[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 {
|
||||
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(any(target_os = "ios", target_os = "android")))]
|
||||
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||
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;
|
||||
@@ -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
|
||||
// callback uses, without re-implementing CaptureState in the bench file.
|
||||
// Marked `#[doc(hidden)]` so the public API surface is unaffected; this
|
||||
// is not a supported external API. Only compiled on non-iOS targets
|
||||
// because `CaptureState` itself is gated on `cfg(not(target_os = "ios"))`.
|
||||
// is not a supported external API. Only compiled on cpal-capture targets
|
||||
// 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)]
|
||||
pub mod bench_seam {
|
||||
use super::{
|
||||
|
||||
Reference in New Issue
Block a user