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}