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

252 lines
9.4 KiB
Rust

//! Route-to-processing policy for P1 iOS.
//!
//! Maps the current [`AudioRoute`] to the recommended
//! [`AudioProcessingConfig`] for that route. The policy enforces
//! INV_009 (never enable platform AEC and Rust AEC simultaneously)
//! and INV_010 (never enable VoiceProcessingIO and Sonora AEC3
//! simultaneously).
//!
//! The returned config is a *recommendation*; the engine may override
//! individual fields (e.g. to keep the user's explicit VAD backend
//! choice) but must not violate the hard invariants.
use crate::audio_processing::{
AudioBackend, AudioProcessingConfig, AudioRoute, EffectOwner, IosVoiceProcessingMode,
VadBackend,
};
/// Compute the recommended [`AudioProcessingConfig`] for a given
/// iOS audio route. The returned config always satisfies the P1
/// hard invariants for iOS.
///
/// * Speaker / Earpiece → platform VPIO (AEC/NS/AGC owned by platform).
/// * Wired headset → noop AEC, conservative NS/AGC optional.
/// * Bluetooth HFP → route-managed (app-side AEC off, NS/AGC conservative).
/// * Bluetooth A2DP → invalid for duplex; transmit blocked at selector level.
/// * Unknown → safe fallback (AEC off until classified).
pub fn ios_route_policy(route: AudioRoute) -> AudioProcessingConfig {
match route {
AudioRoute::Speaker | AudioRoute::Earpiece => AudioProcessingConfig {
route,
ios_mode: IosVoiceProcessingMode::PlatformVoiceProcessing,
processing_backend: AudioBackend::PlatformVoiceProcessing,
vad_backend: VadBackend::SileroOnnx,
aec: EffectOwner::Platform,
// VPIO owns NS and AGC on the shipping default path (IOSP_002/003).
ns: EffectOwner::Platform,
agc: EffectOwner::Platform,
hpf_enabled: true,
limiter_enabled: true,
..AudioProcessingConfig::default()
},
AudioRoute::WiredHeadset => AudioProcessingConfig {
route,
ios_mode: IosVoiceProcessingMode::PlatformVoiceProcessing,
processing_backend: AudioBackend::Noop,
vad_backend: VadBackend::SileroOnnx,
// No AEC needed for wired headset (no acoustic echo path).
aec: EffectOwner::Off,
// Conservative NS/AGC: optional, not forced.
ns: EffectOwner::Conservative,
agc: EffectOwner::Conservative,
hpf_enabled: true,
limiter_enabled: true,
..AudioProcessingConfig::default()
},
AudioRoute::BluetoothHfp => AudioProcessingConfig {
route,
ios_mode: IosVoiceProcessingMode::PlatformVoiceProcessing,
processing_backend: AudioBackend::PlatformVoiceProcessing,
vad_backend: VadBackend::SileroOnnx,
// BT HFP manages its own AEC in the headset firmware.
aec: EffectOwner::Off,
ns: EffectOwner::Conservative,
agc: EffectOwner::Conservative,
hpf_enabled: true,
limiter_enabled: true,
..AudioProcessingConfig::default()
},
AudioRoute::BluetoothA2dp => {
// A2DP is output-only; duplex voice is invalid on this route.
// Return a config that disables all processing and VAD.
// The transmit selector will block transmit via the route check.
AudioProcessingConfig {
route,
ios_mode: IosVoiceProcessingMode::PlatformVoiceProcessing,
processing_backend: AudioBackend::Noop,
vad_backend: VadBackend::Disabled,
aec: EffectOwner::Off,
ns: EffectOwner::Off,
agc: EffectOwner::Off,
hpf_enabled: false,
limiter_enabled: false,
..AudioProcessingConfig::default()
}
}
AudioRoute::Unknown => AudioProcessingConfig {
route,
ios_mode: IosVoiceProcessingMode::PlatformVoiceProcessing,
processing_backend: AudioBackend::Noop,
vad_backend: VadBackend::SileroOnnx,
// Safe fallback: AEC off until route is classified.
aec: EffectOwner::Off,
ns: EffectOwner::Off,
agc: EffectOwner::Off,
hpf_enabled: true,
limiter_enabled: true,
..AudioProcessingConfig::default()
},
}
}
/// Apply a route change to an existing config, preserving user-chosen
/// VAD backend, timing, and debug settings while updating the
/// route-dependent policy fields.
pub fn apply_route_change(
existing: &AudioProcessingConfig,
new_route: AudioRoute,
) -> AudioProcessingConfig {
let policy = ios_route_policy(new_route);
AudioProcessingConfig {
// Route-policy fields from the new route.
route: policy.route,
ios_mode: policy.ios_mode,
processing_backend: policy.processing_backend,
aec: policy.aec,
ns: policy.ns,
agc: policy.agc,
hpf_enabled: policy.hpf_enabled,
limiter_enabled: policy.limiter_enabled,
// Preserve user-chosen VAD backend and timing.
vad_backend: existing.vad_backend,
vad_hangover_ms: existing.vad_hangover_ms,
vad_pre_roll_ms: existing.vad_pre_roll_ms,
vad_min_tx_ms: existing.vad_min_tx_ms,
// Preserve debug settings.
debug_wav_dump_enabled: existing.debug_wav_dump_enabled,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn speaker_uses_platform_vpio() {
let cfg = ios_route_policy(AudioRoute::Speaker);
assert_eq!(
cfg.processing_backend,
AudioBackend::PlatformVoiceProcessing
);
assert_eq!(cfg.aec, EffectOwner::Platform);
// VPIO owns NS and AGC on the default path.
assert_eq!(cfg.ns, EffectOwner::Platform);
assert_eq!(cfg.agc, EffectOwner::Platform);
assert_eq!(
cfg.ios_mode,
IosVoiceProcessingMode::PlatformVoiceProcessing
);
assert_eq!(cfg.vad_backend, VadBackend::SileroOnnx);
}
#[test]
fn earpiece_uses_platform_vpio() {
let cfg = ios_route_policy(AudioRoute::Earpiece);
assert_eq!(
cfg.processing_backend,
AudioBackend::PlatformVoiceProcessing
);
assert_eq!(cfg.aec, EffectOwner::Platform);
assert_eq!(cfg.vad_backend, VadBackend::SileroOnnx);
}
#[test]
fn wired_headset_disables_aec() {
let cfg = ios_route_policy(AudioRoute::WiredHeadset);
assert_eq!(cfg.aec, EffectOwner::Off);
assert_eq!(cfg.processing_backend, AudioBackend::Noop);
assert_eq!(cfg.vad_backend, VadBackend::SileroOnnx);
}
#[test]
fn bluetooth_hfp_disables_app_aec() {
let cfg = ios_route_policy(AudioRoute::BluetoothHfp);
assert_eq!(cfg.aec, EffectOwner::Off);
assert_eq!(cfg.vad_backend, VadBackend::SileroOnnx);
}
#[test]
fn bluetooth_a2dp_disables_all_processing_and_vad() {
let cfg = ios_route_policy(AudioRoute::BluetoothA2dp);
assert_eq!(cfg.aec, EffectOwner::Off);
assert_eq!(cfg.vad_backend, VadBackend::Disabled);
assert_eq!(cfg.processing_backend, AudioBackend::Noop);
}
#[test]
fn unknown_route_safe_fallback_no_aec() {
let cfg = ios_route_policy(AudioRoute::Unknown);
assert_eq!(cfg.aec, EffectOwner::Off);
assert_eq!(cfg.vad_backend, VadBackend::SileroOnnx);
}
#[test]
fn apply_route_change_preserves_vad_timing_and_debug() {
let existing = AudioProcessingConfig {
vad_backend: VadBackend::WebrtcVad,
vad_hangover_ms: 600,
vad_pre_roll_ms: 200,
vad_min_tx_ms: 300,
debug_wav_dump_enabled: true,
..AudioProcessingConfig::default()
};
let updated = apply_route_change(&existing, AudioRoute::WiredHeadset);
assert_eq!(updated.vad_backend, VadBackend::WebrtcVad);
assert_eq!(updated.vad_hangover_ms, 600);
assert_eq!(updated.vad_pre_roll_ms, 200);
assert_eq!(updated.vad_min_tx_ms, 300);
assert!(updated.debug_wav_dump_enabled);
assert_eq!(updated.route, AudioRoute::WiredHeadset);
assert_eq!(updated.aec, EffectOwner::Off);
}
#[test]
fn no_sonora_aec_in_platform_vpio_policy() {
// INV_009: AEC must never be Sonora in the VPIO path.
for route in [
AudioRoute::Speaker,
AudioRoute::Earpiece,
AudioRoute::WiredHeadset,
AudioRoute::BluetoothHfp,
AudioRoute::BluetoothA2dp,
AudioRoute::Unknown,
] {
let cfg = ios_route_policy(route);
assert_ne!(
cfg.processing_backend,
AudioBackend::Sonora,
"route {:?} must not use Sonora backend in platform policy",
route
);
assert_ne!(
cfg.aec,
EffectOwner::Sonora,
"route {:?}: AEC must not be Sonora in VPIO path (INV_009)",
route
);
assert_ne!(
cfg.ns,
EffectOwner::Sonora,
"route {:?}: NS must not be Sonora in VPIO path (IOSP_003)",
route
);
assert_ne!(
cfg.agc,
EffectOwner::Sonora,
"route {:?}: AGC must not be Sonora in VPIO path (IOSP_003)",
route
);
}
}
}