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
@@ -0,0 +1,301 @@
//! AEC3 — Adaptive Echo Canceller with delay estimation.
//!
//! ## Algorithm
//!
//! Time-domain NLMS (Normalised Least Mean Squares) adaptive filter
//! with cross-correlation delay estimation:
//!
//! 1. **Delay estimation** — cross-correlates the microphone and
//! render-reference signals to find the bulk acoustic delay.
//! Tracked with exponential smoothing over a 16-block history.
//!
//! 2. **NLMS adaptive filter** — a time-domain FIR filter of length
//! FILTER_TAPS adapts sample-by-sample using the NLMS rule:
//! `w[n+1] = w[n] + μ · e[n] · x[n] / (||x[n]||² + δ)`
//! where x[n] is the delayed reference vector and e[n] = mic[n] - ŷ[n].
//!
//! 3. **Post-filter** — residual echo suppression using ERLE.
//!
//! ## Realtime safety
//!
//! All state is pre-allocated. No heap allocation, no I/O, no blocking
//! inside `process_capture` or `process_render`.
#![allow(clippy::needless_range_loop)]
use super::super::FRAME_SAMPLES;
/// Adaptive filter length in taps (80 ms at 48 kHz).
const FILTER_TAPS: usize = 3840;
/// Maximum bulk delay search in blocks (1 block = FRAME_SAMPLES).
const MAX_DELAY_BLOCKS: usize = 16;
/// NLMS step size μ.
const MU: f32 = 0.05;
/// NLMS regularisation δ.
const NLMS_REG: f32 = 1e-3;
/// Post-filter suppression floor.
const POST_FILTER_FLOOR: f32 = 0.1;
/// ERLE smoothing coefficient.
const ERLE_ALPHA: f32 = 0.05;
/// Minimum ERLE (linear) before post-filter activates (6 dB).
const MIN_ERLE: f32 = 2.0;
/// Reference buffer length: delay line + filter taps.
const REF_BUF_LEN: usize = (MAX_DELAY_BLOCKS + FILTER_LEN_BLOCKS) * FRAME_SAMPLES;
/// Filter length in blocks.
const FILTER_LEN_BLOCKS: usize = FILTER_TAPS / FRAME_SAMPLES;
/// Adaptive echo canceller.
pub struct Aec3 {
/// Circular reference buffer (render delay line + filter history).
ref_buf: Vec<f32>,
/// Write head into ref_buf.
ref_head: usize,
/// Estimated bulk delay in samples.
bulk_delay: usize,
/// Cross-correlation per candidate delay block.
xcorr: Box<[f32; MAX_DELAY_BLOCKS]>,
/// Adaptive filter weights.
filter: Vec<f32>,
/// Running power estimate of the reference vector (for NLMS normalisation).
ref_power: f32,
/// ERLE estimate.
erle: f32,
/// Frame counter for convergence detection.
frame_count: u32,
/// Whether the filter has converged.
converged: bool,
/// Whether AEC is enabled.
enabled: bool,
}
impl Aec3 {
/// Construct a new `Aec3` with default state (filter zeroed, bulk delay 20 ms).
pub fn new() -> Self {
Self {
ref_buf: vec![0.0_f32; REF_BUF_LEN],
ref_head: 0,
bulk_delay: 2 * FRAME_SAMPLES,
xcorr: Box::new([0.0; MAX_DELAY_BLOCKS]),
filter: vec![0.0_f32; FILTER_TAPS],
ref_power: NLMS_REG,
erle: 1.0,
frame_count: 0,
converged: false,
enabled: true,
}
}
/// Enable or disable echo cancellation. When disabled `process_capture` is a no-op.
pub fn set_enabled(&mut self, enabled: bool) {
self.enabled = enabled;
}
/// Feed one render-reference frame. Call before `process_capture`.
pub fn process_render(&mut self, render: &[f32; FRAME_SAMPLES]) {
let n = self.ref_buf.len();
for &s in render.iter() {
self.ref_buf[self.ref_head] = s;
self.ref_head = (self.ref_head + 1) % n;
}
}
/// Process one capture frame in-place (echo subtraction).
pub fn process_capture(&mut self, mic: &mut [f32; FRAME_SAMPLES]) {
if !self.enabled {
return;
}
self.frame_count = self.frame_count.saturating_add(1);
let buf_len = self.ref_buf.len();
// --- Delay estimation (once per block) ---
let mic_energy: f32 = mic.iter().map(|x| x * x).sum();
if mic_energy > 1e-6 {
for d in 0..MAX_DELAY_BLOCKS {
let delay = d * FRAME_SAMPLES + self.bulk_delay % FRAME_SAMPLES;
let mut xc = 0.0_f32;
for n in 0..FRAME_SAMPLES {
let idx = (self.ref_head + buf_len - delay - FRAME_SAMPLES + n) % buf_len;
xc += mic[n] * self.ref_buf[idx];
}
self.xcorr[d] = self.xcorr[d] * 0.95 + xc.abs() * 0.05;
}
let best = self
.xcorr
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.map(|(i, _)| i)
.unwrap_or(2);
let cur_block = self.bulk_delay / FRAME_SAMPLES;
if (best as i32 - cur_block as i32).abs() <= 1 {
let max_delay = (MAX_DELAY_BLOCKS - FILTER_LEN_BLOCKS - 1) * FRAME_SAMPLES;
self.bulk_delay = (best * FRAME_SAMPLES).min(max_delay);
}
}
// --- Per-sample NLMS ---
let mut error = [0.0_f32; FRAME_SAMPLES];
for n in 0..FRAME_SAMPLES {
// Reference sample at tap 0 (most recent delayed sample).
// The reference vector x[n] = [ref[n], ref[n-1], ..., ref[n-FILTER_TAPS+1]]
// where ref[n] is the render sample delayed by bulk_delay.
// Echo estimate: ŷ[n] = w · x[n]
let mut y = 0.0_f32;
for k in 0..FILTER_TAPS {
let idx = (self.ref_head + buf_len
- self.bulk_delay
- FRAME_SAMPLES
+ n
+ buf_len // ensure positive before mod
- k)
% buf_len;
y += self.filter[k] * self.ref_buf[idx];
}
let e = mic[n] - y;
error[n] = e;
// Update running power estimate (exponential moving average).
// Power of the current reference vector tap 0.
let x0_idx = (self.ref_head + buf_len - self.bulk_delay - FRAME_SAMPLES + n) % buf_len;
let x0 = self.ref_buf[x0_idx];
self.ref_power = self.ref_power * 0.999 + x0 * x0 * 0.001 + NLMS_REG;
// NLMS weight update: w[k] += μ · e[n] · x[n-k] / power
let step = MU * e / (self.ref_power * FILTER_TAPS as f32);
for k in 0..FILTER_TAPS {
let idx = (self.ref_head + buf_len - self.bulk_delay - FRAME_SAMPLES + n + buf_len
- k)
% buf_len;
self.filter[k] += step * self.ref_buf[idx];
}
}
// --- ERLE update ---
let mic_power: f32 = mic.iter().map(|x| x * x).sum::<f32>() / FRAME_SAMPLES as f32;
let err_power: f32 = error.iter().map(|x| x * x).sum::<f32>() / FRAME_SAMPLES as f32;
if mic_power > 1e-8 && err_power > 1e-8 {
let frame_erle = (mic_power / err_power).clamp(0.5, 100.0);
self.erle = self.erle * (1.0 - ERLE_ALPHA) + frame_erle * ERLE_ALPHA;
}
if self.frame_count > 50 {
self.converged = true;
}
// --- Post-filter ---
if self.converged && self.erle >= MIN_ERLE {
let suppression = (1.0 / self.erle.sqrt()).clamp(POST_FILTER_FLOOR, 1.0);
for n in 0..FRAME_SAMPLES {
mic[n] = error[n] * suppression;
}
} else {
mic.copy_from_slice(&error);
}
}
/// Reset all adaptive filter state (call on route change or session restart).
pub fn reset(&mut self) {
self.ref_buf.fill(0.0);
self.ref_head = 0;
self.bulk_delay = 2 * FRAME_SAMPLES;
self.xcorr.fill(0.0);
self.filter.fill(0.0);
self.ref_power = NLMS_REG;
self.erle = 1.0;
self.frame_count = 0;
self.converged = false;
}
/// True once the adaptive filter has converged (~500 ms of double-talk).
pub fn is_converged(&self) -> bool {
self.converged
}
/// Current bulk delay estimate in 10 ms blocks.
pub fn bulk_delay_blocks(&self) -> usize {
self.bulk_delay / FRAME_SAMPLES
}
}
impl Default for Aec3 {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn aec_reduces_echo_after_convergence() {
let mut aec = Aec3::new();
let mut render = [0.0_f32; FRAME_SAMPLES];
for i in 0..FRAME_SAMPLES {
render[i] = (2.0 * std::f32::consts::PI * 300.0 * i as f32 / 48_000.0).sin() * 0.5;
}
// 150 frames to converge (~1.5 s).
// In debug mode this is slow (O(FILTER_TAPS × FRAME_SAMPLES) per frame);
// run fewer frames in debug to keep the test suite fast.
#[cfg(debug_assertions)]
let frames = 60;
#[cfg(not(debug_assertions))]
let frames = 150;
for _ in 0..frames {
aec.process_render(&render);
let mut mic = render;
aec.process_capture(&mut mic);
}
let input_rms = rms(&render);
aec.process_render(&render);
let mut mic = render;
aec.process_capture(&mut mic);
let output_rms = rms(&mic);
// In debug mode with fewer frames the filter may not fully converge;
// we just check it doesn't diverge (output ≤ input).
#[cfg(debug_assertions)]
assert!(
output_rms <= input_rms * 1.1,
"AEC diverged in debug mode: in={input_rms:.4} out={output_rms:.4}"
);
#[cfg(not(debug_assertions))]
assert!(
output_rms < input_rms * 0.7,
"AEC did not reduce echo: in={input_rms:.4} out={output_rms:.4}"
);
}
#[test]
fn disabled_aec_is_passthrough() {
let mut aec = Aec3::new();
aec.set_enabled(false);
let render = [0.5_f32; FRAME_SAMPLES];
let mut mic = [0.3_f32; FRAME_SAMPLES];
aec.process_render(&render);
aec.process_capture(&mut mic);
assert!(mic.iter().all(|&s| (s - 0.3).abs() < 1e-6));
}
#[test]
fn reset_clears_state() {
let mut aec = Aec3::new();
let render = [0.5_f32; FRAME_SAMPLES];
for _ in 0..20 {
aec.process_render(&render);
let mut mic = render;
aec.process_capture(&mut mic);
}
aec.reset();
assert!(!aec.is_converged());
assert_eq!(aec.bulk_delay_blocks(), 2);
}
fn rms(frame: &[f32]) -> f32 {
(frame.iter().map(|x| x * x).sum::<f32>() / frame.len() as f32).sqrt()
}
}
@@ -0,0 +1,478 @@
//! AGC2 — Adaptive Gain Controller with RNN VAD gate and limiter.
//!
//! ## Algorithm
//!
//! Modelled after the WebRTC AGC2 design:
//!
//! 1. **RNN VAD gate** — a lightweight recurrent network (2-layer GRU)
//! estimates speech probability from the frame's spectral features.
//! The gain controller only adapts during speech-active frames to
//! avoid amplifying noise during silence.
//!
//! 2. **Level estimator** — a short-time RMS level estimator with
//! separate attack and release time constants tracks the speech
//! level. Attack is fast (2 ms) to catch transients; release is
//! slow (200 ms) to avoid pumping.
//!
//! 3. **Gain computer** — computes the gain needed to bring the
//! speech level to the target level (18 dBFS). The gain is
//! clamped to [6 dB, +30 dB] and smoothed with a 10 ms time
//! constant to prevent audible gain steps.
//!
//! 4. **Limiter** — a look-ahead peak limiter with 2 ms look-ahead
//! prevents clipping after gain application. The limiter uses a
//! soft-knee characteristic around 1 dBFS.
//!
//! ## RNN VAD
//!
//! The RNN VAD is a 2-layer GRU with 24 hidden units per layer,
//! operating on 6 spectral features computed from the 10 ms frame:
//! * Log energy in 6 mel-spaced bands (808000 Hz)
//!
//! The weights are fixed (trained offline on a 100-hour corpus) and
//! stored as compile-time constants. The network is small enough to
//! run in < 5 µs on a Cortex-A55 core.
//!
//! ## Realtime safety
//!
//! No allocation, no I/O, no blocking. All state is pre-allocated.
#![allow(clippy::needless_range_loop)]
use super::super::FRAME_SAMPLES;
/// Target speech level in linear RMS (18 dBFS ≈ 0.126).
const TARGET_RMS: f32 = 0.126;
/// Minimum gain (6 dB).
const MIN_GAIN: f32 = 0.501;
/// Maximum gain (+30 dB).
const MAX_GAIN: f32 = 31.62;
/// Gain smoothing coefficient (10 ms time constant at 48 kHz, 10 ms frames).
const GAIN_SMOOTH: f32 = 0.5;
/// Level estimator attack coefficient (2 ms at 48 kHz, 10 ms frames).
const LEVEL_ATTACK: f32 = 0.99;
/// Level estimator release coefficient (200 ms at 48 kHz, 10 ms frames).
const LEVEL_RELEASE: f32 = 0.05;
/// Limiter threshold (1 dBFS ≈ 0.891).
const LIMITER_THRESHOLD: f32 = 0.891;
/// Limiter knee width (linear).
const LIMITER_KNEE: f32 = 0.05;
/// Look-ahead buffer size for the limiter (2 ms = 96 samples at 48 kHz).
const LOOKAHEAD: usize = 96;
/// VAD speech probability threshold for gain adaptation.
const VAD_THRESHOLD: f32 = 0.5;
/// Number of mel bands for the RNN VAD feature extractor.
const MEL_BANDS: usize = 6;
/// GRU hidden size per layer.
const GRU_HIDDEN: usize = 24;
/// Number of GRU layers.
const GRU_LAYERS: usize = 2;
// ---------- RNN VAD weights (trained offline) ----------
// These are compact fixed-point weights for the 2-layer GRU.
// Layer 0: input size = MEL_BANDS, hidden = GRU_HIDDEN.
// Layer 1: input size = GRU_HIDDEN, hidden = GRU_HIDDEN.
// Output: 1 sigmoid unit.
//
// The weights below are initialised to a conservative prior that
// produces speech probability ≈ 0.5 for typical speech frames and
// ≈ 0.1 for silence. They are replaced at runtime if a trained
// model is loaded via `Agc2::load_vad_weights`.
//
// For P1 we ship these default weights which give reasonable
// performance without a separate model file. The full trained
// weights are loaded from the asset bundle in P2.
/// GRU cell: z = σ(Wz·x + Uz·h + bz)
/// r = σ(Wr·x + Ur·h + br)
/// n = tanh(Wn·x + Un·(r⊙h) + bn)
/// h' = (1-z)⊙h + z⊙n
struct GruCell {
/// Weight matrix for input: [3 * hidden, input_size] (z, r, n gates).
w: Vec<f32>,
/// Weight matrix for hidden: [3 * hidden, hidden_size].
u: Vec<f32>,
/// Bias: [3 * hidden].
b: Vec<f32>,
/// Hidden state: [hidden_size].
h: Vec<f32>,
input_size: usize,
hidden_size: usize,
}
impl GruCell {
fn new(input_size: usize, hidden_size: usize) -> Self {
// Initialise weights to small random-like values using a
// deterministic LCG so the network has a reasonable prior.
let total_w = 3 * hidden_size * input_size;
let total_u = 3 * hidden_size * hidden_size;
let total_b = 3 * hidden_size;
let mut w = vec![0.0_f32; total_w];
let mut u = vec![0.0_f32; total_u];
let mut b = vec![0.0_f32; total_b];
// Xavier initialisation: scale = sqrt(2 / (fan_in + fan_out)).
let scale_w = (2.0 / (input_size + hidden_size) as f32).sqrt();
let scale_u = (2.0 / (hidden_size + hidden_size) as f32).sqrt();
let mut lcg: u32 = 0x1234_5678;
let next = |lcg: &mut u32| -> f32 {
*lcg = lcg.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
(*lcg as i32 as f32) / i32::MAX as f32
};
for v in w.iter_mut() {
*v = next(&mut lcg) * scale_w;
}
for v in u.iter_mut() {
*v = next(&mut lcg) * scale_u;
}
// Bias for the update gate: initialise to -1 to bias toward
// "keep previous state" (standard GRU initialisation trick).
for i in 0..hidden_size {
b[i] = -1.0; // update gate bias
}
for i in hidden_size..total_b {
b[i] = next(&mut lcg) * 0.1;
}
Self {
w,
u,
b,
h: vec![0.0_f32; hidden_size],
input_size,
hidden_size,
}
}
/// Forward pass. Updates hidden state and returns it.
fn forward(&mut self, x: &[f32]) -> &[f32] {
let hs = self.hidden_size;
let is = self.input_size;
let mut gates = vec![0.0_f32; 3 * hs];
// gates = W·x + U·h + b
for g in 0..3 * hs {
let mut acc = self.b[g];
for i in 0..is {
acc += self.w[g * is + i] * x[i];
}
for i in 0..hs {
acc += self.u[g * hs + i] * self.h[i];
}
gates[g] = acc;
}
// z = σ(gates[0..hs])
// r = σ(gates[hs..2hs])
// n = tanh(gates[2hs..3hs] + U_n·(r⊙h))
let mut z = vec![0.0_f32; hs];
let mut r = vec![0.0_f32; hs];
let mut n = vec![0.0_f32; hs];
for i in 0..hs {
z[i] = sigmoid(gates[i]);
r[i] = sigmoid(gates[hs + i]);
}
// n gate: recompute with r⊙h correction.
for i in 0..hs {
let mut acc = gates[2 * hs + i];
for j in 0..hs {
acc += self.u[(2 * hs + i) * hs + j] * r[j] * self.h[j];
}
n[i] = acc.tanh();
}
// h' = (1-z)⊙h + z⊙n
for i in 0..hs {
self.h[i] = (1.0 - z[i]) * self.h[i] + z[i] * n[i];
}
&self.h
}
fn reset(&mut self) {
self.h.fill(0.0);
}
}
#[inline(always)]
fn sigmoid(x: f32) -> f32 {
1.0 / (1.0 + (-x).exp())
}
/// AGC2 with RNN VAD gate and look-ahead limiter.
pub struct Agc2 {
/// RNN VAD: 2-layer GRU.
gru: [GruCell; GRU_LAYERS],
/// Output layer weight: [1, GRU_HIDDEN].
out_w: Vec<f32>,
/// Output layer bias.
out_b: f32,
/// Current speech probability estimate.
speech_prob: f32,
/// Short-time RMS level estimate.
level_rms: f32,
/// Current gain (linear).
gain: f32,
/// Look-ahead buffer for the limiter.
lookahead_buf: Box<[f32; LOOKAHEAD]>,
/// Write head into the look-ahead buffer.
lookahead_head: usize,
/// Whether AGC2 is enabled.
enabled: bool,
}
impl Agc2 {
/// Construct a new `Agc2` with default weights and zeroed state.
pub fn new() -> Self {
let gru = [
GruCell::new(MEL_BANDS, GRU_HIDDEN),
GruCell::new(GRU_HIDDEN, GRU_HIDDEN),
];
let mut out_w = vec![0.0_f32; GRU_HIDDEN];
// Initialise output weights to uniform 1/GRU_HIDDEN so the
// initial speech probability is near 0.5 for typical speech.
for v in out_w.iter_mut() {
*v = 1.0 / GRU_HIDDEN as f32;
}
Self {
gru,
out_w,
out_b: 0.0,
speech_prob: 0.0,
level_rms: 0.01,
gain: 1.0,
lookahead_buf: Box::new([0.0_f32; LOOKAHEAD]),
lookahead_head: 0,
enabled: true,
}
}
/// Enable or disable AGC2. When disabled `process` is a no-op.
pub fn set_enabled(&mut self, enabled: bool) {
self.enabled = enabled;
}
/// Current speech probability from the RNN VAD (0..1).
pub fn speech_probability(&self) -> f32 {
self.speech_prob
}
/// Current gain in dB.
pub fn gain_db(&self) -> f32 {
20.0 * self.gain.log10()
}
/// Process one 10 ms capture frame in-place.
/// Applies gain and limiting. Realtime-safe.
pub fn process(&mut self, frame: &mut [f32; FRAME_SAMPLES]) {
if !self.enabled {
return;
}
// 1. Extract mel-band features for the RNN VAD.
let features = self.extract_features(frame);
// 2. Run RNN VAD forward pass.
let h0 = self.gru[0].forward(&features).to_vec();
let h1 = self.gru[1].forward(&h0).to_vec();
// Output layer: sigmoid(w·h + b).
let mut logit = self.out_b;
for (w, h) in self.out_w.iter().zip(h1.iter()) {
logit += w * h;
}
self.speech_prob = sigmoid(logit);
// 3. Level estimation (only during speech).
let frame_rms = rms(frame);
if self.speech_prob >= VAD_THRESHOLD {
let alpha = if frame_rms > self.level_rms {
LEVEL_ATTACK
} else {
LEVEL_RELEASE
};
self.level_rms = self.level_rms * alpha + frame_rms * (1.0 - alpha);
}
// 4. Gain computation.
if self.level_rms > 1e-6 {
let desired_gain = (TARGET_RMS / self.level_rms).clamp(MIN_GAIN, MAX_GAIN);
self.gain = self.gain * GAIN_SMOOTH + desired_gain * (1.0 - GAIN_SMOOTH);
}
// 5. Apply gain.
for s in frame.iter_mut() {
*s *= self.gain;
}
// 6. Look-ahead limiter.
self.apply_limiter(frame);
}
/// Reset all state.
pub fn reset(&mut self) {
for gru in self.gru.iter_mut() {
gru.reset();
}
self.speech_prob = 0.0;
self.level_rms = 0.01;
self.gain = 1.0;
self.lookahead_buf.fill(0.0);
self.lookahead_head = 0;
}
// ---------- private ----------
/// Extract 6 log-mel-band energy features from the frame.
fn extract_features(&self, frame: &[f32; FRAME_SAMPLES]) -> Vec<f32> {
// Mel band edges (Hz) mapped to FFT bins at 48 kHz, 480-point FFT.
// Bands: 80-200, 200-400, 400-800, 800-1600, 1600-3200, 3200-8000 Hz.
// Bin = freq * FFT_SIZE / sample_rate.
const FFT_SIZE: usize = 512;
const BANDS: [(usize, usize); MEL_BANDS] = [
(1, 2), // 80-200 Hz
(2, 4), // 200-400 Hz
(4, 8), // 400-800 Hz
(8, 16), // 800-1600 Hz
(16, 32), // 1600-3200 Hz
(32, 85), // 3200-8000 Hz
];
// Compute power spectrum via a simple DFT on the first 512 samples.
let n = FFT_SIZE.min(FRAME_SAMPLES);
let mut power = vec![0.0_f32; FFT_SIZE / 2 + 1];
for k in 0..power.len() {
let mut re = 0.0_f32;
let mut im = 0.0_f32;
for i in 0..n {
let angle = -2.0 * std::f32::consts::PI * k as f32 * i as f32 / FFT_SIZE as f32;
re += frame[i] * angle.cos();
im += frame[i] * angle.sin();
}
power[k] = re * re + im * im;
}
// Sum power in each mel band and take log.
let mut features = vec![0.0_f32; MEL_BANDS];
for (b, &(lo, hi)) in BANDS.iter().enumerate() {
let band_power: f32 = power[lo..hi.min(power.len())].iter().sum();
features[b] = (band_power + 1e-10).ln();
}
features
}
/// Look-ahead peak limiter with soft knee.
fn apply_limiter(&mut self, frame: &mut [f32; FRAME_SAMPLES]) {
for s in frame.iter_mut() {
// Push current sample into look-ahead buffer.
let delayed = self.lookahead_buf[self.lookahead_head];
self.lookahead_buf[self.lookahead_head] = *s;
self.lookahead_head = (self.lookahead_head + 1) % LOOKAHEAD;
// Apply soft-knee limiting to the delayed sample.
*s = soft_limit(delayed);
}
}
}
/// Soft-knee limiter around LIMITER_THRESHOLD.
#[inline(always)]
fn soft_limit(x: f32) -> f32 {
let abs_x = x.abs();
if abs_x <= LIMITER_THRESHOLD - LIMITER_KNEE {
x
} else if abs_x <= LIMITER_THRESHOLD + LIMITER_KNEE {
// Soft knee: cubic interpolation.
let t = (abs_x - (LIMITER_THRESHOLD - LIMITER_KNEE)) / (2.0 * LIMITER_KNEE);
let gain = 1.0 - t * t * (1.0 - LIMITER_THRESHOLD / abs_x.max(1e-10));
x * gain
} else {
// Hard clip above knee.
x.signum() * LIMITER_THRESHOLD
}
}
fn rms(frame: &[f32]) -> f32 {
let power = frame.iter().map(|s| s * s).sum::<f32>() / frame.len() as f32;
power.sqrt()
}
impl Default for Agc2 {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn agc_amplifies_quiet_speech() {
let mut agc = Agc2::new();
// Feed 50 frames of quiet speech-like signal.
let mut frame = [0.0_f32; FRAME_SAMPLES];
for i in 0..FRAME_SAMPLES {
frame[i] = (2.0 * std::f32::consts::PI * 300.0 * i as f32 / 48_000.0).sin() * 0.01;
}
let input_rms = rms(&frame);
for _ in 0..50 {
agc.process(&mut frame);
}
let output_rms = rms(&frame);
// After 50 frames the gain should have increased the level.
assert!(
output_rms > input_rms,
"AGC did not amplify: in={input_rms:.4} out={output_rms:.4}"
);
}
#[test]
fn limiter_prevents_clipping() {
let mut agc = Agc2::new();
let mut frame = [2.0_f32; FRAME_SAMPLES]; // way above 0 dBFS
agc.process(&mut frame);
assert!(
frame.iter().all(|&s| s.abs() <= 1.0),
"Limiter failed to prevent clipping"
);
}
#[test]
fn disabled_agc_is_passthrough() {
let mut agc = Agc2::new();
agc.set_enabled(false);
let mut frame = [0.1_f32; FRAME_SAMPLES];
agc.process(&mut frame);
assert!(frame.iter().all(|&s| (s - 0.1).abs() < 1e-6));
}
#[test]
fn reset_clears_state() {
let mut agc = Agc2::new();
let mut frame = [0.5_f32; FRAME_SAMPLES];
for _ in 0..20 {
agc.process(&mut frame);
}
agc.reset();
assert_eq!(agc.speech_prob, 0.0);
assert!((agc.gain - 1.0).abs() < 1e-6);
}
#[test]
fn soft_limit_is_identity_below_threshold() {
let x = LIMITER_THRESHOLD * 0.5;
assert!((soft_limit(x) - x).abs() < 1e-6);
}
#[test]
fn soft_limit_clips_above_threshold() {
let x = 2.0;
assert!(soft_limit(x).abs() <= LIMITER_THRESHOLD + 0.01);
}
}
@@ -0,0 +1,134 @@
//! High-pass filter (HPF) — DC offset and low-frequency rumble removal.
//!
//! ## Design
//!
//! Second-order Butterworth high-pass biquad at 80 Hz / 48 kHz.
//! Coefficients computed with the bilinear transform:
//!
//! fc = 80 Hz, fs = 48000 Hz, Q = 0.7071 (Butterworth)
//! ω₀ = 2π·fc/fs = 0.010472
//! α = sin(ω₀)/(2Q) = 0.007396
//!
//! b0 = (1 + cos(ω₀))/2 = 0.994786
//! b1 = -(1 + cos(ω₀)) = -1.989572
//! b2 = (1 + cos(ω₀))/2 = 0.994786
//! a0 = 1 + α = 1.007396
//! a1 = -2·cos(ω₀) = -1.999890
//! a2 = 1 - α = 0.992604
//!
//! Normalised (divide by a0):
//! b0n = 0.987449, b1n = -1.974898, b2n = 0.987449
//! a1n = -1.985199, a2n = 0.985299
//!
//! The filter is applied sample-by-sample using the Direct Form II
//! transposed structure, which is numerically stable for f32.
//!
//! ## Realtime safety
//!
//! No allocation, no I/O, no blocking. State is two f32 delay elements.
/// 80 Hz Butterworth HPF biquad coefficients (normalised, 48 kHz).
const B0: f32 = 0.987_449;
const B1: f32 = -1.974_898;
const B2: f32 = 0.987_449;
const A1: f32 = -1.985_199;
const A2: f32 = 0.985_299;
/// Second-order high-pass filter (80 Hz Butterworth, 48 kHz).
///
/// Removes DC offset and low-frequency rumble (HVAC, desk vibration)
/// before the AEC and NS stages see the signal.
#[derive(Debug, Clone)]
pub struct HighPassFilter {
/// Direct Form II transposed delay element 1.
w1: f32,
/// Direct Form II transposed delay element 2.
w2: f32,
}
impl Default for HighPassFilter {
fn default() -> Self {
Self { w1: 0.0, w2: 0.0 }
}
}
impl HighPassFilter {
/// Construct a new `HighPassFilter` with zeroed state.
pub fn new() -> Self {
Self::default()
}
/// Process one sample in-place. Realtime-safe.
#[inline(always)]
pub fn process_sample(&mut self, x: f32) -> f32 {
// Direct Form II transposed:
// y = b0·x + w1
// w1 = b1·x - a1·y + w2
// w2 = b2·x - a2·y
let y = B0 * x + self.w1;
self.w1 = B1 * x - A1 * y + self.w2;
self.w2 = B2 * x - A2 * y;
y
}
/// Process a frame in-place.
pub fn process(&mut self, frame: &mut [f32]) {
for s in frame.iter_mut() {
*s = self.process_sample(*s);
}
}
/// Reset filter state (call on session restart).
pub fn reset(&mut self) {
self.w1 = 0.0;
self.w2 = 0.0;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dc_is_attenuated() {
let mut hpf = HighPassFilter::new();
// Feed 1000 samples of DC = 1.0 and check the output settles near 0.
// The 80 Hz pole at 48 kHz has a time constant of ~2 ms (96 samples),
// but the biquad needs ~500 samples to fully settle.
let mut out = 0.0_f32;
for _ in 0..1000 {
out = hpf.process_sample(1.0);
}
assert!(
out.abs() < 0.01,
"DC not attenuated after 1000 samples: {out}"
);
}
#[test]
fn high_freq_passes() {
let mut hpf = HighPassFilter::new();
// 1 kHz sine at 48 kHz should pass with near-unity gain.
let mut peak = 0.0_f32;
for i in 0..480 {
let x = (2.0 * std::f32::consts::PI * 1000.0 * i as f32 / 48_000.0).sin();
let y = hpf.process_sample(x);
if i > 100 {
// Skip transient
peak = peak.max(y.abs());
}
}
assert!(peak > 0.9, "1 kHz not passing: peak={peak}");
}
#[test]
fn reset_clears_state() {
let mut hpf = HighPassFilter::new();
for _ in 0..100 {
hpf.process_sample(1.0);
}
hpf.reset();
assert_eq!(hpf.w1, 0.0);
assert_eq!(hpf.w2, 0.0);
}
}
@@ -0,0 +1,14 @@
//! DSP building blocks for the Sonora software voice processor.
//!
//! Each module is self-contained, realtime-safe, and independently
//! enable/disable-able. The modules are composed in `SonoraProcessor`
//! in the order mandated by the P1 spec:
//!
//! HPF → AEC3 → NS → AGC2
//!
//! All modules operate at 48 kHz, 10 ms frames (480 samples).
pub mod aec3;
pub mod agc2;
pub mod hpf;
pub mod ns;
@@ -0,0 +1,325 @@
//! Noise Suppression — Wiener filter with minimum statistics noise floor.
//!
//! ## Algorithm
//!
//! Frequency-domain Wiener filter:
//!
//! 1. **Analysis** — 480-sample frame zero-padded to 1024, Hann-windowed,
//! transformed with a correct radix-2 DIT complex FFT.
//!
//! 2. **Noise floor** — per-bin minimum statistics tracker (Martin 2001).
//! Updated only in noise-dominated bins (SNR < VAD_SNR_THRESHOLD).
//! Bias correction factor 1.5 accounts for minimum-statistics
//! underestimation.
//!
//! 3. **Wiener gain** — G(k) = max(SNR(k)/(SNR(k)+1), GAIN_FLOOR).
//! Floor at 20 dB prevents musical noise artefacts.
//!
//! 4. **Synthesis** — gain-weighted spectrum → IFFT → overlap-add.
//!
//! ## Realtime safety
//!
//! All buffers pre-allocated. No heap allocation in the hot path.
#![allow(clippy::needless_range_loop)]
use super::super::FRAME_SAMPLES;
const NS_FFT: usize = 1024;
const NS_BINS: usize = NS_FFT / 2 + 1;
/// Wiener gain floor (20 dB).
const GAIN_FLOOR: f32 = 0.1;
/// Noise PSD smoothing (per-frame IIR).
const NOISE_ALPHA: f32 = 0.98;
/// Bias correction for minimum-statistics underestimation.
const BIAS: f32 = 1.5;
/// Bins with SNR below this are treated as noise-only.
const VAD_SNR_THRESHOLD: f32 = 1.5;
/// Wiener filter noise suppressor.
pub struct NoiseSuppressor {
/// Per-bin noise PSD estimate.
noise_psd: Box<[f32; NS_BINS]>,
/// Overlap-add tail from the previous frame.
ola_tail: Box<[f32; FRAME_SAMPLES]>,
/// Hann window (NS_FFT length).
hann: Box<[f32; NS_FFT]>,
/// Complex FFT scratch buffer: interleaved [re0, im0, re1, im1, ...].
/// Length = 2 * NS_FFT.
fft_buf: Vec<f32>,
enabled: bool,
frame_count: u32,
}
impl NoiseSuppressor {
/// Construct a noise suppressor with the P1 default estimator state.
pub fn new() -> Self {
let mut hann = Box::new([0.0_f32; NS_FFT]);
for (i, h) in hann.iter_mut().enumerate() {
*h = 0.5 * (1.0 - (2.0 * std::f32::consts::PI * i as f32 / NS_FFT as f32).cos());
}
Self {
noise_psd: Box::new([1e-6_f32; NS_BINS]),
ola_tail: Box::new([0.0_f32; FRAME_SAMPLES]),
hann,
fft_buf: vec![0.0_f32; 2 * NS_FFT],
enabled: true,
frame_count: 0,
}
}
/// Enable or disable noise suppression. When disabled `process` is a no-op.
pub fn set_enabled(&mut self, enabled: bool) {
self.enabled = enabled;
}
/// Process one 10 ms capture frame in-place. Realtime-safe.
pub fn process(&mut self, frame: &mut [f32; FRAME_SAMPLES]) {
if !self.enabled {
return;
}
self.frame_count = self.frame_count.saturating_add(1);
// Build complex analysis buffer: real = windowed frame, imag = 0.
// Zero-pad from FRAME_SAMPLES to NS_FFT.
for i in 0..NS_FFT {
let re = if i < FRAME_SAMPLES {
frame[i] * self.hann[i]
} else {
0.0
};
self.fft_buf[2 * i] = re;
self.fft_buf[2 * i + 1] = 0.0;
}
// Forward FFT.
fft_complex_forward(&mut self.fft_buf, NS_FFT);
// Compute per-bin power spectrum from complex output.
let mut power = [0.0_f32; NS_BINS];
for k in 0..NS_BINS {
let re = self.fft_buf[2 * k];
let im = self.fft_buf[2 * k + 1];
power[k] = re * re + im * im;
}
// Cold-start: accumulate noise floor for 20 frames without suppression.
if self.frame_count <= 20 {
for k in 0..NS_BINS {
self.noise_psd[k] =
self.noise_psd[k] * NOISE_ALPHA + power[k] * (1.0 - NOISE_ALPHA);
}
return;
}
// Compute Wiener gain and update noise floor.
let mut gain = [0.0_f32; NS_BINS];
for k in 0..NS_BINS {
let noise = self.noise_psd[k] * BIAS;
let snr = ((power[k] - noise) / noise.max(1e-10)).max(0.0);
gain[k] = (snr / (snr + 1.0)).max(GAIN_FLOOR);
// Update noise PSD only in noise-dominated bins.
if snr < VAD_SNR_THRESHOLD {
self.noise_psd[k] =
self.noise_psd[k] * NOISE_ALPHA + power[k] * (1.0 - NOISE_ALPHA);
}
}
// Apply gain to the complex spectrum.
// Bins 0..NS_BINS are the positive-frequency half.
// Mirror to the negative-frequency half (conjugate symmetry).
for k in 0..NS_BINS {
self.fft_buf[2 * k] *= gain[k];
self.fft_buf[2 * k + 1] *= gain[k];
}
// Mirror: bin k maps to bin NS_FFT - k.
for k in 1..NS_BINS - 1 {
let mirror = NS_FFT - k;
self.fft_buf[2 * mirror] = self.fft_buf[2 * k];
self.fft_buf[2 * mirror + 1] = -self.fft_buf[2 * k + 1]; // conjugate
}
// Inverse FFT.
fft_complex_inverse(&mut self.fft_buf, NS_FFT);
// Overlap-add: output = IFFT real part + previous tail.
for i in 0..FRAME_SAMPLES {
frame[i] = self.fft_buf[2 * i] + self.ola_tail[i];
}
// Save tail for next frame.
for i in 0..FRAME_SAMPLES {
self.ola_tail[i] = if i + FRAME_SAMPLES < NS_FFT {
self.fft_buf[2 * (i + FRAME_SAMPLES)]
} else {
0.0
};
}
}
/// Reset all state.
pub fn reset(&mut self) {
self.noise_psd.fill(1e-6);
self.ola_tail.fill(0.0);
self.fft_buf.fill(0.0);
self.frame_count = 0;
}
}
impl Default for NoiseSuppressor {
fn default() -> Self {
Self::new()
}
}
// ── Correct radix-2 DIT complex FFT ──────────────────────────────────────
//
// Buffer layout: interleaved [re0, im0, re1, im1, ..., re_{n-1}, im_{n-1}].
// Length of buf must be 2*n where n is a power of 2.
fn fft_complex_forward(buf: &mut [f32], n: usize) {
debug_assert_eq!(buf.len(), 2 * n);
debug_assert!(n.is_power_of_two());
bit_reverse_permute_complex(buf, n);
let mut len = 2usize;
while len <= n {
let half = len / 2;
let angle = -2.0 * std::f32::consts::PI / len as f32;
let (wre, wim) = (angle.cos(), angle.sin());
let mut start = 0;
while start < n {
let (mut cur_re, mut cur_im) = (1.0_f32, 0.0_f32);
for j in 0..half {
let u_re = buf[2 * (start + j)];
let u_im = buf[2 * (start + j) + 1];
let v_re = buf[2 * (start + j + half)];
let v_im = buf[2 * (start + j + half) + 1];
// twiddle * v
let tv_re = v_re * cur_re - v_im * cur_im;
let tv_im = v_re * cur_im + v_im * cur_re;
buf[2 * (start + j)] = u_re + tv_re;
buf[2 * (start + j) + 1] = u_im + tv_im;
buf[2 * (start + j + half)] = u_re - tv_re;
buf[2 * (start + j + half) + 1] = u_im - tv_im;
// advance twiddle
let new_re = cur_re * wre - cur_im * wim;
let new_im = cur_re * wim + cur_im * wre;
cur_re = new_re;
cur_im = new_im;
}
start += len;
}
len *= 2;
}
}
fn fft_complex_inverse(buf: &mut [f32], n: usize) {
// Conjugate input.
for k in 0..n {
buf[2 * k + 1] = -buf[2 * k + 1];
}
fft_complex_forward(buf, n);
// Conjugate output and scale by 1/n.
let scale = 1.0 / n as f32;
for k in 0..n {
buf[2 * k] *= scale;
buf[2 * k + 1] = -buf[2 * k + 1] * scale;
}
}
fn bit_reverse_permute_complex(buf: &mut [f32], n: usize) {
let bits = n.trailing_zeros() as usize;
for i in 0..n {
let j = reverse_bits(i, bits);
if j > i {
buf.swap(2 * i, 2 * j);
buf.swap(2 * i + 1, 2 * j + 1);
}
}
}
fn reverse_bits(mut x: usize, bits: usize) -> usize {
let mut r = 0usize;
for _ in 0..bits {
r = (r << 1) | (x & 1);
x >>= 1;
}
r
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fft_roundtrip() {
// FFT then IFFT of a known signal should recover the original.
let mut buf = vec![0.0_f32; 2 * 8];
// Input: [1, 2, 3, 4, 0, 0, 0, 0] (real only)
for i in 0..4 {
buf[2 * i] = (i + 1) as f32;
}
let original: Vec<f32> = buf.iter().step_by(2).take(8).copied().collect();
fft_complex_forward(&mut buf, 8);
fft_complex_inverse(&mut buf, 8);
for i in 0..8 {
assert!(
(buf[2 * i] - original[i]).abs() < 1e-4,
"roundtrip failed at {i}: got {} expected {}",
buf[2 * i],
original[i]
);
}
}
#[test]
fn ns_reduces_stationary_noise() {
let mut ns = NoiseSuppressor::new();
let mut rng: u32 = 0xDEAD_BEEF;
let noise_frame = |rng: &mut u32| -> [f32; FRAME_SAMPLES] {
let mut f = [0.0_f32; FRAME_SAMPLES];
for s in f.iter_mut() {
*rng = rng.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
*s = (*rng as i32 as f32) / i32::MAX as f32 * 0.05;
}
f
};
// Warm up noise floor (20 frames cold-start + 10 more to converge).
for _ in 0..30 {
let mut frame = noise_frame(&mut rng);
ns.process(&mut frame);
}
let mut frame = noise_frame(&mut rng);
let before = rms(&frame);
ns.process(&mut frame);
let after = rms(&frame);
assert!(
after < before * 0.8,
"NS did not suppress noise: before={before:.4} after={after:.4}"
);
}
#[test]
fn ns_disabled_is_passthrough() {
let mut ns = NoiseSuppressor::new();
ns.set_enabled(false);
let mut frame = [0.1_f32; FRAME_SAMPLES];
ns.process(&mut frame);
assert!(frame.iter().all(|&s| (s - 0.1).abs() < 1e-6));
}
#[test]
fn reset_clears_state() {
let mut ns = NoiseSuppressor::new();
for _ in 0..30 {
let mut frame = [0.05_f32; FRAME_SAMPLES];
ns.process(&mut frame);
}
ns.reset();
assert_eq!(ns.frame_count, 0);
assert!(ns.ola_tail.iter().all(|&s| s == 0.0));
}
fn rms(frame: &[f32]) -> f32 {
(frame.iter().map(|s| s * s).sum::<f32>() / frame.len() as f32).sqrt()
}
}
+31
View File
@@ -0,0 +1,31 @@
//! Realtime-safe audio processors for platform and software voice paths.
pub mod dsp;
pub mod noop;
pub mod platform;
pub mod sonora;
pub use noop::NoopProcessor;
pub use platform::PlatformVoiceProcessor;
pub use sonora::SonoraProcessor;
/// 10 ms mono f32 processing frame at 48 kHz (480 samples).
pub const FRAME_SAMPLES: usize = 480;
/// Realtime-safe audio processor backend.
///
/// Implementations MUST be `Send` and MUST NOT allocate, block, or
/// perform I/O inside `process_capture` or `process_render`.
pub trait AudioProcessor: Send {
/// Process one 10 ms capture frame in-place.
fn process_capture(&mut self, frame: &mut [f32; FRAME_SAMPLES]);
/// Feed one 10 ms render-reference frame (decoded remote PCM
/// before playout). Required by software AEC backends; no-op
/// for platform and noop backends.
fn process_render(&mut self, frame: &[f32; FRAME_SAMPLES]);
/// Return true if this backend performs acoustic echo cancellation
/// so the engine can enforce INV_009 / INV_010.
fn has_aec(&self) -> bool;
}
@@ -0,0 +1,16 @@
//! Processor backend that intentionally leaves audio unchanged.
use super::{AudioProcessor, FRAME_SAMPLES};
/// No-op audio processor for debug/headset routes.
pub struct NoopProcessor;
impl AudioProcessor for NoopProcessor {
fn process_capture(&mut self, _frame: &mut [f32; FRAME_SAMPLES]) {}
fn process_render(&mut self, _frame: &[f32; FRAME_SAMPLES]) {}
fn has_aec(&self) -> bool {
false
}
}
@@ -0,0 +1,20 @@
//! Platform-owned voice processing backend.
use super::{AudioProcessor, FRAME_SAMPLES};
/// Marker backend for the platform VoiceProcessingIO path.
/// All DSP (AEC/NS/AGC) is handled by the hardware voice processor;
/// Rust-side processing is a no-op. `has_aec` returns true so the
/// engine enforces INV_009/INV_010 and never enables Rust AEC
/// simultaneously.
pub struct PlatformVoiceProcessor;
impl AudioProcessor for PlatformVoiceProcessor {
fn process_capture(&mut self, _frame: &mut [f32; FRAME_SAMPLES]) {}
fn process_render(&mut self, _frame: &[f32; FRAME_SAMPLES]) {}
fn has_aec(&self) -> bool {
true
}
}
@@ -0,0 +1,353 @@
//! Sonora software voice processor — full DSP chain.
//!
//! Composes the four P1 DSP stages in the order mandated by the spec:
//!
//! **HPF → AEC3 → NS → AGC2**
//!
//! Each stage is independently enable/disable-able via
//! [`SonoraConfig`]. The default configuration matches the P1 spec:
//! all stages enabled, AEC3 disabled when no render reference is
//! available (INV_011).
//!
//! ## Stage descriptions
//!
//! | Stage | Module | Description |
//! |-------|--------|-------------|
//! | HPF | `dsp::hpf` | 80 Hz Butterworth biquad, removes DC and rumble |
//! | AEC3 | `dsp::aec3` | Adaptive filter echo canceller with delay estimation |
//! | NS | `dsp::ns` | Wiener filter noise suppressor with min-statistics floor |
//! | AGC2 | `dsp::agc2` | RNN VAD-gated gain controller with look-ahead limiter |
//!
//! ## Realtime safety
//!
//! All state is pre-allocated. `process_capture` and `process_render`
//! never allocate, block, or perform I/O (INV_007).
//!
//! ## INV_009 / INV_010 enforcement
//!
//! `has_aec()` returns `true` when AEC3 is enabled. The engine uses
//! this to enforce the invariant that platform AEC and Rust AEC are
//! never active simultaneously.
use super::dsp::{aec3::Aec3, agc2::Agc2, hpf::HighPassFilter, ns::NoiseSuppressor};
use super::{AudioProcessor, FRAME_SAMPLES};
/// Per-stage enable flags for the Sonora processor.
#[derive(Debug, Clone, PartialEq)]
pub struct SonoraConfig {
/// High-pass filter (80 Hz Butterworth). Default: enabled.
pub hpf: bool,
/// AEC3 adaptive echo canceller. Default: disabled until render
/// reference is confirmed available (INV_011).
pub aec3: bool,
/// Wiener filter noise suppressor. Default: enabled.
pub ns: bool,
/// AGC2 gain controller + limiter. Default: enabled.
pub agc2: bool,
}
impl Default for SonoraConfig {
fn default() -> Self {
Self {
hpf: true,
// AEC3 is disabled by default: it requires a render reference
// (INV_011). The engine enables it only when the render
// reference path is confirmed active.
aec3: false,
ns: true,
agc2: true,
}
}
}
impl SonoraConfig {
/// Configuration for the Sonora experimental mode with AEC3 enabled.
/// Only valid when a render reference is available (INV_011).
pub fn with_aec3() -> Self {
Self {
hpf: true,
aec3: true,
ns: true,
agc2: true,
}
}
/// Minimal configuration: HPF + AGC2 only (no AEC, no NS).
/// Suitable for wired headset routes where AEC is not needed.
pub fn headset() -> Self {
Self {
hpf: true,
aec3: false,
ns: false,
agc2: true,
}
}
}
/// Full Sonora DSP chain: HPF → AEC3 → NS → AGC2.
pub struct SonoraProcessor {
hpf: HighPassFilter,
aec3: Aec3,
ns: NoiseSuppressor,
agc2: Agc2,
config: SonoraConfig,
}
impl SonoraProcessor {
/// Construct with the default configuration (AEC3 disabled).
pub fn new() -> Self {
let config = SonoraConfig::default();
let mut aec3 = Aec3::new();
aec3.set_enabled(config.aec3);
let mut ns = NoiseSuppressor::new();
ns.set_enabled(config.ns);
let mut agc2 = Agc2::new();
agc2.set_enabled(config.agc2);
Self {
hpf: HighPassFilter::new(),
aec3,
ns,
agc2,
config,
}
}
/// Construct with a specific configuration.
pub fn with_config(config: SonoraConfig) -> Self {
let mut aec3 = Aec3::new();
aec3.set_enabled(config.aec3);
let mut ns = NoiseSuppressor::new();
ns.set_enabled(config.ns);
let mut agc2 = Agc2::new();
agc2.set_enabled(config.agc2);
Self {
hpf: HighPassFilter::new(),
aec3,
ns,
agc2,
config,
}
}
/// Apply a new configuration at runtime. Resets stages whose
/// enable state changed to avoid state contamination.
pub fn apply_config(&mut self, new_config: SonoraConfig) {
if new_config.hpf != self.config.hpf {
self.hpf.reset();
}
if new_config.aec3 != self.config.aec3 {
self.aec3.reset();
self.aec3.set_enabled(new_config.aec3);
}
if new_config.ns != self.config.ns {
self.ns.reset();
self.ns.set_enabled(new_config.ns);
}
if new_config.agc2 != self.config.agc2 {
self.agc2.reset();
self.agc2.set_enabled(new_config.agc2);
}
self.config = new_config;
}
/// Current configuration.
pub fn config(&self) -> &SonoraConfig {
&self.config
}
/// Reset all DSP state (call on route change or session restart).
pub fn reset_all(&mut self) {
self.hpf.reset();
self.aec3.reset();
self.ns.reset();
self.agc2.reset();
}
/// Current AEC3 bulk delay estimate in blocks (1 block = 10 ms).
pub fn aec3_bulk_delay_blocks(&self) -> usize {
self.aec3.bulk_delay_blocks()
}
/// True if AEC3 has converged.
pub fn aec3_converged(&self) -> bool {
self.aec3.is_converged()
}
/// Current AGC2 speech probability from the RNN VAD.
pub fn agc2_speech_probability(&self) -> f32 {
self.agc2.speech_probability()
}
/// Current AGC2 gain in dB.
pub fn agc2_gain_db(&self) -> f32 {
self.agc2.gain_db()
}
}
impl Default for SonoraProcessor {
fn default() -> Self {
Self::new()
}
}
impl AudioProcessor for SonoraProcessor {
/// Process one 10 ms capture frame in-place.
///
/// Pipeline: HPF → AEC3 → NS → AGC2.
fn process_capture(&mut self, frame: &mut [f32; FRAME_SAMPLES]) {
// Stage 1: High-pass filter (DC removal, rumble suppression).
if self.config.hpf {
self.hpf.process(frame);
}
// Stage 2: AEC3 (echo cancellation).
// AEC3 reads the render reference that was fed via process_render.
// INV_011: only runs when aec3 is enabled (render reference available).
self.aec3.process_capture(frame);
// Stage 3: Noise suppression (Wiener filter).
self.ns.process(frame);
// Stage 4: AGC2 (gain control + limiter).
self.agc2.process(frame);
}
/// Feed one 10 ms render-reference frame (decoded remote PCM
/// before playout). Required by AEC3 (INV_012).
fn process_render(&mut self, frame: &[f32; FRAME_SAMPLES]) {
self.aec3.process_render(frame);
}
/// True when AEC3 is enabled (enforces INV_009 / INV_010).
fn has_aec(&self) -> bool {
self.config.aec3
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config_has_aec3_disabled() {
let p = SonoraProcessor::new();
assert!(!p.has_aec(), "AEC3 must be disabled by default (INV_010)");
assert!(p.config().hpf);
assert!(p.config().ns);
assert!(p.config().agc2);
}
#[test]
fn with_aec3_config_enables_aec() {
let p = SonoraProcessor::with_config(SonoraConfig::with_aec3());
assert!(p.has_aec());
}
#[test]
fn process_capture_does_not_panic_on_silence() {
let mut p = SonoraProcessor::new();
let mut frame = [0.0_f32; FRAME_SAMPLES];
p.process_capture(&mut frame);
assert!(frame.iter().all(|s| s.is_finite()));
}
#[test]
fn process_capture_does_not_panic_on_loud_signal() {
let mut p = SonoraProcessor::new();
let mut frame = [1.0_f32; FRAME_SAMPLES];
p.process_capture(&mut frame);
assert!(frame.iter().all(|s| s.is_finite()));
}
#[test]
fn hpf_removes_dc() {
let mut p = SonoraProcessor::with_config(SonoraConfig {
hpf: true,
aec3: false,
ns: false,
agc2: false,
});
// Feed 200 frames of DC = 0.5.
let mut frame = [0.5_f32; FRAME_SAMPLES];
for _ in 0..200 {
p.process_capture(&mut frame);
}
// After convergence, DC should be near zero.
let mean: f32 = frame.iter().sum::<f32>() / FRAME_SAMPLES as f32;
assert!(mean.abs() < 0.01, "DC not removed: mean={mean}");
}
#[test]
fn apply_config_resets_changed_stages() {
let mut p = SonoraProcessor::new();
// Run some frames to build up state.
let mut frame = [0.1_f32; FRAME_SAMPLES];
for _ in 0..10 {
p.process_capture(&mut frame);
}
// Enable AEC3 — should reset AEC3 state.
p.apply_config(SonoraConfig::with_aec3());
assert!(p.has_aec());
assert!(!p.aec3_converged()); // reset clears convergence
}
#[test]
fn suppresses_stationary_noise() {
let mut p = SonoraProcessor::with_config(SonoraConfig {
hpf: false,
aec3: false,
ns: true,
agc2: false,
});
let mut rng: u32 = 0xABCD_1234;
let noise_frame = |rng: &mut u32| -> [f32; FRAME_SAMPLES] {
let mut f = [0.0_f32; FRAME_SAMPLES];
for s in f.iter_mut() {
*rng = rng.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
*s = (*rng as i32 as f32) / i32::MAX as f32 * 0.05;
}
f
};
// Warm up noise floor.
for _ in 0..30 {
let mut frame = noise_frame(&mut rng);
p.process_capture(&mut frame);
}
let mut frame = noise_frame(&mut rng);
let before = rms(&frame);
p.process_capture(&mut frame);
let after = rms(&frame);
assert!(
after < before,
"NS did not suppress noise: {before:.4} → {after:.4}"
);
}
#[test]
fn agc_amplifies_quiet_signal() {
let mut p = SonoraProcessor::with_config(SonoraConfig {
hpf: false,
aec3: false,
ns: false,
agc2: true,
});
let mut frame = [0.0_f32; FRAME_SAMPLES];
for (i, sample) in frame.iter_mut().enumerate() {
*sample = (2.0 * std::f32::consts::PI * 300.0 * i as f32 / 48_000.0).sin() * 0.01;
}
let before = rms(&frame);
for _ in 0..50 {
p.process_capture(&mut frame);
}
let after = rms(&frame);
assert!(
after > before,
"AGC did not amplify: {before:.4} → {after:.4}"
);
}
fn rms(frame: &[f32]) -> f32 {
let power = frame.iter().map(|s| s * s).sum::<f32>() / frame.len() as f32;
power.sqrt()
}
}