Skip to main content

media/
gstreamer_audio.rs

1//! GStreamer audio capture (M-MEDIA.5 / AUT-101) — CLI-pipe pattern.
2//!
3//! Spawns `gst-launch-1.0` with a pipeline that emits normalized
4//! `F32LE` raw audio on stdout, then chunks the byte stream into
5//! [`AudioChunk`]s.
6//!
7//! # Two source modes
8//!
9//! ```text
10//! GstreamerAudioCapture::test_source(format, freq_hz)
11//!   → audiotestsrc wave=sine freq=F
12//!     ! audioconvert
13//!     ! audioresample
14//!     ! audio/x-raw,format=F32LE,rate=R,channels=C
15//!     ! fdsink fd=1
16//!
17//! GstreamerAudioCapture::from_file(path, format)
18//!   → filesrc location=PATH
19//!     ! decodebin
20//!     ! audioconvert
21//!     ! audioresample
22//!     ! audio/x-raw,format=F32LE,rate=R,channels=C
23//!     ! fdsink fd=1
24//! ```
25//!
26//! `test_source` is the AUT-101 deliverable. `from_file` is the
27//! companion fixture path used by M-MEDIA.8+ integration tests so the
28//! histogram + waveform code runs against real audio without
29//! depending on a microphone.
30//!
31//! # Lifecycle
32//!
33//! `Drop` kills the child + waits — without this, `gst-launch-1.0`
34//! keeps decoding into a dropped pipe and burns CPU. Matches the
35//! `decode::GstreamerPipeStream` pattern.
36
37use std::io::{ErrorKind, Read};
38use std::path::Path;
39use std::process::{Child, ChildStdout, Command, Stdio};
40
41use crate::audio::{AudioChunk, AudioChunkError, AudioFormat, SampleFormat};
42use crate::clock::MediaTime;
43
44/// Failure modes for the GStreamer audio capture pipe.
45#[derive(Debug, thiserror::Error)]
46pub enum Error {
47    /// `gst-launch-1.0` could not be launched. The `PATH` snapshot in
48    /// the message makes CI diagnoses easier.
49    #[error("failed to spawn `gst-launch-1.0`: {source} (PATH={path})")]
50    Spawn {
51        /// The OS-level reason the spawn failed.
52        #[source]
53        source: std::io::Error,
54        /// `$PATH` at the moment of failure.
55        path: String,
56    },
57    /// Stdout was not piped — shouldn't happen, indicates a misuse.
58    #[error("child stdout was not piped")]
59    NoStdout,
60    /// I/O error while reading from the child's stdout.
61    #[error("read error: {0}")]
62    Io(#[from] std::io::Error),
63    /// Pipeline ended (EOF) before the requested frames were read.
64    #[error("audio pipeline ended after {frames_read} of {frames_requested} frames")]
65    EndOfStream {
66        /// Frames actually delivered before EOF.
67        frames_read: u64,
68        /// Frames the caller asked for.
69        frames_requested: u64,
70    },
71    /// Constructed an `AudioChunk` that failed shape validation —
72    /// internal bug.
73    #[error("internal: built invalid AudioChunk: {0}")]
74    InvalidChunk(#[from] AudioChunkError),
75    /// Format is not currently supported by the capture pipeline.
76    /// Only `SampleFormat::F32` is supported because the pipeline
77    /// caps it to `F32LE` explicitly.
78    #[error("unsupported sample format {0:?} — only F32 is supported")]
79    UnsupportedFormat(SampleFormat),
80}
81
82/// Streaming audio capture wrapping a `gst-launch-1.0` child process.
83///
84/// Each [`Self::next_chunk`] call reads exactly `frames` frames of
85/// audio from the child's stdout, packages them as a normalized-`f32`
86/// [`AudioChunk`], and assigns the appropriate PTS so the chunks form
87/// a contiguous timeline.
88pub struct GstreamerAudioCapture {
89    child: Child,
90    stdout: ChildStdout,
91    format: AudioFormat,
92    next_frame: u64,
93    /// Pre-allocated scratch buffer for the raw bytes of one chunk.
94    /// Sized lazily on first `next_chunk`; reused after.
95    raw_buffer: Vec<u8>,
96}
97
98impl std::fmt::Debug for GstreamerAudioCapture {
99    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        f.debug_struct("GstreamerAudioCapture")
101            .field("format", &self.format)
102            .field("frames_emitted", &self.next_frame)
103            .finish_non_exhaustive()
104    }
105}
106
107impl GstreamerAudioCapture {
108    /// Build a capture from `audiotestsrc` at the given sine
109    /// `frequency_hz`. AUT-101's "deterministic GStreamer source"
110    /// path.
111    pub fn test_source(format: AudioFormat, frequency_hz: f32) -> Result<Self, Error> {
112        Self::reject_non_f32(format)?;
113        let caps = caps_string(format);
114        let freq_arg = format!("freq={frequency_hz}");
115        Self::spawn(
116            &[
117                "-q",
118                "audiotestsrc",
119                "wave=sine",
120                &freq_arg,
121                "is-live=false",
122                "!",
123                "audioconvert",
124                "!",
125                "audioresample",
126                "!",
127                &caps,
128                "!",
129                "fdsink",
130                "fd=1",
131            ],
132            format,
133        )
134    }
135
136    /// Build a capture by decoding an audio file through GStreamer.
137    /// Companion to [`Self::test_source`] for tests that want to
138    /// exercise the histogram / waveform code against real audio.
139    pub fn from_file(path: &Path, format: AudioFormat) -> Result<Self, Error> {
140        Self::reject_non_f32(format)?;
141        let caps = caps_string(format);
142        let location = format!("location={}", path.display());
143        Self::spawn(
144            &[
145                "-q",
146                "filesrc",
147                &location,
148                "!",
149                "decodebin",
150                "!",
151                "audioconvert",
152                "!",
153                "audioresample",
154                "!",
155                &caps,
156                "!",
157                "fdsink",
158                "fd=1",
159            ],
160            format,
161        )
162    }
163
164    /// Build a capture from a specific microphone via the
165    /// per-OS gst element (M-MIC.1 / AUT-278 + M-MIC.3 / AUT-284).
166    ///
167    /// - macOS: `osxaudiosrc unique-id=<native_id>`
168    /// - Linux: `pulsesrc device=<native_id>`
169    /// - Windows: `wasapisrc device=<native_id>`
170    ///
171    /// When `native_id` is empty (the device didn't expose
172    /// `unique-id` in gst-device-monitor output, OR the caller
173    /// wants the OS default), the pipeline falls back to
174    /// `autoaudiosrc` which opens the OS default mic.
175    ///
176    /// `format` must use [`SampleFormat::F32`] — the pipeline caps
177    /// to `F32LE` explicitly. Non-`F32` rejects at construction (same
178    /// shape as [`Self::test_source`]).
179    ///
180    /// ```admonish note title="Format choice differs from the ticket prose"
181    /// AUT-278 originally described the pipeline as
182    /// `…audio/x-raw,format=S16LE,…`. The capture infra around this
183    /// type is F32-only (see [`Self::reject_non_f32`] and
184    /// [`AudioChunk`] normalisation); reusing it required F32LE.
185    /// `audioresample` + `audioconvert` in the pipeline handle the
186    /// downstream conversion when a future encoder wants S16.
187    /// ```
188    pub fn from_microphone(
189        mic_id: &str,
190        native_id: &str,
191        format: AudioFormat,
192    ) -> Result<Self, Error> {
193        Self::reject_non_f32(format)?;
194        let caps = caps_string(format);
195        let mut args: Vec<String> = vec!["-q".to_string()];
196        if let Some((element, prop)) = resolve_mic_element(native_id) {
197            let prop_arg = format!("{prop}={native_id}");
198            args.push(element.to_string());
199            args.push(prop_arg);
200            tracing::info!(
201                mic_id,
202                native_id,
203                element,
204                sample_rate = format.sample_rate,
205                channels = format.channels,
206                "from_microphone: spawning gst-launch with per-device element"
207            );
208        } else {
209            args.push("autoaudiosrc".to_string());
210            tracing::info!(
211                mic_id,
212                has_native_id = !native_id.is_empty(),
213                sample_rate = format.sample_rate,
214                channels = format.channels,
215                "from_microphone: spawning gst-launch (autoaudiosrc — OS default)"
216            );
217        }
218        args.extend(
219            [
220                "!",
221                "audioconvert",
222                "!",
223                "audioresample",
224                "!",
225                &caps,
226                "!",
227                "fdsink",
228                "fd=1",
229            ]
230            .iter()
231            .map(|s| (*s).to_string()),
232        );
233        let args_ref: Vec<&str> = args.iter().map(String::as_str).collect();
234        Self::spawn(&args_ref, format)
235    }
236
237    /// Format of the produced chunks (same as the format passed at
238    /// construction).
239    #[must_use]
240    pub fn format(&self) -> AudioFormat {
241        self.format
242    }
243
244    /// Cumulative frames emitted across `next_chunk` calls. Used by
245    /// the sync harness (M-MEDIA.7) to assert against expected
246    /// per-source counts.
247    #[must_use]
248    pub fn frames_emitted(&self) -> u64 {
249        self.next_frame
250    }
251
252    /// Read exactly `frames` frames of audio. Returns an
253    /// [`AudioChunk`] with the PTS pointing at the first sample.
254    ///
255    /// # Errors
256    ///
257    /// - [`Error::Io`] on read failures.
258    /// - [`Error::EndOfStream`] if the pipeline ends before `frames`
259    ///   are delivered (returns the partial count for diagnosis).
260    pub fn next_chunk(&mut self, frames: u64) -> Result<AudioChunk, Error> {
261        let bytes_per_frame = usize::from(self.format.channels) * 4; // f32 = 4 bytes
262        let frames_usize = usize::try_from(frames).expect("frames fits usize");
263        let need = frames_usize * bytes_per_frame;
264        if self.raw_buffer.len() < need {
265            self.raw_buffer.resize(need, 0);
266        }
267        let slice = &mut self.raw_buffer[..need];
268        let mut read = 0;
269        while read < need {
270            match self.stdout.read(&mut slice[read..]) {
271                Ok(0) => {
272                    let read_frames = (read / bytes_per_frame) as u64;
273                    return Err(Error::EndOfStream {
274                        frames_read: self.next_frame + read_frames,
275                        frames_requested: self.next_frame + frames,
276                    });
277                }
278                Ok(n) => read += n,
279                Err(e) if e.kind() == ErrorKind::Interrupted => {}
280                Err(e) => return Err(Error::Io(e)),
281            }
282        }
283
284        // Decode little-endian f32 samples.
285        let mut samples = Vec::with_capacity(frames_usize * usize::from(self.format.channels));
286        for chunk in slice.chunks_exact(4) {
287            let arr: [u8; 4] = chunk.try_into().expect("chunks_exact(4) yields [u8; 4]");
288            samples.push(f32::from_le_bytes(arr));
289        }
290
291        let pts = MediaTime::from_sample(self.next_frame, self.format.sample_rate);
292        let out = AudioChunk::new(self.format, samples, pts)?;
293        self.next_frame = self.next_frame.saturating_add(frames);
294        Ok(out)
295    }
296
297    fn spawn(args: &[&str], format: AudioFormat) -> Result<Self, Error> {
298        let mut cmd = Command::new("gst-launch-1.0");
299        cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::null());
300        let mut child = cmd.spawn().map_err(|source| Error::Spawn {
301            source,
302            path: std::env::var("PATH").unwrap_or_else(|_| "<unset>".into()),
303        })?;
304        let stdout = child.stdout.take().ok_or(Error::NoStdout)?;
305        Ok(Self {
306            child,
307            stdout,
308            format,
309            next_frame: 0,
310            raw_buffer: Vec::new(),
311        })
312    }
313
314    fn reject_non_f32(format: AudioFormat) -> Result<(), Error> {
315        if !matches!(format.sample_format, SampleFormat::F32) {
316            return Err(Error::UnsupportedFormat(format.sample_format));
317        }
318        Ok(())
319    }
320}
321
322impl Drop for GstreamerAudioCapture {
323    fn drop(&mut self) {
324        // Kill the child + wait; without this gst-launch-1.0 keeps
325        // decoding into a dropped pipe.
326        let _ = self.child.kill();
327        let _ = self.child.wait();
328    }
329}
330
331fn caps_string(format: AudioFormat) -> String {
332    // Audio raw caps. F32LE means little-endian normalized float —
333    // matches AudioChunk::samples shape, no extra conversion.
334    format!(
335        "audio/x-raw,format=F32LE,rate={rate},channels={channels},layout=interleaved",
336        rate = format.sample_rate,
337        channels = format.channels,
338    )
339}
340
341/// Pick the per-OS gst element + property name that takes the
342/// device's native identifier (M-MIC.3 / AUT-284). Returns `None`
343/// when `native_id` is empty OR when the current target OS isn't
344/// one we know an element name for — callers fall back to
345/// `autoaudiosrc` in that case.
346///
347/// | OS      | Element        | Property     |
348/// | ------- | -------------- | ------------ |
349/// | macOS   | `osxaudiosrc`  | `unique-id`  |
350/// | Linux   | `pulsesrc`     | `device`     |
351/// | Windows | `wasapisrc`    | `device`     |
352///
353/// On macOS the string device-selection prop is `unique-id`, NOT
354/// `device-uid` — the latter is not a property of `osxaudiosrc` and
355/// makes `gst-launch-1.0` reject the pipeline with zero bytes of
356/// audio output, which the mic worker observes as `EndOfStream` on
357/// the first read.
358#[must_use]
359pub fn resolve_mic_element(native_id: &str) -> Option<(&'static str, &'static str)> {
360    if native_id.is_empty() {
361        return None;
362    }
363    #[cfg(target_os = "macos")]
364    {
365        Some(("osxaudiosrc", "unique-id"))
366    }
367    #[cfg(target_os = "linux")]
368    {
369        Some(("pulsesrc", "device"))
370    }
371    #[cfg(target_os = "windows")]
372    {
373        Some(("wasapisrc", "device"))
374    }
375    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
376    {
377        None
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[test]
386    fn caps_string_includes_rate_channels_format() {
387        let s = caps_string(AudioFormat::stereo_f32(48_000));
388        assert!(s.contains("rate=48000"));
389        assert!(s.contains("channels=2"));
390        assert!(s.contains("format=F32LE"));
391    }
392
393    #[test]
394    fn non_f32_format_is_rejected_at_construction() {
395        let fmt = AudioFormat {
396            sample_rate: 48_000,
397            channels: 1,
398            sample_format: SampleFormat::I16,
399        };
400        let err = GstreamerAudioCapture::test_source(fmt, 440.0).unwrap_err();
401        assert!(matches!(err, Error::UnsupportedFormat(SampleFormat::I16)));
402    }
403
404    // Send + Sync — capture can be passed across threads as long as
405    // the consumer takes &mut.
406    #[test]
407    fn capture_is_send() {
408        fn assert_send<T: Send>() {}
409        assert_send::<GstreamerAudioCapture>();
410    }
411
412    /// Anti-regression: `osxaudiosrc` has no `device-uid` property
413    /// (its string device-selection prop is `unique-id`). The wrong
414    /// name makes gst-launch reject the pipeline at parse time and
415    /// produces zero audio bytes — observed downstream as a silent
416    /// recording with no audio stream in the final `.mp4`.
417    #[cfg(target_os = "macos")]
418    #[test]
419    fn resolve_mic_element_macos_returns_unique_id_not_device_uid() {
420        let (element, prop) = resolve_mic_element("BuiltInMicrophoneDevice").expect("macOS arm");
421        assert_eq!(element, "osxaudiosrc");
422        assert_eq!(prop, "unique-id");
423        assert_ne!(prop, "device-uid");
424    }
425
426    #[test]
427    fn resolve_mic_element_empty_native_id_returns_none() {
428        assert!(resolve_mic_element("").is_none());
429    }
430}