screen_app/audio/pipeline.rs
1//! M-MIC.1 / AUT-278 — microphone-capture worker thread.
2//!
3//! Owns a dedicated OS thread that runs the `gst-launch-1.0
4//! autoaudiosrc` capture subprocess and pulls PCM chunks into Rust.
5//! Mirror of [`crate::preview::pipeline`] for the audio path —
6//! same Drop-safety, cancel-flag-+-join lifecycle, idempotent
7//! `mark_running` transition.
8//!
9//! ```admonish important title="What this commit ships"
10//! Just the **gst-into-Rust** layer for microphone PCM:
11//!
12//! 1. `start_mic_capture(mic_id)` spawns a [`MicCapturePipeline`].
13//! 2. Worker opens
14//! [`media::gstreamer_audio::GstreamerAudioCapture::from_microphone`],
15//! triggering the macOS `NSMicrophoneUsageDescription` prompt on
16//! first run.
17//! 3. Worker loops `next_chunk()` and advances the
18//! [`MicLifecycle`](crate::audio::MicLifecycle) — `Starting →
19//! Running` on first successful chunk.
20//! 4. `stop_mic_capture` drops the worker; `Drop` flips the cancel
21//! flag and joins the thread. The gst child is killed by
22//! `GstreamerAudioCapture`'s own `Drop` impl (per CLAUDE.md
23//! "Drop-kill the child" pattern).
24//!
25//! NOT yet shipped:
26//!
27//! * **Per-device selection** — `autoaudiosrc` always opens the OS
28//! default; the `mic_id` parameter is plumbed and logged but not
29//! yet used to pick a specific input (deferred to a follow-up;
30//! pattern is `osxaudiosrc device-uid=…` on macOS,
31//! `pulsesrc device=…` on Linux).
32//! * **RMS event emission to Leptos** — the chunks are pulled and
33//! dropped. M-MIC.2 wires the `audio-levels` Tauri event when it
34//! needs the meter.
35//! * **Encode path** — M-RECORD multiplexes mic PCM into the
36//! final encoded stream.
37//! ```
38//!
39//! Thread-affinity contract — `GstreamerAudioCapture` owns a
40//! `std::process::Child` + a `ChildStdout` reader. Both are `Send`,
41//! so the worker thread can own the stream exclusively. No `Rc` /
42//! `RefCell` anywhere in the type, safe to move into a spawned
43//! thread.
44
45use std::sync::Arc;
46use std::sync::atomic::{AtomicBool, Ordering};
47use std::thread::{self, JoinHandle};
48
49use media::audio::AudioFormat;
50use media::gstreamer_audio::GstreamerAudioCapture;
51
52use super::{MicCaptureState, MicError};
53use crate::recording::SharedAudioMixer;
54
55/// Native sample rate the worker requests from gst. `audioresample`
56/// converts on the input side if the device doesn't natively support
57/// it. 48 kHz matches the recorder's encoder target + the
58/// `M-MIC.0` device-enumeration default for `sample_rate_hz == 0`.
59pub const MIC_SAMPLE_RATE: u32 = 48_000;
60
61/// Native channel count the worker requests. 2 = stereo —
62/// `audioconvert` upmixes mono inputs and downmixes higher-count
63/// inputs cleanly. Matches the `M-MIC.0` device-enumeration default
64/// for `channels == 0`.
65pub const MIC_CHANNELS: u8 = 2;
66
67/// Frames per `next_chunk` call. 2400 frames @ 48 kHz = 50 ms of
68/// audio per chunk — gives the M-AUDIO.METER / AUT-287 audio-level
69/// meter ~20 Hz update cadence (one chunk → one RMS sample → one
70/// `mic-level` event). Previously 4800 (100 ms / 10 Hz); reduced for
71/// meter smoothness without measurable IPC overhead.
72pub const MIC_CHUNK_FRAMES: u64 = 2_400;
73
74/// EMA smoothing factor for the mic level meter (M-AUDIO.METER /
75/// AUT-287). Higher = more reactive to transients; lower = smoother.
76/// 0.3 balances "responds visibly when you speak" against
77/// "doesn't flicker on micro-pauses."
78pub const MIC_LEVEL_EMA_ALPHA: f32 = 0.3;
79
80// Compile-time invariants for the mic-pipeline constants. These fire
81// at compile time (zero runtime cost) and fail the build if a future
82// edit drifts the values, instead of just failing a test — same
83// pattern as `crate::preview::pipeline`'s `const _: () =
84// assert!(..)` guards.
85const _: () = assert!(
86 MIC_SAMPLE_RATE == 48_000,
87 "MIC_SAMPLE_RATE must be 48000 — the encoder + downstream resampler assume 48 kHz"
88);
89const _: () = assert!(
90 MIC_CHANNELS == 2,
91 "MIC_CHANNELS must be 2 — audioconvert handles mono/multi inputs but the chunk \
92 layout downstream assumes interleaved stereo"
93);
94const _: () = assert!(
95 MIC_CHUNK_FRAMES == 2_400,
96 "MIC_CHUNK_FRAMES must equal 50 ms @ MIC_SAMPLE_RATE for the M-AUDIO.METER 20 Hz update cadence"
97);
98
99/// Mic-pipeline worker handle. Owns the spawned thread and a
100/// cooperative cancel flag; `Drop` cancels + joins so a panicking
101/// caller can never leave a zombie gst child behind.
102pub struct MicCapturePipeline {
103 cancel: Arc<AtomicBool>,
104 handle: Option<JoinHandle<()>>,
105}
106
107impl MicCapturePipeline {
108 /// Spawn the worker thread. Returns immediately; the worker
109 /// transitions [`MicLifecycle`] to `Running` once
110 /// `next_chunk()` first succeeds (after any macOS permission
111 /// prompt resolves).
112 ///
113 /// `mixer` controls whether the worker forwards samples into the
114 /// recorder's shared [`AudioMixer`]. Pass `None` for preview
115 /// (meter only — what the picker uses); pass `Some(mixer)` for
116 /// recording. Mirror of the SCK system-audio
117 /// [`start`](crate::system_audio::SystemAudioCaptureState::start)
118 /// vs. [`start_with_mixer`](crate::system_audio::SystemAudioCaptureState::start_with_mixer)
119 /// split. Without this distinction, preview samples accumulate
120 /// in the mixer and contaminate the next recording.
121 ///
122 /// # Errors
123 ///
124 /// Returns `Err` only if the OS refuses to spawn a thread
125 /// (effectively never happens). gst-side errors are handled
126 /// inside the worker — the worker logs via `tracing::error!`
127 /// and resets the lifecycle to `Idle` so the UI shows a recovery
128 /// state.
129 pub fn spawn(
130 app: tauri::AppHandle,
131 mic_id: String,
132 native_id: String,
133 mixer: Option<SharedAudioMixer>,
134 ) -> Result<Self, MicError> {
135 let cancel = Arc::new(AtomicBool::new(false));
136 let cancel_for_thread = Arc::clone(&cancel);
137 let handle = thread::Builder::new()
138 .name("mic-capture".to_owned())
139 .spawn(move || {
140 run_pipeline(
141 &app,
142 &mic_id,
143 &native_id,
144 &cancel_for_thread,
145 mixer.as_ref(),
146 );
147 })
148 .map_err(|err| MicError::GstFailed(format!("thread spawn failed: {err}")))?;
149 Ok(Self {
150 cancel,
151 handle: Some(handle),
152 })
153 }
154}
155
156impl Drop for MicCapturePipeline {
157 fn drop(&mut self) {
158 self.cancel.store(true, Ordering::Relaxed);
159 if let Some(handle) = self.handle.take() {
160 // Best-effort join; if the worker panicked we don't
161 // care — we're tearing down.
162 let _ = handle.join();
163 }
164 }
165}
166
167/// The actual worker loop. `&` borrows let the public-facing
168/// `spawn` move cloned values onto the thread without keeping
169/// `Self` alive on the thread.
170///
171/// `mixer` is `None` for preview (meter only) and `Some` for
172/// recording — see [`MicCapturePipeline::spawn`].
173fn run_pipeline(
174 app: &tauri::AppHandle,
175 mic_id: &str,
176 native_id: &str,
177 cancel: &AtomicBool,
178 mixer: Option<&SharedAudioMixer>,
179) {
180 let format = AudioFormat::stereo_f32(MIC_SAMPLE_RATE);
181 let mut capture = match GstreamerAudioCapture::from_microphone(mic_id, native_id, format) {
182 Ok(cap) => cap,
183 Err(err) => {
184 tracing::error!(?err, mic_id, native_id, "from_microphone failed");
185 reset_lifecycle(app);
186 return;
187 }
188 };
189 tracing::info!(
190 mic_id,
191 native_id,
192 sample_rate = MIC_SAMPLE_RATE,
193 channels = MIC_CHANNELS,
194 forwards_to_mixer = mixer.is_some(),
195 "mic-capture opened; awaiting first chunk"
196 );
197
198 // M-AUDIO.METER / AUT-287 — running EMA-smoothed RMS so the
199 // Leptos meter doesn't flicker on transients. Emitted to the
200 // webview via the `mic-level` Tauri event on every chunk
201 // (~20 Hz at MIC_CHUNK_FRAMES = 50 ms).
202 let mut smoothed_level: f32 = 0.0;
203
204 while !cancel.load(Ordering::Relaxed) {
205 match capture.next_chunk(MIC_CHUNK_FRAMES) {
206 Ok(chunk) => {
207 advance_to_running(app);
208 // Linear RMS is useless for a 10-bar meter — typical
209 // speech sits at ~0.03 RMS and would only light the
210 // first bar. `rms_to_meter_level` maps to dBFS so
211 // conversational speech lands near the middle.
212 let raw_level = media::audio::rms_to_meter_level(chunk.rms());
213 smoothed_level =
214 MIC_LEVEL_EMA_ALPHA * raw_level + (1.0 - MIC_LEVEL_EMA_ALPHA) * smoothed_level;
215 emit_mic_level(app, smoothed_level);
216 // M-PIX.3 — feed the mixer. The mic worker emits
217 // stereo F32LE matching the mixer's default channel
218 // count; alignment is enforced by the mixer.
219 if let Some(mixer_arc) = mixer {
220 let mut mixer_guard = mixer_arc
221 .lock()
222 .unwrap_or_else(std::sync::PoisonError::into_inner);
223 if let Err(err) = mixer_guard.push_mic(chunk.samples()) {
224 // Misalignment shouldn't happen — chunk is
225 // already validated — but warn instead of
226 // panic so a stray sample-count anomaly
227 // doesn't kill the worker.
228 tracing::warn!(?err, "AudioMixer::push_mic rejected chunk");
229 }
230 }
231 drop(chunk);
232 }
233 Err(err) => {
234 tracing::warn!(?err, "next_chunk errored; tearing down");
235 break;
236 }
237 }
238 }
239
240 tracing::info!("mic-capture cancel observed; shutting down");
241 reset_lifecycle(app);
242 // `capture` drops here → gst-launch child killed + reaped per
243 // CLAUDE.md's "Drop-kill the child" pattern.
244 drop(capture);
245}
246
247/// Push the smoothed mic level to the webview via the `mic-level`
248/// Tauri event (M-AUDIO.METER / AUT-287). Failures are swallowed +
249/// `tracing::trace!`'d — at 20 Hz a missed emit is invisible, and
250/// surfacing the error to the worker loop would break audio
251/// capture for cosmetic event-bus issues.
252fn emit_mic_level(app: &tauri::AppHandle, level: f32) {
253 use tauri::Emitter;
254 if let Err(err) = app.emit("mic-level", level) {
255 tracing::trace!(?err, "emit mic-level failed");
256 }
257}
258
259/// Mark the mic lifecycle as `Running` (idempotent — already-
260/// `Running` stays running). Called on each successful chunk; the
261/// `mark_running` transition is idempotent so we don't need a
262/// "first chunk" guard.
263fn advance_to_running(app: &tauri::AppHandle) {
264 use tauri::Manager;
265 let state = app.state::<MicCaptureState>();
266 let mut guard = state
267 .0
268 .lock()
269 .unwrap_or_else(std::sync::PoisonError::into_inner);
270 let next = guard.mark_running();
271 if *guard != next {
272 tracing::info!("mic lifecycle: Starting → Running (first chunk received)");
273 *guard = next;
274 }
275}
276
277/// Drive the lifecycle back to `Idle` on shutdown OR on gst-side
278/// startup failure (no mic attached, permission denied, etc.).
279fn reset_lifecycle(app: &tauri::AppHandle) {
280 use tauri::Manager;
281 let state = app.state::<MicCaptureState>();
282 let mut guard = state
283 .0
284 .lock()
285 .unwrap_or_else(std::sync::PoisonError::into_inner);
286 *guard = guard.try_stop().finish_stop();
287}
288
289/// Tauri-managed handle for the active mic pipeline. Mirror of
290/// [`crate::preview::CameraPipelineHandle`]. Wrapping
291/// `Option<MicCapturePipeline>` in a `Mutex` rather than an
292/// `AtomicCell` keeps the dep surface small; contention is bounded
293/// by user start/stop clicks, which can't race meaningfully.
294#[derive(Default)]
295pub struct MicCaptureHandle(pub std::sync::Mutex<Option<MicCapturePipeline>>);
296
297impl MicCaptureHandle {
298 /// Install a freshly-spawned pipeline. If one was already
299 /// running, it's dropped first (which kills + joins the previous
300 /// worker before the new one starts). Idempotent under
301 /// concurrent calls — the mutex serialises the swap.
302 pub fn install(&self, pipeline: MicCapturePipeline) {
303 let mut guard = self
304 .0
305 .lock()
306 .unwrap_or_else(std::sync::PoisonError::into_inner);
307 *guard = Some(pipeline);
308 }
309
310 /// Drop the active pipeline (which cancels + joins the worker).
311 /// No-op if no pipeline is active.
312 pub fn shutdown(&self) {
313 let mut guard = self
314 .0
315 .lock()
316 .unwrap_or_else(std::sync::PoisonError::into_inner);
317 *guard = None;
318 }
319
320 /// `true` if a worker is currently held. Used by tests +
321 /// diagnostics; [`MicLifecycle`] is the source of truth for UI
322 /// state.
323 #[must_use]
324 pub fn is_active(&self) -> bool {
325 self.0
326 .lock()
327 .unwrap_or_else(std::sync::PoisonError::into_inner)
328 .is_some()
329 }
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335
336 #[test]
337 fn handle_starts_inactive_and_shutdown_is_noop() {
338 // Pure-state test — never actually spawns a worker (which
339 // would require a tauri::AppHandle, a real gst install, and
340 // a mic). Same shape as the M-CAM.3 handle smoke.
341 let handle = MicCaptureHandle::default();
342 assert!(!handle.is_active());
343
344 // Shutdown on an empty handle must not panic.
345 handle.shutdown();
346 assert!(!handle.is_active());
347 }
348
349 // MIC_SAMPLE_RATE / MIC_CHANNELS / MIC_CHUNK_FRAMES invariants
350 // are enforced at compile time via the `const _: () =
351 // assert!(..)` blocks above — they fail the build if a future
352 // edit drifts the values, which is a stronger guard than
353 // `#[test]`s ever were.
354}