Skip to main content

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}