feat: Android Oboe voice backend — WebRTC APM, VAD, HW/SW toggle, BBCode welcome, link trust, foreground task

Audio engine (Rust):
- Android Oboe: WebRTC APM (AEC/NS/AGC/HPF) + TEN/Silero ONNX VAD
- Hardware effects (JNI) with software fallback per-effect
- Render reference buffer for AEC between output/capture callbacks
- Voice activity gate: suppress transmission when speaker muted (all platforms)
- Audio focus (SDD-109) + Bluetooth SCO (SDD-110) via JNI
- ONNX Runtime 1.26 via ort 2.0.0-rc.12 (down from rc.10, ndarray 0.17)
- VAD worker channel capacity 8→32, initial seq u64::MAX (warm-up fix)
- TEN VAD default backend (was Silero)
- Platform→WebrtcApm resolution after hardware binding
- oboe-rs edisonjwa fork with get_raw_session_id()

Android Kotlin:
- AndroidAudioFocusController + AndroidBluetoothScoController
- AndroidAudioLifecycleController (route changes to Flutter)
- ProGuard rules for new controllers

Flutter UI:
- VoiceSettings: Android HW/SW toggle (Platform auto / WebRTC APM)
- VoiceStatusChip: mute warning border + Speaker muted label
- BBCode welcome message parser (BbCodeText, case-insensitive)
- Welcome message foldable (expanded by default)
- Link trust dialog (domain wildcards, SharedPreferences)
- HapticFeedback on voice sheet opener
- Server name in AppBar, version v0.1.0
- Default channel (id=1) visible, serverquery clients hidden
- flutter_foreground_task integration

