Skip to main content

tauri_plugin_system_audio/
apm.rs

1// WebRTC APM (AEC3 + NS + HPF + AGC2) — FFI binding to webrtc-apm.dll.
2//
3// Windows: loads the dll from one of the following candidates (first hit
4// wins):
5//   1. Explicit override via `set_lib_path("…\\webrtc-apm.dll")` — the
6//      plugin's `setup` hook calls this with the Tauri resource-resolved
7//      path when the app bundles the dll as a resource.
8//   2. Plain name `webrtc-apm.dll` — works in dev when the dll sits next
9//      to the .exe (`target/<profile>/webrtc-apm.dll`).
10//   3. `<exe_dir>/webrtc-apm.dll` — explicit absolute path, same as (2)
11//      but doesn't depend on Windows PATH search semantics.
12//   4. `<exe_dir>/resources/webrtc-apm.dll` — Tauri's bundled-resource
13//      layout after `tauri build`.
14//
15// Non-Windows: stub. On macOS, Apple's voice-processing I/O (VPIO)
16// AudioUnit handles AEC + NS at the OS level, so shipping WebRTC APM there
17// buys nothing.
18//
19// C ABI expected from the dll (see webrtc-apm/ABI.md in this repo):
20//   * `webrtc_apm_create()` → `*mut Apm`
21//   * `webrtc_apm_stream_config_create(sr: i32, num_channels: size_t)` → `*mut StreamConfig`
22//   * `webrtc_apm_config_set_*(config, ...)` — all `i32` enabled flags, enums marshal as `i32`
23//   * `webrtc_apm_process_stream(apm, src: float**, in_cfg, out_cfg, dst: float**)` → `ApmError(i32)`
24//   * `webrtc_apm_process_reverse_stream(...)` — same signature
25//   * `webrtc_apm_set_stream_delay_ms(apm, i32)`
26//
27// The `float**` is deinterleaved per-channel: array of channel-count pointers,
28// each pointing to a contiguous block of `FRAME_SIZE` floats in [-1, 1].
29// We're always mono (channels=1) so we pass a single-element `[*const f32; 1]`
30// stack array; APM reads the one pointer, dereferences `FRAME_SIZE` floats.
31// Frame size is exactly 160 samples (10ms @ 16kHz) — a hard APM constraint.
32
33// Constants are only consumed by the Windows `imp` module; on other
34// platforms the stub doesn't use them but we keep them defined at the
35// public root so downstream callers (e.g. tests) can reference the
36// canonical frame size regardless of host.
37#[allow(dead_code)]
38pub const APM_FRAME_SIZE: usize = 160;
39#[allow(dead_code)]
40pub const APM_SAMPLE_RATE: i32 = 16_000;
41
42/// Default playback-delay seed. AEC3's delay estimator tolerates ±~250ms
43/// of seed error and converges from there. For raw WASAPI loopback the
44/// loopback path captures the digital pre-mixer signal, so the
45/// speaker → room → mic round-trip is the only acoustic delay (~10-50ms
46/// typical); buffered TTS playback paths can add ~300ms. 150ms covers
47/// both scenarios.
48#[allow(dead_code)]
49pub const APM_PLAYBACK_DELAY_MS: i32 = 150;
50
51#[cfg(target_os = "windows")]
52mod imp {
53    use super::{APM_FRAME_SIZE, APM_PLAYBACK_DELAY_MS, APM_SAMPLE_RATE};
54    use libloading::{Library, Symbol};
55    use parking_lot::Mutex;
56    use std::env;
57    use std::ffi::c_void;
58    use std::path::PathBuf;
59    use std::sync::OnceLock;
60
61    // --- Native types ----------------------------------------------------
62    //
63    // All "config" / "stream_config" / "apm" pointers are opaque heap
64    // handles owned by the dll. We round-trip them as `*mut c_void`.
65    //
66    // Enum values are passed as `i32`. For NoiseSuppression: Low=0
67    // Moderate=1 High=2 VeryHigh=3. For DownmixMethod: AverageChannels=0
68    // UseFirstChannel=1. GainControlMode: AdaptiveAnalog=0
69    // AdaptiveDigital=1 FixedDigital=2.
70
71    type ApmCreate = unsafe extern "C" fn() -> *mut c_void;
72    type ApmDestroy = unsafe extern "C" fn(*mut c_void);
73    type ApmInitialize = unsafe extern "C" fn(*mut c_void) -> i32;
74    type ApmApplyConfig = unsafe extern "C" fn(*mut c_void, *mut c_void) -> i32;
75
76    // Deinterleaved per-channel pointer arrays — see the module header.
77    type ApmProcessStream = unsafe extern "C" fn(
78        apm: *mut c_void,
79        src: *const *const f32,
80        input_cfg: *mut c_void,
81        output_cfg: *mut c_void,
82        dest: *const *mut f32,
83    ) -> i32;
84    type ApmProcessReverseStream = ApmProcessStream;
85
86    type ApmSetStreamDelay = unsafe extern "C" fn(*mut c_void, i32);
87
88    // size_t == usize on the Rust side.
89    type ApmStreamConfigCreate = unsafe extern "C" fn(i32, usize) -> *mut c_void;
90    type ApmStreamConfigDestroy = unsafe extern "C" fn(*mut c_void);
91
92    type ApmConfigCreate = unsafe extern "C" fn() -> *mut c_void;
93    type ApmConfigDestroy = unsafe extern "C" fn(*mut c_void);
94
95    type ApmConfigSetEchoCanceller = unsafe extern "C" fn(*mut c_void, i32, i32);
96    type ApmConfigSetNoiseSuppression = unsafe extern "C" fn(*mut c_void, i32, i32);
97    type ApmConfigSetHighPassFilter = unsafe extern "C" fn(*mut c_void, i32);
98    type ApmConfigSetGainController1 = unsafe extern "C" fn(*mut c_void, i32, i32, i32, i32, i32);
99    type ApmConfigSetGainController2 = unsafe extern "C" fn(*mut c_void, i32);
100    type ApmConfigSetPipeline = unsafe extern "C" fn(*mut c_void, i32, i32, i32, i32);
101
102    pub struct Apm {
103        _lib: Library,
104        handle: *mut c_void,
105        process_stream: ApmProcessStream,
106        process_reverse: ApmProcessReverseStream,
107        set_delay: ApmSetStreamDelay,
108        destroy: ApmDestroy,
109        cfg_destroy: ApmStreamConfigDestroy,
110        stream_cfg: *mut c_void,
111        // Reusable output buffer — APM frames are exactly APM_FRAME_SIZE
112        // samples (10ms @ 16kHz). Both `process_near` and `process_far`
113        // are called sequentially from the capture loop's drain tick,
114        // so a single scratch suffices. Far-end output is discarded by
115        // contract; near-end is copied back into the caller's frame.
116        // Wrapped in `Mutex` for `Sync`.
117        scratch_out: Mutex<Vec<f32>>,
118    }
119    unsafe impl Send for Apm {}
120    unsafe impl Sync for Apm {}
121
122    static LIB_PATH: OnceLock<Mutex<Option<String>>> = OnceLock::new();
123
124    /// Override the default search candidates. The plugin's `setup` hook
125    /// calls this with the path of the bundled resource — see `lib.rs`.
126    pub fn set_lib_path(p: String) {
127        let cell = LIB_PATH.get_or_init(|| Mutex::new(None));
128        *cell.lock() = Some(p);
129    }
130
131    fn candidate_paths() -> Vec<String> {
132        let mut out = Vec::new();
133        if let Some(cell) = LIB_PATH.get() {
134            if let Some(explicit) = cell.lock().clone() {
135                out.push(explicit);
136            }
137        }
138        out.push("webrtc-apm.dll".to_string());
139        if let Ok(exe) = env::current_exe() {
140            if let Some(dir) = exe.parent() {
141                let exe_dir = PathBuf::from(dir);
142                out.push(
143                    exe_dir
144                        .join("webrtc-apm.dll")
145                        .to_string_lossy()
146                        .into_owned(),
147                );
148                out.push(
149                    exe_dir
150                        .join("resources")
151                        .join("webrtc-apm.dll")
152                        .to_string_lossy()
153                        .into_owned(),
154                );
155            }
156        }
157        out
158    }
159
160    pub fn open() -> anyhow::Result<Apm> {
161        let candidates = candidate_paths();
162        let mut last_err: Option<String> = None;
163        let lib = candidates
164            .iter()
165            .find_map(|p| match unsafe { Library::new(p) } {
166                Ok(lib) => Some(lib),
167                Err(e) => {
168                    last_err = Some(format!("{p}: {e}"));
169                    None
170                }
171            })
172            .ok_or_else(|| {
173                anyhow::anyhow!(
174                    "webrtc-apm.dll not found (tried {} candidates) — last error: {}",
175                    candidates.len(),
176                    last_err.unwrap_or_else(|| "<none>".to_string())
177                )
178            })?;
179
180        unsafe {
181            let create: Symbol<ApmCreate> = lib.get(b"webrtc_apm_create")?;
182            let init: Symbol<ApmInitialize> = lib.get(b"webrtc_apm_initialize")?;
183            let apply: Symbol<ApmApplyConfig> = lib.get(b"webrtc_apm_apply_config")?;
184            let cfg_create: Symbol<ApmStreamConfigCreate> =
185                lib.get(b"webrtc_apm_stream_config_create")?;
186            let cfg_destroy: Symbol<ApmStreamConfigDestroy> =
187                lib.get(b"webrtc_apm_stream_config_destroy")?;
188            let process_stream: Symbol<ApmProcessStream> = lib.get(b"webrtc_apm_process_stream")?;
189            let process_reverse: Symbol<ApmProcessReverseStream> =
190                lib.get(b"webrtc_apm_process_reverse_stream")?;
191            let set_delay: Symbol<ApmSetStreamDelay> =
192                lib.get(b"webrtc_apm_set_stream_delay_ms")?;
193            let destroy: Symbol<ApmDestroy> = lib.get(b"webrtc_apm_destroy")?;
194
195            // Config funcs. Note:
196            // webrtc_apm_config_set_gain_controller1 takes (cfg, enabled,
197            // mode, target_dbfs, gain_db, enable_limiter) — five int args
198            // after the config handle.
199            let apm_cfg_create: Symbol<ApmConfigCreate> = lib.get(b"webrtc_apm_config_create")?;
200            let apm_cfg_destroy: Symbol<ApmConfigDestroy> =
201                lib.get(b"webrtc_apm_config_destroy")?;
202            let cfg_set_aec: Symbol<ApmConfigSetEchoCanceller> =
203                lib.get(b"webrtc_apm_config_set_echo_canceller")?;
204            let cfg_set_ns: Symbol<ApmConfigSetNoiseSuppression> =
205                lib.get(b"webrtc_apm_config_set_noise_suppression")?;
206            let cfg_set_hpf: Symbol<ApmConfigSetHighPassFilter> =
207                lib.get(b"webrtc_apm_config_set_high_pass_filter")?;
208            let cfg_set_agc1: Symbol<ApmConfigSetGainController1> =
209                lib.get(b"webrtc_apm_config_set_gain_controller1")?;
210            let cfg_set_agc2: Symbol<ApmConfigSetGainController2> =
211                lib.get(b"webrtc_apm_config_set_gain_controller2")?;
212            let cfg_set_pipeline: Symbol<ApmConfigSetPipeline> =
213                lib.get(b"webrtc_apm_config_set_pipeline")?;
214
215            let handle = create();
216            if handle.is_null() {
217                anyhow::bail!("webrtc_apm_create returned null");
218            }
219
220            let cfg = apm_cfg_create();
221            if cfg.is_null() {
222                (destroy)(handle);
223                anyhow::bail!("webrtc_apm_config_create returned null");
224            }
225            // Tuned to mimic Apple VPIO's behavior baseline (a good
226            // reference point for speech/STT workloads): AEC on, NS Low,
227            // HPF off, AGC1/AGC2 off. WebRTC APM's aggressive defaults
228            // (AGC2 + NS Moderate + HPF) re-pump room noise to -6dBFS
229            // during pauses and shave the 80-300Hz speech fundamental —
230            // both measurably hurt STT accuracy.
231            cfg_set_aec(cfg, 1, 0);
232            cfg_set_ns(cfg, 1, 0);
233            cfg_set_hpf(cfg, 0);
234            cfg_set_agc1(cfg, 0, 1, 3, 9, 1);
235            cfg_set_agc2(cfg, 0);
236            cfg_set_pipeline(cfg, APM_SAMPLE_RATE, 0, 0, 0);
237            let apply_err = apply(handle, cfg);
238            apm_cfg_destroy(cfg);
239            if apply_err != 0 {
240                (destroy)(handle);
241                anyhow::bail!("webrtc_apm_apply_config returned {apply_err}");
242            }
243            let init_err = init(handle);
244            if init_err != 0 {
245                (destroy)(handle);
246                anyhow::bail!("webrtc_apm_initialize returned {init_err}");
247            }
248
249            // 16kHz mono — matches the post-resample target shared by mic
250            // and loopback paths in `capture.rs`.
251            let stream_cfg = cfg_create(APM_SAMPLE_RATE, 1);
252            if stream_cfg.is_null() {
253                (destroy)(handle);
254                anyhow::bail!("webrtc_apm_stream_config_create returned null");
255            }
256            // Seed playback delay; `capture.rs` may refine with
257            // `set_stream_delay_ms` if it learns the actual loop latency.
258            set_delay(handle, APM_PLAYBACK_DELAY_MS);
259
260            Ok(Apm {
261                process_stream: *process_stream,
262                process_reverse: *process_reverse,
263                set_delay: *set_delay,
264                destroy: *destroy,
265                cfg_destroy: *cfg_destroy,
266                _lib: lib,
267                handle,
268                stream_cfg,
269                scratch_out: Mutex::new(vec![0.0f32; APM_FRAME_SIZE]),
270            })
271        }
272    }
273
274    impl Apm {
275        /// Process the near-end (mic) frame in place. Length must be a
276        /// multiple of `APM_FRAME_SIZE`; remainder samples are passed
277        /// through untouched. Samples are nominal `[-1.0, 1.0]` f32 —
278        /// matches the cpal F32 capture format directly, no
279        /// precision-eating i16 round-trip. Returns the last APM error
280        /// code seen (0 = ok).
281        pub fn process_near(&self, frame: &mut [f32]) -> i32 {
282            let mut last: i32 = 0;
283            let mut buf_out = self.scratch_out.lock();
284            let mut i = 0;
285            while i + APM_FRAME_SIZE <= frame.len() {
286                let src_ptrs: [*const f32; 1] = [frame[i..].as_ptr()];
287                let dst_ptrs: [*mut f32; 1] = [buf_out.as_mut_ptr()];
288                unsafe {
289                    last = (self.process_stream)(
290                        self.handle,
291                        src_ptrs.as_ptr(),
292                        self.stream_cfg,
293                        self.stream_cfg,
294                        dst_ptrs.as_ptr(),
295                    );
296                }
297                frame[i..i + APM_FRAME_SIZE].copy_from_slice(&buf_out[..APM_FRAME_SIZE]);
298                i += APM_FRAME_SIZE;
299            }
300            last
301        }
302
303        /// Process the far-end (loopback / playback) frame for AEC
304        /// reference. **Output is discarded** — APM uses the far-end only
305        /// to maintain its echo model, the post-filter playback signal
306        /// isn't useful to us. Length must be a multiple of
307        /// `APM_FRAME_SIZE`; partial tail is ignored.
308        pub fn process_far(&self, frame: &[f32]) -> i32 {
309            let mut last: i32 = 0;
310            let mut buf_out = self.scratch_out.lock();
311            let mut i = 0;
312            while i + APM_FRAME_SIZE <= frame.len() {
313                let src_ptrs: [*const f32; 1] = [frame[i..].as_ptr()];
314                let dst_ptrs: [*mut f32; 1] = [buf_out.as_mut_ptr()];
315                unsafe {
316                    last = (self.process_reverse)(
317                        self.handle,
318                        src_ptrs.as_ptr(),
319                        self.stream_cfg,
320                        self.stream_cfg,
321                        dst_ptrs.as_ptr(),
322                    );
323                }
324                i += APM_FRAME_SIZE;
325            }
326            last
327        }
328
329        pub fn set_stream_delay_ms(&self, ms: i32) {
330            unsafe { (self.set_delay)(self.handle, ms) }
331        }
332    }
333
334    impl Drop for Apm {
335        fn drop(&mut self) {
336            unsafe {
337                (self.cfg_destroy)(self.stream_cfg);
338                (self.destroy)(self.handle);
339            }
340        }
341    }
342}
343
344#[cfg(not(target_os = "windows"))]
345mod imp {
346    pub struct Apm;
347    pub fn open() -> anyhow::Result<Apm> {
348        anyhow::bail!("APM not used on this platform (Apple VPIO handles AEC + NS on macOS)")
349    }
350    impl Apm {
351        pub fn process_near(&self, _frame: &mut [f32]) -> i32 {
352            0
353        }
354        pub fn process_far(&self, _frame: &[f32]) -> i32 {
355            0
356        }
357        pub fn set_stream_delay_ms(&self, _ms: i32) {}
358    }
359    #[allow(dead_code)]
360    pub fn set_lib_path(_p: String) {}
361}
362
363// `Apm` is the live FFI handle; `open` constructs one; `set_lib_path` is
364// only invoked on Windows by the plugin's `setup` hook. The non-Windows
365// stub keeps the same surface so call sites compile unchanged.
366#[allow(unused_imports)]
367pub use imp::{open, set_lib_path, Apm};
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    /// Sanity: `open()` is the public entry point. On non-Windows it must
374    /// error cleanly with a non-empty message (no panic, no UB). On
375    /// Windows in test mode we may or may not find the dll depending on
376    /// whether it sits next to the test binary, so we only assert the
377    /// error message is a String.
378    #[test]
379    fn apm_open_returns_either_ok_or_err() {
380        let res = open();
381        match res {
382            Ok(_apm) => {
383                // If we got here we're on Windows with a working dll.
384                // No further assertion — the smoke tests in capture.rs
385                // exercise the real frames.
386            }
387            Err(e) => {
388                let msg = e.to_string();
389                assert!(!msg.is_empty(), "error message must not be empty");
390            }
391        }
392    }
393
394    /// Verify the APM_FRAME_SIZE constant aligns with the upstream
395    /// constraint (160 samples = 10ms @ 16kHz). This is hard-coded by
396    /// webrtc-apm's internal block size; deviating produces BadDataLength.
397    #[test]
398    fn apm_frame_size_is_10ms_at_16khz() {
399        assert_eq!(APM_FRAME_SIZE, 160);
400        assert_eq!(APM_SAMPLE_RATE, 16_000);
401        // 160 samples / 16000 Hz = 10ms exactly.
402        let ms = (APM_FRAME_SIZE as f32 / APM_SAMPLE_RATE as f32) * 1000.0;
403        assert!((ms - 10.0).abs() < 0.001, "frame must be 10ms, got {ms}ms");
404    }
405
406    /// On non-Windows the stub's `process_near` is a no-op pass-through —
407    /// frame must come out unchanged.
408    #[cfg(not(target_os = "windows"))]
409    #[test]
410    fn apm_stub_is_passthrough() {
411        let apm = Apm;
412        let mut frame = vec![0.25f32; APM_FRAME_SIZE];
413        let original = frame.clone();
414        let rc = apm.process_near(&mut frame);
415        assert_eq!(rc, 0);
416        assert_eq!(frame, original, "stub must not modify near-end frame");
417    }
418}