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:
@@ -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()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user