1use std::path::Path;
9use std::process::Command;
10
11use crate::pcm::Pcm;
12
13#[derive(Debug, Clone, PartialEq)]
15pub struct Probe {
16 pub channels: u16,
17 pub frame_rate: u32,
18 pub duration_s: Option<f64>,
20 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 #[error("Decoding failed. ffmpeg returned error code: {code}\n\nOutput from ffmpeg/avlib:\n\n{stderr}")]
32 CouldntDecode { code: i32, stderr: String },
33 #[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
41pub 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
90pub fn decode(path: &Path) -> Result<Pcm, AudioError> {
98 let mut cmd = Command::new("ffmpeg");
99 cmd.arg("-y").arg("-i").arg(path);
100 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
121fn 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(), };
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 pos = body + declared + (declared & 1);
155 }
156 None
157}
158
159pub 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 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; }
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 assert!((pcm.len_ms() - 1000).abs() < 100, "{} ms", pcm.len_ms());
206 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}