Skip to main content

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}