Config:
- ort load-dynamic on all non-iOS (Android/Linux/Windows)
- ONNX Runtime AAR 1.26.0
- ndarray moved to common deps (was Apple-only)
This commit is contained in:
Edison Jwa
2026-05-22 09:29:57 +09:00
parent 6af4ecab0f
commit bf284018e6
37 changed files with 3676 additions and 703 deletions
@@ -4,10 +4,12 @@ pub mod dsp;
pub mod noop;
pub mod platform;
pub mod sonora;
pub mod webrtc_apm;
pub use noop::NoopProcessor;
pub use platform::PlatformVoiceProcessor;
pub use sonora::SonoraProcessor;
pub use webrtc_apm::WebRtcApmProcessor;
/// 10 ms mono f32 processing frame at 48 kHz (480 samples).
pub const FRAME_SAMPLES: usize = 480;
@@ -0,0 +1,193 @@
//! WebRTC Audio Processing Module backend.
//!
//! This backend delegates capture-side voice processing to WebRTC APM
//! instead of Chanora's experimental Rust DSP chain. Frames are 10 ms,
//! mono, 48 kHz f32, matching the rest of the voice pipeline.
use sonora::config::{
AdaptiveDigital, EchoCanceller, GainController2, HighPassFilter, NoiseSuppression,
NoiseSuppressionLevel, Pipeline,
};
use sonora::{AudioProcessing, Config, StreamConfig};
use super::{AudioProcessor, FRAME_SAMPLES};
/// Per-module WebRTC APM enable flags.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WebRtcApmConfig {
/// Acoustic echo cancellation.
pub aec: bool,
/// Automatic gain control.
pub agc: bool,
/// High-pass filter.
pub hpf: bool,
/// Noise suppression.
pub ns: bool,
/// WebRTC VAD is available for transmit gating.
pub vad: bool,
}
impl WebRtcApmConfig {
/// Resolve WebRTC APM flags from the shared audio-processing config.
pub fn from_audio_config(config: &crate::AudioProcessingConfig) -> Self {
Self {
aec: config.aec == crate::EffectOwner::WebrtcApm,
agc: matches!(
config.agc,
crate::EffectOwner::WebrtcApm | crate::EffectOwner::Conservative
),
hpf: config.hpf_enabled,
ns: matches!(
config.ns,
crate::EffectOwner::WebrtcApm | crate::EffectOwner::Conservative
),
vad: config.vad_backend == crate::VadBackend::WebrtcVad,
}
}
fn to_webrtc_config(self) -> Config {
Config {
pipeline: Pipeline {
maximum_internal_processing_rate: sonora::config::MaxProcessingRate::Rate48kHz,
..Default::default()
},
high_pass_filter: self.hpf.then_some(HighPassFilter::default()),
echo_canceller: self.aec.then_some(EchoCanceller::default()),
noise_suppression: self.ns.then_some(NoiseSuppression {
level: NoiseSuppressionLevel::Moderate,
analyze_linear_aec_output_when_available: false,
}),
gain_controller2: self.agc.then_some(GainController2 {
input_volume_controller: false,
adaptive_digital: Some(AdaptiveDigital::default()),
fixed_digital: Default::default(),
}),
..Default::default()
}
}
/// Stable runtime-log summary.
pub fn summary(self) -> String {
format!(
"aec={} agc={} hpf={} ns={} vad={}",
self.aec, self.agc, self.hpf, self.ns, self.vad
)
}
}
impl Default for WebRtcApmConfig {
fn default() -> Self {
Self {
aec: true,
agc: true,
hpf: true,
ns: true,
vad: true,
}
}
}
/// WebRTC APM processor with preallocated render scratch.
pub struct WebRtcApmProcessor {
processor: AudioProcessing,
config: WebRtcApmConfig,
capture_scratch: [f32; FRAME_SAMPLES],
render_scratch: [f32; FRAME_SAMPLES],
}
impl WebRtcApmProcessor {
/// Construct with a specific module configuration.
pub fn with_config(config: WebRtcApmConfig) -> Result<Self, crate::AudioError> {
let stream_config = StreamConfig::new(crate::frame::SAMPLE_RATE_HZ, 1);
let processor = AudioProcessing::builder()
.config(config.to_webrtc_config())
.capture_config(stream_config)
.render_config(stream_config)
.echo_detector(config.aec)
.build();
tracing::info!(
target: "chanora_audio",
modules = %config.summary(),
"WebRTC APM processor active"
);
Ok(Self {
processor,
config,
capture_scratch: [0.0; FRAME_SAMPLES],
render_scratch: [0.0; FRAME_SAMPLES],
})
}
/// Current module configuration.
pub fn config(&self) -> WebRtcApmConfig {
self.config
}
/// Apply updated module flags outside the realtime callback.
pub fn apply_config(&mut self, config: WebRtcApmConfig) {
if config == self.config {
return;
}
self.processor.apply_config(config.to_webrtc_config());
self.config = config;
tracing::info!(
target: "chanora_audio",
modules = %config.summary(),
"WebRTC APM processor reconfigured"
);
}
}
impl AudioProcessor for WebRtcApmProcessor {
fn process_capture(&mut self, frame: &mut [f32; FRAME_SAMPLES]) {
let channels = [&frame[..]];
let mut out = [&mut self.capture_scratch[..]];
if let Err(error) = self.processor.process_capture_f32(&channels, &mut out) {
tracing::trace!(target: "chanora_audio", %error, "WebRTC APM capture frame skipped");
} else {
frame.copy_from_slice(&self.capture_scratch);
}
}
fn process_render(&mut self, frame: &[f32; FRAME_SAMPLES]) {
let channels = [&frame[..]];
let mut out = [&mut self.render_scratch[..]];
if let Err(error) = self.processor.process_render_f32(&channels, &mut out) {
tracing::trace!(target: "chanora_audio", %error, "WebRTC APM render frame skipped");
}
}
fn has_aec(&self) -> bool {
self.config.aec
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config_enables_all_modules() {
let cfg = WebRtcApmConfig::default();
assert!(cfg.aec && cfg.agc && cfg.hpf && cfg.ns && cfg.vad);
}
#[test]
fn process_silence_is_stable() {
let mut processor = WebRtcApmProcessor::with_config(WebRtcApmConfig {
aec: false,
agc: true,
hpf: true,
ns: true,
vad: true,
})
.expect("webrtc apm init");
let render = [0.0_f32; FRAME_SAMPLES];
let mut capture = [0.0_f32; FRAME_SAMPLES];
processor.process_render(&render);
processor.process_capture(&mut capture);
assert!(capture.iter().all(|s| s.is_finite()));
}
}