Skip to main content

xil_audio/
ffmpeg.rs

1//! Decoding and probing through ffmpeg subprocesses.
2//!
3//! pydub shells out to ffmpeg too, so going through the same binary is
4//! what keeps the samples identical rather than merely close. Symphonia
5//! would decode MP3 slightly differently and there would be no way to
6//! tell a port bug from a decoder difference.
7
8use std::path::Path;
9use std::process::Command;
10
11use crate::pcm::Pcm;
12
13/// What ffprobe reports about a stream, before any decoding.
14#[derive(Debug, Clone, PartialEq)]
15pub struct Probe {
16    pub channels: u16,
17    pub frame_rate: u32,
18    /// Container duration in seconds, when the stream declares one.
19    pub duration_s: Option<f64>,
20    /// Stream bit rate in bits per second, when declared.
21    pub bit_rate: Option<u64>,
22}
23
24#[derive(Debug, thiserror::Error)]
25pub enum AudioError {
26    #[error("ffmpeg is not installed or not on PATH")]
27    FfmpegMissing,
28    /// pydub's `CouldntDecodeError`, worded exactly as it is — the whole
29    /// string reaches the operator through a warning line, ffmpeg's own
30    /// stderr included.
31    #[error("Decoding failed. ffmpeg returned error code: {code}\n\nOutput from ffmpeg/avlib:\n\n{stderr}")]
32    CouldntDecode { code: i32, stderr: String },
33    /// pydub's `CouldntEncodeError` (the command itself is left out: it
34    /// names temporary files).
35    #[error("Encoding failed. ffmpeg/avlib returned error code: {code}\n\nOutput from ffmpeg/avlib:\n\n{stderr}")]
36    CouldntEncode { code: i32, stderr: String },
37    #[error("{path}: {what}")]
38    Malformed { path: String, what: String },
39}
40
41/// Read the first audio stream's format without decoding it.
42pub fn probe(path: &Path) -> Result<Probe, AudioError> {
43    let out = Command::new("ffprobe")
44        .args([
45            "-v",
46            "quiet",
47            "-select_streams",
48            "a:0",
49            "-show_entries",
50            "stream=channels,sample_rate,duration,bit_rate",
51            "-of",
52            "default=noprint_wrappers=1:nokey=0",
53        ])
54        .arg(path)
55        .output()
56        .map_err(|_| AudioError::FfmpegMissing)?;
57    if !out.status.success() {
58        return Err(AudioError::Malformed {
59            path: path.display().to_string(),
60            what: "ffprobe could not read it".into(),
61        });
62    }
63    let text = String::from_utf8_lossy(&out.stdout);
64    let field = |k: &str| -> Option<String> {
65        text.lines()
66            .find_map(|l| l.strip_prefix(&format!("{k}=")))
67            .map(str::to_string)
68            .filter(|v| v != "N/A")
69    };
70    let channels = field("channels")
71        .and_then(|v| v.parse().ok())
72        .ok_or_else(|| AudioError::Malformed {
73            path: path.display().to_string(),
74            what: "no audio stream".into(),
75        })?;
76    let frame_rate = field("sample_rate")
77        .and_then(|v| v.parse().ok())
78        .ok_or_else(|| AudioError::Malformed {
79            path: path.display().to_string(),
80            what: "no sample rate".into(),
81        })?;
82    Ok(Probe {
83        channels,
84        frame_rate,
85        duration_s: field("duration").and_then(|v| v.parse().ok()),
86        bit_rate: field("bit_rate").and_then(|v| v.parse().ok()),
87    })
88}
89
90/// Decode to interleaved 16-bit PCM at the file's own rate and channel
91/// count — the same shape `AudioSegment.from_file` produces.
92///
93/// The command line is pydub's, down to the order of the arguments and
94/// the conditional `-acodec`, because on failure ffmpeg's stderr is part
95/// of the error the operator sees. A leading `-v quiet` would silence
96/// exactly the text that has to match.
97pub fn decode(path: &Path) -> Result<Pcm, AudioError> {
98    let mut cmd = Command::new("ffmpeg");
99    cmd.arg("-y").arg("-i").arg(path);
100    // pydub probes first and forces a codec only when the probe found a
101    // stream; a file that cannot be probed gets no -acodec, and that
102    // shapes the stderr it fails with.
103    if probe(path).is_ok() {
104        cmd.args(["-acodec", "pcm_s16le"]);
105    }
106    cmd.args(["-vn", "-f", "wav", "-"]);
107
108    let out = cmd.output().map_err(|_| AudioError::FfmpegMissing)?;
109    if !out.status.success() || out.stdout.is_empty() {
110        return Err(AudioError::CouldntDecode {
111            code: out.status.code().unwrap_or(-1),
112            stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
113        });
114    }
115    parse_wav(&out.stdout).ok_or_else(|| AudioError::Malformed {
116        path: path.display().to_string(),
117        what: "ffmpeg produced an unreadable WAV".into(),
118    })
119}
120
121/// Pull format and samples out of a RIFF/WAVE buffer.
122///
123/// ffmpeg writes a placeholder size when the output is a pipe, so the
124/// declared `data` length is not trustworthy — whatever follows the
125/// chunk header is the audio.
126fn parse_wav(buf: &[u8]) -> Option<Pcm> {
127    if buf.len() < 12 || &buf[0..4] != b"RIFF" || &buf[8..12] != b"WAVE" {
128        return None;
129    }
130    let u16at = |i: usize| u16::from_le_bytes([buf[i], buf[i + 1]]);
131    let u32at = |i: usize| u32::from_le_bytes([buf[i], buf[i + 1], buf[i + 2], buf[i + 3]]);
132
133    let (mut channels, mut rate, mut bits) = (0u16, 0u32, 0u16);
134    let mut pos = 12usize;
135    while pos + 8 <= buf.len() {
136        let id = &buf[pos..pos + 4];
137        let declared = u32at(pos + 4) as usize;
138        let body = pos + 8;
139        if id == b"fmt " && body + 16 <= buf.len() {
140            channels = u16at(body + 2);
141            rate = u32at(body + 4);
142            bits = u16at(body + 14);
143        } else if id == b"data" {
144            let end = match body.checked_add(declared) {
145                Some(e) if e <= buf.len() => e,
146                _ => buf.len(), // a streamed WAV declares a bogus size
147            };
148            if channels == 0 || rate == 0 || bits != 16 {
149                return None;
150            }
151            return Some(Pcm::from_s16le(&buf[body..end], channels, rate));
152        }
153        // Chunks are word-aligned.
154        pos = body + declared + (declared & 1);
155    }
156    None
157}
158
159/// Is ffmpeg reachable at all? Cheap enough to call before a batch.
160pub fn available() -> bool {
161    Command::new("ffmpeg")
162        .arg("-version")
163        .output()
164        .is_ok_and(|o| o.status.success())
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    /// Build a deterministic MP3 with ffmpeg's own signal generators, so
172    /// the test needs no committed binary and no network.
173    fn make_mp3(dir: &Path, name: &str, filter: &str, extra: &[&str]) -> std::path::PathBuf {
174        let out = dir.join(name);
175        let status = Command::new("ffmpeg")
176            .args(["-v", "quiet", "-y", "-f", "lavfi", "-i", filter])
177            .args(extra)
178            .arg(&out)
179            .status()
180            .expect("ffmpeg runs");
181        assert!(status.success(), "generating {name}");
182        out
183    }
184
185    #[test]
186    fn decodes_at_the_files_own_rate_and_channels() {
187        if !available() {
188            return; // CI installs ffmpeg; a dev box without it skips.
189        }
190        let tmp = tempfile::tempdir().unwrap();
191        let mono = make_mp3(
192            tmp.path(),
193            "mono.mp3",
194            "sine=frequency=440:duration=1:sample_rate=22050",
195            &["-ac", "1"],
196        );
197        let p = probe(&mono).unwrap();
198        assert_eq!((p.channels, p.frame_rate), (1, 22050));
199
200        let pcm = decode(&mono).unwrap();
201        assert_eq!(pcm.channels, 1);
202        assert_eq!(pcm.frame_rate, 22050);
203        // An MP3 decode adds encoder padding, so the length is near but
204        // not exactly the requested second.
205        assert!((pcm.len_ms() - 1000).abs() < 100, "{} ms", pcm.len_ms());
206        // ffmpeg's sine generator is well below full scale; the point
207        // is that real signal came through, not silence.
208        assert!(pcm.max_abs() > 1000, "peak was {}", pcm.max_abs());
209        assert!(
210            pcm.dbfs() < 0.0 && pcm.dbfs() > -40.0,
211            "dBFS was {}",
212            pcm.dbfs()
213        );
214    }
215
216    #[test]
217    fn a_corrupt_file_reports_pydubs_message() {
218        if !available() {
219            return;
220        }
221        let tmp = tempfile::tempdir().unwrap();
222        let bad = tmp.path().join("bad.mp3");
223        std::fs::write(&bad, b"ID3\x03\x00door").unwrap();
224        let err = decode(&bad).unwrap_err().to_string();
225        assert!(
226            err.starts_with("Decoding failed. ffmpeg returned error code: 183"),
227            "{err}"
228        );
229        assert!(
230            err.contains("\n\nOutput from ffmpeg/avlib:\n\n"),
231            "the stderr block is part of the message"
232        );
233    }
234
235    #[test]
236    fn silence_decodes_to_silence() {
237        if !available() {
238            return;
239        }
240        let tmp = tempfile::tempdir().unwrap();
241        let quiet = make_mp3(tmp.path(), "quiet.mp3", "anullsrc=r=8000:cl=mono:d=1", &[]);
242        let pcm = decode(&quiet).unwrap();
243        assert_eq!(pcm.max_abs(), 0);
244        assert_eq!(pcm.dbfs(), f64::NEG_INFINITY);
245    }
246
247    #[test]
248    fn a_missing_file_is_an_error_not_a_panic() {
249        if !available() {
250            return;
251        }
252        let err = decode(Path::new("/nonexistent/nope.mp3")).unwrap_err();
253        assert!(matches!(err, AudioError::CouldntDecode { .. }), "{err}");
254        assert!(
255            err.to_string()
256                .starts_with("Decoding failed. ffmpeg returned error code: "),
257            "{err}"
258        );
259    }
260}