//! 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 ); } } }