457 lines
16 KiB
Rust
457 lines
16 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 {
|
|
/// Shipping default: Apple VoiceProcessingIO owns AEC/NS/AGC.
|
|
PlatformVoiceProcessing,
|
|
/// Experimental Sonora capture-processing path.
|
|
SonoraExperimental,
|
|
}
|
|
|
|
/// 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,
|
|
/// Future WebRTC APM 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,
|
|
/// TEN VAD backend. Native TEN runtime is optional; unavailable
|
|
/// builds fall back to the realtime-safe WebRTC detector.
|
|
TenVad,
|
|
/// WebRTC-style fallback VAD.
|
|
WebrtcVad,
|
|
/// Debug-only energy VAD.
|
|
EnergyDebug,
|
|
/// VAD disabled.
|
|
Disabled,
|
|
}
|
|
|
|
impl VadBackend {
|
|
/// Stable bridge/debug string.
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::SileroOnnx => "silero_vad_onnx",
|
|
Self::TenVad => "ten_vad",
|
|
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: VadBackend::SileroOnnx,
|
|
aec: EffectOwner::Platform,
|
|
// iOS VPIO owns NS/AGC on the default shipping path. Rust/Sonora
|
|
// 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.ios_mode == IosVoiceProcessingMode::PlatformVoiceProcessing
|
|
&& (self.processing_backend == AudioBackend::Sonora
|
|
|| self.aec == EffectOwner::Sonora
|
|
|| self.ns == EffectOwner::Sonora
|
|
|| self.agc == EffectOwner::Sonora)
|
|
{
|
|
return Err(AudioError::InvalidAudioProcessingConfig(
|
|
"Sonora cannot be enabled with iOS VoiceProcessingIO".to_string(),
|
|
));
|
|
}
|
|
if self.ios_mode == IosVoiceProcessingMode::SonoraExperimental {
|
|
if self.processing_backend != AudioBackend::Sonora {
|
|
return Err(AudioError::InvalidAudioProcessingConfig(
|
|
"ios Sonora experimental mode requires the Sonora processing backend"
|
|
.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_sonora_effects() {
|
|
let config = AudioProcessingConfig {
|
|
ns: EffectOwner::Sonora,
|
|
..AudioProcessingConfig::default()
|
|
};
|
|
|
|
assert!(config.validate_for_ios().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn ten_vad_has_stable_debug_string() {
|
|
assert_eq!(VadBackend::TenVad.as_str(), "ten_vad");
|
|
}
|
|
|
|
#[test]
|
|
fn sonora_experimental_allows_full_sonora_chain() {
|
|
let config = AudioProcessingConfig {
|
|
ios_mode: IosVoiceProcessingMode::SonoraExperimental,
|
|
processing_backend: AudioBackend::Sonora,
|
|
aec: EffectOwner::Sonora,
|
|
ns: EffectOwner::Sonora,
|
|
agc: EffectOwner::Sonora,
|
|
..AudioProcessingConfig::default()
|
|
};
|
|
|
|
assert!(config.validate_for_ios().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn sonora_experimental_rejects_non_sonora_backend() {
|
|
let config = AudioProcessingConfig {
|
|
ios_mode: IosVoiceProcessingMode::SonoraExperimental,
|
|
processing_backend: AudioBackend::PlatformVoiceProcessing,
|
|
aec: EffectOwner::Sonora,
|
|
ns: EffectOwner::Sonora,
|
|
agc: EffectOwner::Sonora,
|
|
..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,
|
|
/// 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,
|
|
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),
|
|
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 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);
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
|
|
/// 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),
|
|
sonora_enabled: config.processing_backend == AudioBackend::Sonora,
|
|
platform_voice_processing_enabled: config.processing_backend
|
|
== AudioBackend::PlatformVoiceProcessing,
|
|
}
|
|
}
|
|
}
|