Skip to main content

tauri_plugin_system_audio/
lib.rs

1//! Dual audio capture for Tauri 2 — microphone + system audio (WASAPI
2//! loopback) with WebRTC AEC3 echo cancellation.
3//!
4//! Extracted from the production desktop app of [SubcueAI](https://subcue.ai).
5//!
6//! ```text
7//!   ┌─────────────┐    ┌─────────────┐    ┌───────────┐
8//!   │ mic capture │───▶│  resampler  │───▶│   APM     │──▶ FrameEvent::Pcm (mic)
9//!   └─────────────┘    │  to 16k f32 │    │ near-end  │
10//!                      └─────────────┘    └───────────┘
11//!   ┌─────────────┐    ┌─────────────┐    ┌───────────┐
12//!   │ loopback*   │───▶│  resampler  │───▶│   APM     │──▶ FrameEvent::Pcm (loopback)
13//!   └─────────────┘    │  to 16k f32 │    │ reverse   │
14//!      *Windows only    └─────────────┘    └───────────┘
15//! ```
16//!
17//! Mic and loopback are emitted as **independent** 16 kHz mono PCM streams
18//! (no additive mix), so the JS side can route each to its own consumer —
19//! e.g. two STT sockets tagged "local speaker" vs "remote party". A
20//! [`mixer::Mixer`] utility is included if you want a single combined
21//! stream instead.
22//!
23//! On Windows the loopback (system output) feed doubles as the far-end
24//! reference for WebRTC AEC3, so speaker bleed is cancelled from the mic
25//! before your app ever sees it. On macOS the plugin runs mic-only:
26//! Apple's voice-processing I/O (VPIO) already does AEC at the OS level,
27//! and system-audio capture requires ScreenCaptureKit, which is out of
28//! scope here.
29
30pub mod apm;
31mod capture;
32mod loopback;
33pub mod mixer;
34pub mod resampler;
35
36use parking_lot::Mutex;
37use serde::{Deserialize, Serialize};
38use std::sync::Arc;
39use tauri::ipc::Channel;
40use tauri::plugin::{Builder, TauriPlugin};
41use tauri::{Manager, Runtime};
42
43#[derive(Debug, thiserror::Error)]
44pub enum Error {
45    #[error("audio session already running")]
46    AlreadyRunning,
47    #[error("audio session not running")]
48    NotRunning,
49    #[error("audio device error: {0}")]
50    Device(String),
51    /// OS denied microphone access. Surfaced as a distinct category so the
52    /// renderer can show the right "open Settings" prompt vs. a generic
53    /// "device unavailable" toast.
54    #[error("audio permission denied: {0}")]
55    Permission(String),
56    #[error("io: {0}")]
57    Io(String),
58}
59
60impl serde::Serialize for Error {
61    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
62        s.serialize_str(&self.to_string())
63    }
64}
65
66/// What to capture and how to process it. All flags are orthogonal;
67/// defaults give you the full pipeline (mic + loopback + APM).
68#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
69#[serde(rename_all = "camelCase", default)]
70pub struct CaptureOptions {
71    /// Capture the default render (output) device via WASAPI loopback.
72    /// Windows only — on other platforms the flag is ignored and capture
73    /// runs mic-only.
74    pub loopback: bool,
75    /// Run the WebRTC audio processing module (AEC3 echo cancellation +
76    /// light noise suppression) on the mic path. Requires `webrtc-apm.dll`
77    /// on Windows; a missing dll degrades gracefully to unprocessed mic.
78    /// No-op on non-Windows platforms.
79    pub processing: bool,
80    /// Emit only 10 Hz [`FrameEvent::Level`] events — no PCM, no loopback,
81    /// no APM. For "level meter preview" UI that runs while idle: ~0%
82    /// upload cost, <1% CPU.
83    pub level_only: bool,
84}
85
86impl Default for CaptureOptions {
87    fn default() -> Self {
88        Self {
89            loopback: true,
90            processing: true,
91            level_only: false,
92        }
93    }
94}
95
96impl CaptureOptions {
97    pub(crate) fn uses_loopback(self) -> bool {
98        self.loopback && !self.level_only
99    }
100    pub(crate) fn uses_apm(self) -> bool {
101        self.processing && !self.level_only
102    }
103    pub(crate) fn emits_pcm(self) -> bool {
104        !self.level_only
105    }
106}
107
108/// Which physical source a PCM frame came from. Mic and loopback frames
109/// arrive interleaved on the same channel; consumers split on this tag —
110/// e.g. mic → "local speaker" STT, loopback → "remote party" STT.
111#[derive(Debug, Clone, Copy, Serialize)]
112#[serde(rename_all = "snake_case")]
113pub enum PcmSource {
114    Mic,
115    Loopback,
116}
117
118/// Events emitted to the JS side over the Tauri [`Channel`].
119#[derive(Debug, Clone, Serialize)]
120#[serde(tag = "kind", rename_all = "snake_case")]
121pub enum FrameEvent {
122    /// One 20 ms frame of 16 kHz mono PCM, base64-encoded i16 LE bytes.
123    Pcm {
124        seq: u64,
125        source: PcmSource,
126        sample_rate: u32,
127        channels: u8,
128        samples_base64: String,
129    },
130    /// Throttled (10 Hz) RMS levels for meter UI, normalized 0..1.
131    Level { mic_rms: f32, loopback_rms: f32 },
132    /// Terminal failure of the capture worker. `category` is one of
133    /// `"permission" | "device" | "io" | "lifecycle"`.
134    Failure { category: String, message: String },
135}
136
137#[derive(Default)]
138pub struct AudioSession {
139    stop_token: Option<crossbeam_channel::Sender<()>>,
140}
141
142pub type SharedSession = Arc<Mutex<AudioSession>>;
143
144#[tauri::command]
145fn start(
146    state: tauri::State<'_, SharedSession>,
147    options: Option<CaptureOptions>,
148    channel: Channel<FrameEvent>,
149) -> Result<(), Error> {
150    let mut session = state.lock();
151    if session.stop_token.is_some() {
152        return Err(Error::AlreadyRunning);
153    }
154    let (tx, rx) = crossbeam_channel::bounded::<()>(1);
155    session.stop_token = Some(tx);
156    drop(session);
157
158    let options = options.unwrap_or_default();
159    std::thread::Builder::new()
160        .name("system-audio".into())
161        .spawn(move || {
162            if let Err(err) = capture::run(options, channel.clone(), rx) {
163                let category = match &err {
164                    Error::Permission(_) => "permission",
165                    Error::Device(_) => "device",
166                    Error::Io(_) => "io",
167                    Error::AlreadyRunning | Error::NotRunning => "lifecycle",
168                };
169                let _ = channel.send(FrameEvent::Failure {
170                    category: category.into(),
171                    message: err.to_string(),
172                });
173            }
174        })
175        .map_err(|e| Error::Io(e.to_string()))?;
176    Ok(())
177}
178
179#[tauri::command]
180fn stop(state: tauri::State<'_, SharedSession>) -> Result<(), Error> {
181    let mut session = state.lock();
182    if let Some(tx) = session.stop_token.take() {
183        let _ = tx.send(());
184        Ok(())
185    } else {
186        Err(Error::NotRunning)
187    }
188}
189
190/// Probe the OS for mic permission **without** starting capture, so the UI
191/// can grey out a Start button preemptively. Returns `"allowed"`,
192/// `"denied"`, or `"unknown"` (platforms without a cheap probe).
193#[tauri::command]
194fn permission_status() -> &'static str {
195    capture::check_mic_permission_status()
196}
197
198/// Initializes the plugin. Registers `start` / `stop` /
199/// `permission_status` commands and, on Windows, resolves a bundled
200/// `webrtc-apm.dll` through Tauri's resource resolver (works for
201/// `tauri dev` and `tauri build` alike).
202pub fn init<R: Runtime>() -> TauriPlugin<R> {
203    Builder::new("system-audio")
204        .invoke_handler(tauri::generate_handler![start, stop, permission_status])
205        .setup(|app, _api| {
206            app.manage(SharedSession::default());
207            #[cfg(target_os = "windows")]
208            {
209                use tauri::path::BaseDirectory;
210                for candidate in ["webrtc-apm.dll", "resources/webrtc-apm.dll"] {
211                    if let Ok(path) = app.path().resolve(candidate, BaseDirectory::Resource) {
212                        if path.exists() {
213                            apm::set_lib_path(path.to_string_lossy().into_owned());
214                            break;
215                        }
216                    }
217                }
218            }
219            Ok(())
220        })
221        .build()
222}