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}