screen_app/audio/mod.rs
1//! Microphone capture lifecycle (M-MIC.1 / AUT-278) + worker.
2//!
3//! Structural mirror of [`crate::preview`] (M-CAM.2 / M-CAM.3):
4//! a four-state lifecycle [`MicLifecycle`] managed inside
5//! [`MicCaptureState`] (Tauri-managed) plus a dedicated worker
6//! thread defined in [`pipeline`] that owns the `gst-launch-1.0`
7//! subprocess. The state machine is pure Rust (no `tauri::*`, no
8//! async, no I/O) so its transition contract works on every OS
9//! including Windows, where Tauri 2's `mock_builder` won't even
10//! link at test time (per CLAUDE.md).
11
12pub mod pipeline;
13
14pub use pipeline::{MicCaptureHandle, MicCapturePipeline};
15
16use std::sync::Mutex;
17
18use serde::{Deserialize, Serialize};
19
20/// Lifecycle state of the microphone capture worker.
21///
22/// Mirror of [`crate::preview::PreviewLifecycle`]. `Starting` /
23/// `Stopping` exist so a re-entrant `start_mic_capture` can detect
24/// "already booting, drop the new one" instead of double-spawning
25/// gst children. `Running` is the steady state.
26#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
27pub enum MicLifecycle {
28 /// No worker running.
29 #[default]
30 Idle,
31 /// `start_mic_capture` invoked but no audio chunk has arrived
32 /// yet (gst spawn + first-frame latency, ~100–300 ms on macOS
33 /// `osxaudiosrc`).
34 Starting,
35 /// Worker is producing audio chunks.
36 Running,
37 /// `stop_mic_capture` invoked but the gst child is still being
38 /// torn down. Transient — drops into `Idle` once reaped.
39 Stopping,
40}
41
42/// IPC-surface error variants for the mic capture commands.
43///
44/// Mirror of [`crate::preview::CameraError`]. `PermissionPending`
45/// is the macOS first-run case where the OS shows
46/// `NSMicrophoneUsageDescription` and the gst pipeline blocks until
47/// the user clicks. `PermissionDenied` is the post-prompt rejection.
48#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
49pub enum MicError {
50 /// macOS microphone-permission prompt is showing; no chunk has
51 /// arrived yet but access isn't denied either.
52 #[error("microphone permission prompt is pending user response")]
53 PermissionPending,
54 /// User has explicitly denied microphone access in System Settings.
55 #[error("microphone access denied; user must grant in System Settings")]
56 PermissionDenied,
57 /// The selected microphone is held by another app, or — on macOS
58 /// — the missing-Info.plist failure mode that masquerades as
59 /// busy.
60 #[error("microphone is busy or otherwise unavailable")]
61 DeviceBusy,
62 /// gst-launch pipeline spawn / runtime failure.
63 #[error("gst pipeline failed: {0}")]
64 GstFailed(String),
65 /// The picker handed us a `mic_id` that no longer matches any
66 /// device on the host — typically the mic was unplugged between
67 /// `list_microphones()` and `start_mic_capture` (Bluetooth
68 /// devices sleep, USB devices yanked). Caller should re-enumerate
69 /// + re-prompt the user.
70 ///
71 /// M-MIC.3 / AUT-284 + M-RECORD-EXPORT tightening — was silently
72 /// falling back to `autoaudiosrc`, which gave the wrong device.
73 #[error("microphone id `{0}` not present on this host (was the mic unplugged?)")]
74 NotFound(String),
75}
76
77/// Tauri-managed wrapper around [`MicLifecycle`]. Held in
78/// `tauri::State` so the IPC handlers and the worker thread share
79/// one source of truth for the lifecycle.
80#[derive(Default)]
81pub struct MicCaptureState(pub Mutex<MicLifecycle>);
82
83impl MicLifecycle {
84 /// Attempt to advance to `Starting`. Re-entrant calls (already
85 /// Starting / Running / Stopping) are a no-op — the caller is
86 /// expected to first stop the existing session.
87 #[must_use]
88 pub fn try_start(self) -> Self {
89 match self {
90 Self::Idle => Self::Starting,
91 other => other,
92 }
93 }
94
95 /// Mark the worker as running (first audio chunk received).
96 /// Idempotent — `Running.mark_running() == Running`.
97 #[must_use]
98 pub fn mark_running(self) -> Self {
99 match self {
100 Self::Starting => Self::Running,
101 other => other,
102 }
103 }
104
105 /// Begin teardown — moves Running / Starting → Stopping. Stays
106 /// in `Idle` if no worker is up.
107 #[must_use]
108 pub fn try_stop(self) -> Self {
109 match self {
110 Self::Running | Self::Starting => Self::Stopping,
111 other => other,
112 }
113 }
114
115 /// Complete teardown — moves Stopping → Idle.
116 #[must_use]
117 pub fn finish_stop(self) -> Self {
118 match self {
119 Self::Stopping => Self::Idle,
120 other => other,
121 }
122 }
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 #[test]
130 fn default_is_idle() {
131 assert_eq!(MicLifecycle::default(), MicLifecycle::Idle);
132 }
133
134 #[test]
135 fn idle_can_start() {
136 assert_eq!(MicLifecycle::Idle.try_start(), MicLifecycle::Starting);
137 }
138
139 #[test]
140 fn starting_cannot_start_again() {
141 assert_eq!(MicLifecycle::Starting.try_start(), MicLifecycle::Starting);
142 }
143
144 #[test]
145 fn running_cannot_start_again() {
146 assert_eq!(MicLifecycle::Running.try_start(), MicLifecycle::Running);
147 }
148
149 #[test]
150 fn starting_can_mark_running() {
151 assert_eq!(MicLifecycle::Starting.mark_running(), MicLifecycle::Running);
152 }
153
154 #[test]
155 fn mark_running_is_idempotent_on_running() {
156 // First-chunk arrival can race with subsequent chunks; the
157 // worker calls mark_running on every chunk to avoid an extra
158 // "first-frame" guard — so the transition has to be a no-op
159 // once Running is reached.
160 assert_eq!(MicLifecycle::Running.mark_running(), MicLifecycle::Running);
161 }
162
163 #[test]
164 fn idle_mark_running_is_noop() {
165 // A spurious mark_running from a worker that wasn't actually
166 // started (shouldn't happen, but the API has to be safe) must
167 // not put us into Running without a try_start.
168 assert_eq!(MicLifecycle::Idle.mark_running(), MicLifecycle::Idle);
169 }
170
171 #[test]
172 fn running_can_stop() {
173 assert_eq!(MicLifecycle::Running.try_stop(), MicLifecycle::Stopping);
174 }
175
176 #[test]
177 fn starting_can_stop_before_first_chunk() {
178 // User clicks stop during the macOS permission prompt: we
179 // must move Starting → Stopping, not stay stuck Starting.
180 assert_eq!(MicLifecycle::Starting.try_stop(), MicLifecycle::Stopping);
181 }
182
183 #[test]
184 fn stopping_finishes_to_idle() {
185 assert_eq!(MicLifecycle::Stopping.finish_stop(), MicLifecycle::Idle);
186 }
187
188 #[test]
189 fn full_round_trip() {
190 let mut s = MicLifecycle::default();
191 s = s.try_start();
192 assert_eq!(s, MicLifecycle::Starting);
193 s = s.mark_running();
194 assert_eq!(s, MicLifecycle::Running);
195 s = s.try_stop();
196 assert_eq!(s, MicLifecycle::Stopping);
197 s = s.finish_stop();
198 assert_eq!(s, MicLifecycle::Idle);
199 }
200
201 #[test]
202 fn mic_error_round_trips_serde() {
203 let cases = [
204 MicError::PermissionPending,
205 MicError::PermissionDenied,
206 MicError::DeviceBusy,
207 MicError::GstFailed("spawn failed: ENOENT".into()),
208 MicError::NotFound("mic-cafebabe".into()),
209 ];
210 for err in cases {
211 let json = serde_json::to_string(&err).unwrap();
212 let back: MicError = serde_json::from_str(&json).unwrap();
213 assert_eq!(back, err);
214 }
215 }
216}