feat(voice): add iOS VAD runtime support

This commit is contained in:
Edison Jwa
2026-05-21 20:51:45 +09:00
parent 171baf6e41
commit 6af4ecab0f
73 changed files with 11529 additions and 1249 deletions
+453 -18
View File
@@ -274,22 +274,7 @@ fn log_file_path() -> Option<std::path::PathBuf> {
}
#[cfg(target_os = "ios")]
{
// iOS sandbox: write the log to the app's Documents
// directory so it persists across launches and can be
// pulled via Xcode -> Devices and Simulators -> Download
// Container, OR via Files.app on the device (the app
// appears under "On My iPhone" once we declare
// UIFileSharingEnabled + LSSupportsOpeningDocumentsInPlace
// in Info.plist — done in a follow-up).
//
// HOME on iOS resolves to the app sandbox root; Documents
// is the standard user-visible subdirectory.
let home = std::env::var_os("HOME")?;
Some(
std::path::PathBuf::from(home)
.join("Documents")
.join("chanora.log"),
)
None
}
#[cfg(target_os = "android")]
{
@@ -508,13 +493,46 @@ pub async fn is_connected() -> bool {
/// Handle iOS AVAudioSession route changes (SDD-100).
#[frb(sync)]
pub fn handle_route_change() {
let result = runtime().block_on(async { session().ios_handle_route_change().await });
pub fn handle_route_change(route: BridgeAudioRoute) {
let result =
runtime().block_on(async { session().ios_handle_route_change(route.into()).await });
if let Err(e) = result {
warn!(target: "chanora_bridge", error = %e, "iOS route-change handling failed");
}
}
/// Handle iOS AVAudioSession media-services reset (legacy, no route arg).
///
/// Called by the existing FRB-generated Dart binding. Uses
/// `AudioRoute::Unknown` which triggers a route-change recompute.
/// The AppDelegate now also calls `handle_media_services_reset_with_route`
/// directly after rebuilding the session.
#[frb(sync)]
pub fn handle_media_services_reset() {
let result = runtime().block_on(async {
session()
.ios_handle_media_services_reset(chanora_audio::AudioRoute::Unknown)
.await
});
if let Err(e) = result {
warn!(target: "chanora_bridge", error = %e, "iOS media-services reset handling failed");
}
}
/// Handle iOS AVAudioSession media-services reset with the current
/// route class. Called by AppDelegate after rebuilding the session.
///
/// `route_class` is the Swift-side route class string (e.g. "Speaker").
#[frb(sync)]
pub fn handle_media_services_reset_with_route(route_class: String) {
let route = chanora_audio::AudioRoute::from_route_class(&route_class);
let result =
runtime().block_on(async { session().ios_handle_media_services_reset(route).await });
if let Err(e) = result {
warn!(target: "chanora_bridge", error = %e, "iOS media-services reset (with route) handling failed");
}
}
/// Handle iOS AVAudioSession interruption begin (SDD-101).
#[frb(sync)]
pub fn handle_interruption_began() {
@@ -787,6 +805,331 @@ pub struct BridgeAudioStats {
pub ptt_active: bool,
}
/// Bridge route class for P1 audio-processing policy.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BridgeAudioRoute {
/// Built-in speakerphone.
Speaker,
/// Built-in receiver/earpiece.
Earpiece,
/// Wired or USB headset.
WiredHeadset,
/// Bluetooth HFP duplex route.
BluetoothHfp,
/// Bluetooth A2DP output-only route.
BluetoothA2dp,
/// Unknown route.
Unknown,
}
/// Bridge iOS voice-processing mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BridgeIosVoiceProcessingMode {
/// Shipping VPIO path.
PlatformVoiceProcessing,
/// Experimental Sonora path.
SonoraExperimental,
}
/// Bridge processing backend.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BridgeAudioBackend {
/// Platform voice processing.
PlatformVoiceProcessing,
/// Sonora backend.
Sonora,
/// WebRTC APM backend.
WebrtcApm,
/// No-op backend.
Noop,
}
/// Bridge VAD backend.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BridgeVadBackend {
/// Silero ONNX VAD.
SileroOnnx,
/// TEN VAD.
TenVad,
/// WebRTC fallback VAD.
WebrtcVad,
/// Debug energy VAD.
EnergyDebug,
/// VAD disabled.
Disabled,
}
/// Bridge effect owner for AEC/NS/AGC.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BridgeEffectOwner {
/// Platform-owned effect.
Platform,
/// Sonora-owned effect.
Sonora,
/// WebRTC APM-owned effect.
WebrtcApm,
/// Conservative route-managed setting.
Conservative,
/// Disabled.
Off,
}
/// P1 audio-processing configuration DTO.
#[derive(Debug, Clone)]
pub struct BridgeAudioProcessingConfig {
/// Route class.
pub route: BridgeAudioRoute,
/// iOS voice-processing mode.
pub ios_mode: BridgeIosVoiceProcessingMode,
/// Processing backend.
pub processing_backend: BridgeAudioBackend,
/// VAD backend.
pub vad_backend: BridgeVadBackend,
/// AEC owner.
pub aec: BridgeEffectOwner,
/// Noise suppression owner.
pub ns: BridgeEffectOwner,
/// AGC owner.
pub agc: BridgeEffectOwner,
/// High-pass filter enabled.
pub hpf_enabled: bool,
/// Limiter enabled.
pub limiter_enabled: bool,
/// VAD hangover in ms.
pub vad_hangover_ms: u32,
/// VAD pre-roll in ms.
pub vad_pre_roll_ms: u32,
/// Minimum transmit duration in ms.
pub vad_min_tx_ms: u32,
/// Debug WAV dump enabled.
pub debug_wav_dump_enabled: bool,
}
/// P1 audio-processing stats DTO.
#[derive(Debug, Clone)]
pub struct BridgeAudioProcessingStats {
/// Input dBFS.
pub input_dbfs: f32,
/// Render dBFS.
pub render_dbfs: f32,
/// Processed capture dBFS.
pub processed_dbfs: f32,
/// Latest VAD probability.
pub vad_probability: f32,
/// VAD active.
pub vad_active: bool,
/// Currently transmitting.
pub transmitting: bool,
/// VAD backend.
pub vad_backend: BridgeVadBackend,
/// Fallback VAD active.
pub vad_fallback_active: bool,
/// Processing backend.
pub processing_backend: BridgeAudioBackend,
/// iOS voice-processing mode.
pub ios_voice_processing_mode: BridgeIosVoiceProcessingMode,
/// Audio route.
pub audio_route: BridgeAudioRoute,
/// Actual sample rate.
pub actual_sample_rate_hz: u32,
/// Actual IO buffer frames.
pub actual_io_buffer_frames: u32,
/// Input overruns.
pub input_overruns: u64,
/// Output underruns.
pub output_underruns: u64,
/// Callback xruns.
pub callback_xruns: u64,
/// Clipped samples.
pub clipped_samples: u64,
/// Sonora enabled.
pub sonora_enabled: bool,
/// Platform voice processing enabled.
pub platform_voice_processing_enabled: bool,
}
impl From<BridgeAudioRoute> for chanora_core::AudioRoute {
fn from(route: BridgeAudioRoute) -> Self {
match route {
BridgeAudioRoute::Speaker => Self::Speaker,
BridgeAudioRoute::Earpiece => Self::Earpiece,
BridgeAudioRoute::WiredHeadset => Self::WiredHeadset,
BridgeAudioRoute::BluetoothHfp => Self::BluetoothHfp,
BridgeAudioRoute::BluetoothA2dp => Self::BluetoothA2dp,
BridgeAudioRoute::Unknown => Self::Unknown,
}
}
}
impl From<chanora_core::AudioRoute> for BridgeAudioRoute {
fn from(route: chanora_core::AudioRoute) -> Self {
match route {
chanora_core::AudioRoute::Speaker => Self::Speaker,
chanora_core::AudioRoute::Earpiece => Self::Earpiece,
chanora_core::AudioRoute::WiredHeadset => Self::WiredHeadset,
chanora_core::AudioRoute::BluetoothHfp => Self::BluetoothHfp,
chanora_core::AudioRoute::BluetoothA2dp => Self::BluetoothA2dp,
chanora_core::AudioRoute::Unknown => Self::Unknown,
}
}
}
impl From<BridgeIosVoiceProcessingMode> for chanora_core::IosVoiceProcessingMode {
fn from(mode: BridgeIosVoiceProcessingMode) -> Self {
match mode {
BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => Self::PlatformVoiceProcessing,
BridgeIosVoiceProcessingMode::SonoraExperimental => Self::SonoraExperimental,
}
}
}
impl From<chanora_core::IosVoiceProcessingMode> for BridgeIosVoiceProcessingMode {
fn from(mode: chanora_core::IosVoiceProcessingMode) -> Self {
match mode {
chanora_core::IosVoiceProcessingMode::PlatformVoiceProcessing => {
Self::PlatformVoiceProcessing
}
chanora_core::IosVoiceProcessingMode::SonoraExperimental => Self::SonoraExperimental,
}
}
}
impl From<BridgeAudioBackend> for chanora_core::AudioBackend {
fn from(backend: BridgeAudioBackend) -> Self {
match backend {
BridgeAudioBackend::PlatformVoiceProcessing => Self::PlatformVoiceProcessing,
BridgeAudioBackend::Sonora => Self::Sonora,
BridgeAudioBackend::WebrtcApm => Self::WebrtcApm,
BridgeAudioBackend::Noop => Self::Noop,
}
}
}
impl From<chanora_core::AudioBackend> for BridgeAudioBackend {
fn from(backend: chanora_core::AudioBackend) -> Self {
match backend {
chanora_core::AudioBackend::PlatformVoiceProcessing => Self::PlatformVoiceProcessing,
chanora_core::AudioBackend::Sonora => Self::Sonora,
chanora_core::AudioBackend::WebrtcApm => Self::WebrtcApm,
chanora_core::AudioBackend::Noop => Self::Noop,
}
}
}
impl From<BridgeVadBackend> for chanora_core::VadBackend {
fn from(backend: BridgeVadBackend) -> Self {
match backend {
BridgeVadBackend::SileroOnnx => Self::SileroOnnx,
BridgeVadBackend::TenVad => Self::TenVad,
BridgeVadBackend::WebrtcVad => Self::WebrtcVad,
BridgeVadBackend::EnergyDebug => Self::EnergyDebug,
BridgeVadBackend::Disabled => Self::Disabled,
}
}
}
impl From<chanora_core::VadBackend> for BridgeVadBackend {
fn from(backend: chanora_core::VadBackend) -> Self {
match backend {
chanora_core::VadBackend::SileroOnnx => Self::SileroOnnx,
chanora_core::VadBackend::TenVad => Self::TenVad,
chanora_core::VadBackend::WebrtcVad => Self::WebrtcVad,
chanora_core::VadBackend::EnergyDebug => Self::EnergyDebug,
chanora_core::VadBackend::Disabled => Self::Disabled,
}
}
}
impl From<BridgeEffectOwner> for chanora_core::EffectOwner {
fn from(owner: BridgeEffectOwner) -> Self {
match owner {
BridgeEffectOwner::Platform => Self::Platform,
BridgeEffectOwner::Sonora => Self::Sonora,
BridgeEffectOwner::WebrtcApm => Self::WebrtcApm,
BridgeEffectOwner::Conservative => Self::Conservative,
BridgeEffectOwner::Off => Self::Off,
}
}
}
impl From<chanora_core::EffectOwner> for BridgeEffectOwner {
fn from(owner: chanora_core::EffectOwner) -> Self {
match owner {
chanora_core::EffectOwner::Platform => Self::Platform,
chanora_core::EffectOwner::Sonora => Self::Sonora,
chanora_core::EffectOwner::WebrtcApm => Self::WebrtcApm,
chanora_core::EffectOwner::Conservative => Self::Conservative,
chanora_core::EffectOwner::Off => Self::Off,
}
}
}
impl From<BridgeAudioProcessingConfig> for chanora_core::AudioProcessingConfig {
fn from(config: BridgeAudioProcessingConfig) -> Self {
Self {
route: config.route.into(),
ios_mode: config.ios_mode.into(),
processing_backend: config.processing_backend.into(),
vad_backend: config.vad_backend.into(),
aec: config.aec.into(),
ns: config.ns.into(),
agc: config.agc.into(),
hpf_enabled: config.hpf_enabled,
limiter_enabled: config.limiter_enabled,
vad_hangover_ms: config.vad_hangover_ms,
vad_pre_roll_ms: config.vad_pre_roll_ms,
vad_min_tx_ms: config.vad_min_tx_ms,
debug_wav_dump_enabled: config.debug_wav_dump_enabled,
}
}
}
impl From<chanora_core::AudioProcessingConfig> for BridgeAudioProcessingConfig {
fn from(c: chanora_core::AudioProcessingConfig) -> Self {
Self {
route: c.route.into(),
ios_mode: c.ios_mode.into(),
processing_backend: c.processing_backend.into(),
vad_backend: c.vad_backend.into(),
aec: c.aec.into(),
ns: c.ns.into(),
agc: c.agc.into(),
hpf_enabled: c.hpf_enabled,
limiter_enabled: c.limiter_enabled,
vad_hangover_ms: c.vad_hangover_ms,
vad_pre_roll_ms: c.vad_pre_roll_ms,
vad_min_tx_ms: c.vad_min_tx_ms,
debug_wav_dump_enabled: c.debug_wav_dump_enabled,
}
}
}
impl From<chanora_core::AudioProcessingStats> for BridgeAudioProcessingStats {
fn from(stats: chanora_core::AudioProcessingStats) -> Self {
Self {
input_dbfs: stats.input_dbfs,
render_dbfs: stats.render_dbfs,
processed_dbfs: stats.processed_dbfs,
vad_probability: stats.vad_probability,
vad_active: stats.vad_active,
transmitting: stats.transmitting,
vad_backend: stats.vad_backend.into(),
vad_fallback_active: stats.vad_fallback_active,
processing_backend: stats.processing_backend.into(),
ios_voice_processing_mode: stats.ios_voice_processing_mode.into(),
audio_route: stats.audio_route.into(),
actual_sample_rate_hz: stats.actual_sample_rate_hz,
actual_io_buffer_frames: stats.actual_io_buffer_frames,
input_overruns: stats.input_overruns,
output_underruns: stats.output_underruns,
callback_xruns: stats.callback_xruns,
clipped_samples: stats.clipped_samples,
sonora_enabled: stats.sonora_enabled,
platform_voice_processing_enabled: stats.platform_voice_processing_enabled,
}
}
}
// ---------- Diagnostics (A.3) ----------
/// User-initiated diagnostic export. Returns a multi-line text
@@ -1335,3 +1678,95 @@ pub async fn audio_stats() -> Result<BridgeAudioStats, BridgeError> {
ptt_active: p,
})
}
/// Apply the P1 audio-processing config.
pub async fn set_audio_processing_config(
config: BridgeAudioProcessingConfig,
) -> Result<(), BridgeError> {
runtime()
.spawn(async move { session().set_audio_processing_config(config.into()).await })
.await
.map_err(|e| task_join_error("set_audio_processing_config", e))??;
Ok(())
}
/// Read the current audio-processing config.
///
/// Returns the live config as last applied to the audio engine.
/// Returns a default config when no session is active.
pub async fn get_audio_processing_config() -> Result<BridgeAudioProcessingConfig, BridgeError> {
let config = runtime()
.spawn(async { session().get_audio_processing_config().await })
.await
.map_err(|e| task_join_error("get_audio_processing_config", e))??;
Ok(config.into())
}
/// Read P1 audio-processing diagnostics.
pub async fn audio_processing_stats() -> Result<BridgeAudioProcessingStats, BridgeError> {
let stats = runtime()
.spawn(async { session().audio_processing_stats().await })
.await
.map_err(|e| task_join_error("audio_processing_stats", e))??;
Ok(stats.into())
}
/// Configure the VAD model path.
pub async fn set_vad_model_path(path: String) -> Result<(), BridgeError> {
if path.trim().is_empty() {
return Err(BridgeError::InvalidCommand(
"vad model path must not be empty".to_string(),
));
}
runtime()
.spawn(async move { session().set_vad_model_path(path).await })
.await
.map_err(|e| task_join_error("set_vad_model_path", e))??;
Ok(())
}
/// Enable or disable audio debug WAV dumping.
pub async fn enable_audio_debug_wav_dump(enabled: bool) -> Result<(), BridgeError> {
runtime()
.spawn(async move { session().set_audio_debug_wav_dump(enabled).await })
.await
.map_err(|e| task_join_error("enable_audio_debug_wav_dump", e))?
.map_err(|e| BridgeError::Unmapped(format!("enable_audio_debug_wav_dump: {e}")))?;
Ok(())
}
/// Select the iOS voice-processing mode.
pub async fn set_ios_voice_processing_mode(
mode: BridgeIosVoiceProcessingMode,
) -> Result<(), BridgeError> {
let config = BridgeAudioProcessingConfig {
route: BridgeAudioRoute::Speaker,
ios_mode: mode,
processing_backend: match mode {
BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => {
BridgeAudioBackend::PlatformVoiceProcessing
}
BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeAudioBackend::Sonora,
},
vad_backend: BridgeVadBackend::SileroOnnx,
aec: match mode {
BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => BridgeEffectOwner::Platform,
BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeEffectOwner::Sonora,
},
ns: match mode {
BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => BridgeEffectOwner::Platform,
BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeEffectOwner::Sonora,
},
agc: match mode {
BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => BridgeEffectOwner::Platform,
BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeEffectOwner::Sonora,
},
hpf_enabled: true,
limiter_enabled: true,
vad_hangover_ms: 500,
vad_pre_roll_ms: 160,
vad_min_tx_ms: 200,
debug_wav_dump_enabled: false,
};
set_audio_processing_config(config).await
}