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
489 lines
17 KiB
Rust
489 lines
17 KiB
Rust
//! P1 audio-processing configuration and statistics.
|
|
//!
|
|
//! iOS P1 ships the platform VoiceProcessingIO path by default. Rust
|
|
//! software AEC/NS/AGC backends are represented in the schema so the
|
|
//! bridge can reject unsafe combinations instead of silently enabling
|
|
//! double processing.
|
|
|
|
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
|
|
|
use crate::AudioError;
|
|
|
|
/// Physical/logical audio route class used for route-aware policy.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum AudioRoute {
|
|
/// Built-in speakerphone path.
|
|
Speaker,
|
|
/// Built-in receiver/earpiece path.
|
|
Earpiece,
|
|
/// Wired headset or USB headset.
|
|
WiredHeadset,
|
|
/// Bluetooth Hands-Free Profile duplex route.
|
|
BluetoothHfp,
|
|
/// Bluetooth A2DP output-only route.
|
|
BluetoothA2dp,
|
|
/// Route could not be classified yet.
|
|
Unknown,
|
|
}
|
|
|
|
impl AudioRoute {
|
|
/// Stable bridge/debug string.
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::Speaker => "speaker",
|
|
Self::Earpiece => "earpiece",
|
|
Self::WiredHeadset => "wired_headset",
|
|
Self::BluetoothHfp => "bluetooth_hfp",
|
|
Self::BluetoothA2dp => "bluetooth_a2dp",
|
|
Self::Unknown => "unknown",
|
|
}
|
|
}
|
|
|
|
/// Parse from the Swift-side route class string (case-insensitive).
|
|
/// Unrecognised strings map to `Unknown`.
|
|
pub fn from_route_class(s: &str) -> Self {
|
|
match s {
|
|
"Speaker" | "speaker" => Self::Speaker,
|
|
"Earpiece" | "earpiece" => Self::Earpiece,
|
|
"WiredHeadset" | "wired_headset" => Self::WiredHeadset,
|
|
"BluetoothHfp" | "bluetooth_hfp" => Self::BluetoothHfp,
|
|
"BluetoothA2dp" | "bluetooth_a2dp" => Self::BluetoothA2dp,
|
|
_ => Self::Unknown,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// iOS voice-processing mode.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum IosVoiceProcessingMode {
|
|
/// Apple VoiceProcessingIO owns AEC/NS/AGC.
|
|
PlatformVoiceProcessing,
|
|
}
|
|
|
|
/// Processing backend selected by policy/config.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum AudioBackend {
|
|
/// Platform voice processing, VPIO on iOS.
|
|
PlatformVoiceProcessing,
|
|
/// Rust-native Sonora backend.
|
|
Sonora,
|
|
/// WebRTC Audio Processing Module backend.
|
|
WebrtcApm,
|
|
/// No processing.
|
|
Noop,
|
|
}
|
|
|
|
impl AudioBackend {
|
|
/// Stable bridge/debug string.
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::PlatformVoiceProcessing => "platform_voice_processing",
|
|
Self::Sonora => "sonora",
|
|
Self::WebrtcApm => "webrtc_apm",
|
|
Self::Noop => "noop",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// VAD backend selected by policy/config.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum VadBackend {
|
|
/// Silero ONNX VAD. P1 schema default when model/runtime exist.
|
|
SileroOnnx,
|
|
/// WebRTC-style fallback VAD.
|
|
WebrtcVad,
|
|
/// Debug-only energy VAD.
|
|
EnergyDebug,
|
|
/// VAD disabled.
|
|
Disabled,
|
|
}
|
|
|
|
fn default_vad_backend() -> VadBackend {
|
|
VadBackend::SileroOnnx
|
|
}
|
|
|
|
impl VadBackend {
|
|
/// Stable bridge/debug string.
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
|
Self::SileroOnnx => "apple_coreml",
|
|
#[cfg(not(any(target_os = "ios", target_os = "macos")))]
|
|
Self::SileroOnnx => "silero_vad_onnx",
|
|
Self::WebrtcVad => "webrtc_vad",
|
|
Self::EnergyDebug => "energy_debug",
|
|
Self::Disabled => "disabled",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Effect owner for AEC/NS/AGC policy fields.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum EffectOwner {
|
|
/// Owned by platform voice processing.
|
|
Platform,
|
|
/// Owned by Sonora.
|
|
Sonora,
|
|
/// Owned by WebRTC APM.
|
|
WebrtcApm,
|
|
/// Conservative route-managed setting.
|
|
Conservative,
|
|
/// Disabled.
|
|
Off,
|
|
}
|
|
|
|
/// Voice-processing configuration owned by the Rust audio engine.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct AudioProcessingConfig {
|
|
/// Current route class.
|
|
pub route: AudioRoute,
|
|
/// iOS processing mode.
|
|
pub ios_mode: IosVoiceProcessingMode,
|
|
/// Processing backend.
|
|
pub processing_backend: AudioBackend,
|
|
/// VAD backend.
|
|
pub vad_backend: VadBackend,
|
|
/// AEC owner.
|
|
pub aec: EffectOwner,
|
|
/// Noise suppression owner.
|
|
pub ns: EffectOwner,
|
|
/// AGC owner.
|
|
pub agc: EffectOwner,
|
|
/// High-pass filter enabled.
|
|
pub hpf_enabled: bool,
|
|
/// Limiter enabled.
|
|
pub limiter_enabled: bool,
|
|
/// Hangover after speech closes.
|
|
pub vad_hangover_ms: u32,
|
|
/// Pre-roll before open.
|
|
pub vad_pre_roll_ms: u32,
|
|
/// Minimum transmit duration after open.
|
|
pub vad_min_tx_ms: u32,
|
|
/// Debug WAV dumps enabled.
|
|
pub debug_wav_dump_enabled: bool,
|
|
}
|
|
|
|
impl Default for AudioProcessingConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
route: AudioRoute::Speaker,
|
|
ios_mode: IosVoiceProcessingMode::PlatformVoiceProcessing,
|
|
processing_backend: AudioBackend::PlatformVoiceProcessing,
|
|
vad_backend: default_vad_backend(),
|
|
aec: EffectOwner::Platform,
|
|
// iOS VPIO owns NS/AGC on the default shipping path. Software
|
|
// effects are opt-in through the experimental raw route only.
|
|
ns: EffectOwner::Platform,
|
|
agc: EffectOwner::Platform,
|
|
hpf_enabled: true,
|
|
limiter_enabled: true,
|
|
vad_hangover_ms: crate::voice_activity::VAD_HANGOVER_MS,
|
|
vad_pre_roll_ms: 160,
|
|
vad_min_tx_ms: crate::voice_activity::VAD_MIN_TX_MS,
|
|
debug_wav_dump_enabled: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl AudioProcessingConfig {
|
|
/// Validate P1 iOS invariants before applying a config.
|
|
pub fn validate_for_ios(&self) -> Result<(), AudioError> {
|
|
if self.route == AudioRoute::BluetoothA2dp {
|
|
return Err(AudioError::InvalidAudioProcessingConfig(
|
|
"bluetooth_a2dp is output-only and cannot transmit duplex voice".to_string(),
|
|
));
|
|
}
|
|
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(),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Demote a failed VAD backend to the WebRTC fallback.
|
|
///
|
|
/// Returns `true` when the config changed.
|
|
pub fn disable_failed_vad_backend(&mut self, failed_backend: VadBackend) -> bool {
|
|
if self.vad_backend == failed_backend && failed_backend != VadBackend::WebrtcVad {
|
|
self.vad_backend = VadBackend::WebrtcVad;
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn default_config_is_valid_for_ios_vpio() {
|
|
let config = AudioProcessingConfig::default();
|
|
|
|
assert!(config.validate_for_ios().is_ok());
|
|
assert_eq!(
|
|
config.processing_backend,
|
|
AudioBackend::PlatformVoiceProcessing
|
|
);
|
|
assert_eq!(config.aec, EffectOwner::Platform);
|
|
assert_eq!(config.ns, EffectOwner::Platform);
|
|
assert_eq!(config.agc, EffectOwner::Platform);
|
|
assert_eq!(config.vad_backend, VadBackend::SileroOnnx);
|
|
}
|
|
|
|
#[test]
|
|
fn platform_voice_processing_rejects_software_effects() {
|
|
let config = AudioProcessingConfig {
|
|
ns: EffectOwner::WebrtcApm,
|
|
..AudioProcessingConfig::default()
|
|
};
|
|
|
|
assert!(config.validate_for_ios().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn disable_failed_vad_backend_demotes_to_webrtc() {
|
|
let mut config = AudioProcessingConfig {
|
|
vad_backend: VadBackend::SileroOnnx,
|
|
..AudioProcessingConfig::default()
|
|
};
|
|
|
|
assert!(config.disable_failed_vad_backend(VadBackend::SileroOnnx));
|
|
assert_eq!(config.vad_backend, VadBackend::WebrtcVad);
|
|
assert!(!config.disable_failed_vad_backend(VadBackend::SileroOnnx));
|
|
}
|
|
}
|
|
|
|
/// Runtime audio processing stats exposed to bridge/UI diagnostics.
|
|
#[derive(Debug, Clone)]
|
|
pub struct AudioProcessingStats {
|
|
/// Input dBFS before processing.
|
|
pub input_dbfs: f32,
|
|
/// Render dBFS before playout.
|
|
pub render_dbfs: f32,
|
|
/// Processed capture dBFS.
|
|
pub processed_dbfs: f32,
|
|
/// Latest VAD probability or fallback confidence.
|
|
pub vad_probability: f32,
|
|
/// VAD active state.
|
|
pub vad_active: bool,
|
|
/// Current resolved transmit state.
|
|
pub transmitting: bool,
|
|
/// VAD backend.
|
|
pub vad_backend: VadBackend,
|
|
/// Whether fallback VAD is active.
|
|
pub vad_fallback_active: bool,
|
|
/// Processing backend.
|
|
pub processing_backend: AudioBackend,
|
|
/// iOS mode.
|
|
pub ios_voice_processing_mode: IosVoiceProcessingMode,
|
|
/// Route class.
|
|
pub audio_route: AudioRoute,
|
|
/// Actual sample rate.
|
|
pub actual_sample_rate_hz: u32,
|
|
/// Actual IO buffer frame count.
|
|
pub actual_io_buffer_frames: u32,
|
|
/// Input overrun count.
|
|
pub input_overruns: u64,
|
|
/// Output underrun count.
|
|
pub output_underruns: u64,
|
|
/// Callback xrun count.
|
|
pub callback_xruns: u64,
|
|
/// Clipped sample count.
|
|
pub clipped_samples: u64,
|
|
/// Number of effectively silent processed capture frames.
|
|
pub zero_frames: u64,
|
|
/// Number of processed capture frames.
|
|
pub capture_frames: u64,
|
|
/// Number of input callbacks carrying 10 ms of audio.
|
|
pub callbacks_10ms: u64,
|
|
/// Number of input callbacks carrying 20 ms of audio.
|
|
pub callbacks_20ms: u64,
|
|
/// Number of input callbacks carrying any other size.
|
|
pub callbacks_other: u64,
|
|
/// Sonora enabled.
|
|
pub sonora_enabled: bool,
|
|
/// Platform voice processing enabled.
|
|
pub platform_voice_processing_enabled: bool,
|
|
}
|
|
|
|
/// Lock-free stats storage shared with callbacks.
|
|
pub struct SharedAudioProcessingStats {
|
|
input_dbfs: AtomicU32,
|
|
render_dbfs: AtomicU32,
|
|
processed_dbfs: AtomicU32,
|
|
vad_probability: AtomicU32,
|
|
vad_active: AtomicBool,
|
|
transmitting: AtomicBool,
|
|
vad_fallback_active: AtomicBool,
|
|
input_overruns: AtomicU64,
|
|
output_underruns: AtomicU64,
|
|
callback_xruns: AtomicU64,
|
|
clipped_samples: AtomicU64,
|
|
zero_frames: AtomicU64,
|
|
capture_frames: AtomicU64,
|
|
callbacks_10ms: AtomicU64,
|
|
callbacks_20ms: AtomicU64,
|
|
callbacks_other: AtomicU64,
|
|
actual_sample_rate_hz: AtomicU32,
|
|
actual_io_buffer_frames: AtomicU32,
|
|
}
|
|
|
|
impl Default for SharedAudioProcessingStats {
|
|
fn default() -> Self {
|
|
Self {
|
|
input_dbfs: AtomicU32::new((-120.0_f32).to_bits()),
|
|
render_dbfs: AtomicU32::new((-120.0_f32).to_bits()),
|
|
processed_dbfs: AtomicU32::new((-120.0_f32).to_bits()),
|
|
vad_probability: AtomicU32::new(0.0_f32.to_bits()),
|
|
vad_active: AtomicBool::new(false),
|
|
transmitting: AtomicBool::new(false),
|
|
vad_fallback_active: AtomicBool::new(false),
|
|
input_overruns: AtomicU64::new(0),
|
|
output_underruns: AtomicU64::new(0),
|
|
callback_xruns: AtomicU64::new(0),
|
|
clipped_samples: AtomicU64::new(0),
|
|
zero_frames: AtomicU64::new(0),
|
|
capture_frames: AtomicU64::new(0),
|
|
callbacks_10ms: AtomicU64::new(0),
|
|
callbacks_20ms: AtomicU64::new(0),
|
|
callbacks_other: AtomicU64::new(0),
|
|
actual_sample_rate_hz: AtomicU32::new(crate::frame::SAMPLE_RATE_HZ),
|
|
actual_io_buffer_frames: AtomicU32::new(crate::frame::FRAME_20MS_SAMPLES as u32),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl SharedAudioProcessingStats {
|
|
/// Store the raw input dBFS level for capture paths that do not
|
|
/// update the full processing/VAD snapshot on this callback.
|
|
pub fn set_input_dbfs(&self, dbfs: f32) {
|
|
self.input_dbfs.store(dbfs.to_bits(), Ordering::Relaxed);
|
|
}
|
|
|
|
/// Read the current input dBFS level.
|
|
pub fn input_dbfs(&self) -> f32 {
|
|
f32::from_bits(self.input_dbfs.load(Ordering::Relaxed))
|
|
}
|
|
|
|
/// Store capture levels and VAD state.
|
|
pub fn update_capture(
|
|
&self,
|
|
input_dbfs: f32,
|
|
processed_dbfs: f32,
|
|
probability: f32,
|
|
vad_active: bool,
|
|
transmitting: bool,
|
|
) {
|
|
self.input_dbfs
|
|
.store(input_dbfs.to_bits(), Ordering::Relaxed);
|
|
self.processed_dbfs
|
|
.store(processed_dbfs.to_bits(), Ordering::Relaxed);
|
|
self.vad_probability
|
|
.store(probability.clamp(0.0, 1.0).to_bits(), Ordering::Relaxed);
|
|
self.vad_active.store(vad_active, Ordering::Relaxed);
|
|
self.transmitting.store(transmitting, Ordering::Relaxed);
|
|
}
|
|
|
|
/// Store render level and callback buffer size.
|
|
pub fn update_render(&self, dbfs: f32, io_buffer_frames: u32) {
|
|
self.render_dbfs.store(dbfs.to_bits(), Ordering::Relaxed);
|
|
self.actual_io_buffer_frames
|
|
.store(io_buffer_frames, Ordering::Relaxed);
|
|
}
|
|
|
|
/// Record the actual device sample rate.
|
|
pub fn set_actual_sample_rate_hz(&self, sample_rate_hz: u32) {
|
|
self.actual_sample_rate_hz
|
|
.store(sample_rate_hz, Ordering::Relaxed);
|
|
}
|
|
|
|
/// Increment output underrun count.
|
|
pub fn increment_output_underrun(&self) {
|
|
self.output_underruns.fetch_add(1, Ordering::Relaxed);
|
|
}
|
|
|
|
/// Increment callback xrun count.
|
|
pub fn increment_callback_xrun(&self) {
|
|
self.callback_xruns.fetch_add(1, Ordering::Relaxed);
|
|
}
|
|
|
|
/// Add clipped samples.
|
|
pub fn add_clipped_samples(&self, count: u64) {
|
|
self.clipped_samples.fetch_add(count, Ordering::Relaxed);
|
|
}
|
|
|
|
/// Record one processed capture frame and whether it was effectively silent.
|
|
pub fn record_capture_frame(&self, zero_frame: bool) {
|
|
self.capture_frames.fetch_add(1, Ordering::Relaxed);
|
|
if zero_frame {
|
|
self.zero_frames.fetch_add(1, Ordering::Relaxed);
|
|
}
|
|
}
|
|
|
|
/// Bucket callback delivery sizes to diagnose timing jitter and packetization.
|
|
pub fn record_callback_frames(&self, frames: u64) {
|
|
let sample_rate_hz = self.actual_sample_rate_hz.load(Ordering::Relaxed).max(1);
|
|
let frames_10ms = (sample_rate_hz / 100) as u64;
|
|
let frames_20ms = (sample_rate_hz / 50) as u64;
|
|
match frames {
|
|
value if value == frames_10ms => {
|
|
self.callbacks_10ms.fetch_add(1, Ordering::Relaxed);
|
|
}
|
|
value if value == frames_20ms => {
|
|
self.callbacks_20ms.fetch_add(1, Ordering::Relaxed);
|
|
}
|
|
_ => {
|
|
self.callbacks_other.fetch_add(1, Ordering::Relaxed);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Store whether the selected VAD backend is currently using a fallback.
|
|
pub fn set_vad_fallback_active(&self, active: bool) {
|
|
self.vad_fallback_active.store(active, Ordering::Relaxed);
|
|
}
|
|
|
|
/// Build an owned stats snapshot with config-derived labels.
|
|
pub fn snapshot(&self, config: &AudioProcessingConfig) -> AudioProcessingStats {
|
|
AudioProcessingStats {
|
|
input_dbfs: f32::from_bits(self.input_dbfs.load(Ordering::Relaxed)),
|
|
render_dbfs: f32::from_bits(self.render_dbfs.load(Ordering::Relaxed)),
|
|
processed_dbfs: f32::from_bits(self.processed_dbfs.load(Ordering::Relaxed)),
|
|
vad_probability: f32::from_bits(self.vad_probability.load(Ordering::Relaxed)),
|
|
vad_active: self.vad_active.load(Ordering::Relaxed),
|
|
transmitting: self.transmitting.load(Ordering::Relaxed),
|
|
vad_backend: config.vad_backend,
|
|
vad_fallback_active: self.vad_fallback_active.load(Ordering::Relaxed),
|
|
processing_backend: config.processing_backend,
|
|
ios_voice_processing_mode: config.ios_mode,
|
|
audio_route: config.route,
|
|
actual_sample_rate_hz: self.actual_sample_rate_hz.load(Ordering::Relaxed),
|
|
actual_io_buffer_frames: self.actual_io_buffer_frames.load(Ordering::Relaxed),
|
|
input_overruns: self.input_overruns.load(Ordering::Relaxed),
|
|
output_underruns: self.output_underruns.load(Ordering::Relaxed),
|
|
callback_xruns: self.callback_xruns.load(Ordering::Relaxed),
|
|
clipped_samples: self.clipped_samples.load(Ordering::Relaxed),
|
|
zero_frames: self.zero_frames.load(Ordering::Relaxed),
|
|
capture_frames: self.capture_frames.load(Ordering::Relaxed),
|
|
callbacks_10ms: self.callbacks_10ms.load(Ordering::Relaxed),
|
|
callbacks_20ms: self.callbacks_20ms.load(Ordering::Relaxed),
|
|
callbacks_other: self.callbacks_other.load(Ordering::Relaxed),
|
|
sonora_enabled: config.processing_backend == AudioBackend::Sonora,
|
|
platform_voice_processing_enabled: config.processing_backend
|
|
== AudioBackend::PlatformVoiceProcessing,
|
|
}
|
|
}
|
|
}
|