Files
chanora/crates/chanora_audio/src/audio_processing.rs
T

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,
}
}
}