aede_core/copy/transcode.rs
1//! Converting audio on the way out, by driving ffmpeg.
2//!
3//! **ffmpeg is an external program, not a dependency.** It is looked for, run,
4//! and its absence reported in a sentence rather than as a mysterious failure.
5//! The rule in `CLAUDE.md` about adding crates is not being bent here: nothing
6//! is linked, nothing is vendored, and a build without ffmpeg installed is a
7//! build where every other command still works.
8//!
9//! This is the first thing in the project that **writes audio**, and the line is
10//! worth guarding: it writes new files, under a folder the user named, and it
11//! never opens a source file for writing. Writing tags into a file produced here
12//! is not the tag-rewriting the project refuses — that refusal protects the
13//! user's own files, whose modification date, integrity verdict and scan state
14//! all depend on not being touched. A file that did not exist a second ago has
15//! none of those.
16
17use std::collections::BTreeMap;
18use std::path::Path;
19use std::process::Command;
20
21/// A format a copy can be converted into.
22///
23/// Deliberately short. Every entry here is a format a portable player is likely
24/// to want, and each one costs a decoder somebody has to have: adding a target
25/// nobody asked for is a promise to keep it working.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Target {
28 /// The one every device plays.
29 Mp3,
30 /// Best quality per byte at low bitrates, and what a modern player wants.
31 Opus,
32 /// What Apple devices prefer, in an MP4 container.
33 Aac,
34 /// The free lossy format Ogg carries.
35 Vorbis,
36 /// Lossless, and much smaller than WAV: the reason to convert a WAV rip.
37 Flac,
38 /// Uncompressed, for a device that plays nothing else.
39 Wav,
40}
41
42/// One row per target: how it is typed, what it produces, and how it is asked
43/// for on the command line.
44///
45/// A table rather than a `match` in four places, for the reason the role
46/// vocabulary is one: a format added in one place and forgotten in another is
47/// a target that half-works.
48const TARGETS: &[(&str, Target, &str, &str, bool)] = &[
49 // typed, target, extension, ffmpeg codec, lossless
50 ("mp3", Target::Mp3, "mp3", "libmp3lame", false),
51 ("opus", Target::Opus, "opus", "libopus", false),
52 ("aac", Target::Aac, "m4a", "aac", false),
53 ("m4a", Target::Aac, "m4a", "aac", false),
54 ("vorbis", Target::Vorbis, "ogg", "libvorbis", false),
55 ("ogg", Target::Vorbis, "ogg", "libvorbis", false),
56 ("flac", Target::Flac, "flac", "flac", true),
57 ("wav", Target::Wav, "wav", "pcm", true),
58];
59
60impl Target {
61 /// The target a typed word names, or `None` for a word that names none.
62 pub fn parse(word: &str) -> Option<Target> {
63 let wanted = crate::text::normalize(word);
64 TARGETS
65 .iter()
66 .find(|(name, ..)| *name == wanted)
67 .map(|(_, target, ..)| *target)
68 }
69
70 /// Every spelling accepted, for a message that offers what it refuses.
71 pub fn names() -> String {
72 let mut names: Vec<&str> = TARGETS.iter().map(|(name, ..)| *name).collect();
73 names.dedup();
74 names.join(", ")
75 }
76
77 /// The targets that have a quality setting at all.
78 ///
79 /// Read from the same table as everything else, so a format added tomorrow
80 /// appears here without anybody remembering to add it — which is the whole
81 /// reason the table exists.
82 pub fn lossy_names() -> String {
83 TARGETS
84 .iter()
85 .filter(|(_, _, _, _, lossless)| !lossless)
86 .map(|(name, ..)| *name)
87 .collect::<Vec<_>>()
88 .join(", ")
89 }
90
91 /// Accepted quality settings for this encoder, for actionable refusals.
92 pub fn quality_forms(self) -> &'static str {
93 match self {
94 Target::Mp3 => "V0…V9 or a supported MP3 bitrate (8…320k, e.g. 192k)",
95 Target::Vorbis => "q0…q10 or a bitrate (32…500k, e.g. 192k)",
96 Target::Opus => "a bitrate (8…510k, e.g. 128k)",
97 Target::Aac => "a bitrate (8…2048k, e.g. 192k)",
98 Target::Flac | Target::Wav => "no quality setting",
99 }
100 }
101
102 fn row(self) -> (&'static str, &'static str, bool) {
103 TARGETS
104 .iter()
105 .find(|(_, target, ..)| *target == self)
106 .map(|(_, _, extension, codec, lossless)| (*extension, *codec, *lossless))
107 .unwrap_or(("mp3", "libmp3lame", false))
108 }
109
110 /// The file extension a converted file carries, without its dot.
111 pub fn extension(self) -> &'static str {
112 self.row().0
113 }
114
115 /// `true` when the target keeps every sample it was given.
116 pub fn lossless(self) -> bool {
117 self.row().2
118 }
119
120 /// Whether a cover embedded in the source can travel into this target at
121 /// all. `false` here is not this code declining to carry it — ffmpeg
122 /// itself refuses to mux a picture stream into `wav`, `opus` or `vorbis`
123 /// (verified directly: mapping a video stream into any of the three fails
124 /// outright and produces no file), so the loss happens at the encoder
125 /// whatever is asked of it. [`convert`] reads this same rule, once.
126 pub fn keeps_embedded_art(self) -> bool {
127 matches!(self, Target::Mp3 | Target::Aac | Target::Flac)
128 }
129
130 /// Canonical tag keys known to survive a conversion into this target, or
131 /// `None` when every key does.
132 ///
133 /// Only `wav` has a real answer here. ffmpeg's WAV muxer writes the
134 /// legacy RIFF `LIST`/`INFO` chunk, and that chunk's vocabulary is exactly
135 /// this short — verified against a real encode carrying ten canonical
136 /// tags, of which precisely these six arrived and no others. Every other
137 /// target's tag format takes an arbitrary key, so there is nothing here
138 /// worth naming.
139 fn known_tag_vocabulary(self) -> Option<&'static [&'static str]> {
140 match self {
141 Target::Wav => Some(&["title", "artist", "album", "genre", "date", "tracknumber"]),
142 _ => None,
143 }
144 }
145
146 /// Canonical tag keys `present` holds that this target is known to drop.
147 ///
148 /// Empty for every target but `wav`, and empty for `wav` too when nothing
149 /// present falls outside its vocabulary — a file tagged with only title,
150 /// artist and album loses nothing by becoming a WAV.
151 pub fn tags_it_would_drop(self, present: &BTreeMap<String, Vec<String>>) -> Vec<String> {
152 let Some(kept) = self.known_tag_vocabulary() else {
153 return Vec::new();
154 };
155 present
156 .keys()
157 .filter(|key| !kept.contains(&key.as_str()))
158 .cloned()
159 .collect()
160 }
161
162 /// Typical bitrate in kbps, used only to estimate a size before the work is
163 /// done. `None` for a lossless target, where the figure would be a fiction.
164 fn nominal_kbps(self, quality: Option<Quality>) -> Option<u32> {
165 if self.lossless() {
166 return None;
167 }
168 if let Some(Quality::Bitrate(kbps)) = quality {
169 return Some(kbps);
170 }
171 Some(match self {
172 // LAME's VBR scale is only a planning estimate, never a fixed rate.
173 Target::Mp3 => match quality {
174 Some(Quality::Variable(level)) => [245, 225, 190, 175, 165, 130, 115, 100, 85, 65]
175 .get(level as usize)
176 .copied()
177 .unwrap_or(245),
178 _ => 245,
179 },
180 Target::Opus => 128,
181 Target::Aac => 192,
182 Target::Vorbis => 192,
183 _ => 192,
184 })
185 }
186}
187
188/// How hard the encoder should try, as the user asked for it.
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub enum Quality {
191 /// A variable-quality setting, `V0`–`V9` for MP3 or `q0`–`q10` for Vorbis:
192 /// lower is better for MP3, higher is better for Vorbis, which is the
193 /// encoders' own convention and not something to paper over.
194 Variable(u32),
195 /// A constant bitrate in kbps, written `192k`.
196 Bitrate(u32),
197}
198
199impl Quality {
200 /// Reads `V0`, `q6` or `192k`, and nothing else.
201 ///
202 /// A value that parses as none of the three is refused rather than quietly
203 /// replaced by the default: an encoder silently running at the wrong
204 /// setting produces files that are wrong in a way nobody notices until the
205 /// card is full.
206 pub fn parse(word: &str) -> Option<Quality> {
207 let word = word.trim();
208 if let Some(digits) = word
209 .strip_prefix(['V', 'v', 'Q', 'q'])
210 .and_then(|rest| rest.parse::<u32>().ok())
211 && digits <= 10
212 {
213 return Some(Quality::Variable(digits));
214 }
215 let bitrate = word.strip_suffix(['k', 'K']).unwrap_or(word);
216 bitrate
217 .parse::<u32>()
218 .ok()
219 .filter(|kbps| (8..=2048).contains(kbps))
220 .map(Quality::Bitrate)
221 }
222
223 /// Reads and validates a quality setting for one encoder.
224 ///
225 /// MP3 accepts only its V scale and supported constant bitrates; Vorbis
226 /// accepts its q scale or a bitrate. Opus and AAC have bitrate settings.
227 pub fn parse_for(target: Target, word: &str) -> Option<Quality> {
228 let word = word.trim();
229 let quality = Self::parse(word)?;
230 let valid = match quality {
231 Quality::Variable(level) => match target {
232 Target::Mp3 => word.starts_with(['V', 'v']) && level <= 9,
233 Target::Vorbis => word.starts_with(['Q', 'q']) && level <= 10,
234 _ => false,
235 },
236 Quality::Bitrate(kbps) => match target {
237 Target::Mp3 => [
238 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 192, 224, 256, 320,
239 ]
240 .contains(&kbps),
241 Target::Opus => (8..=510).contains(&kbps),
242 Target::Aac => (8..=2048).contains(&kbps),
243 Target::Vorbis => (32..=500).contains(&kbps),
244 _ => false,
245 },
246 };
247 valid.then_some(quality)
248 }
249
250 /// How the setting is written, for a message that repeats what was typed.
251 pub const FORMS: &'static str = "V0…V9 for MP3, q0…q10 for Vorbis, or a bitrate like 192k";
252}
253
254/// What ffmpeg should be told about quality, given the target.
255fn quality_arguments(target: Target, quality: Option<Quality>) -> Vec<String> {
256 match (target, quality) {
257 // A lossless target has no quality knob worth exposing: FLAC's
258 // compression level trades encoding time for a few percent, and PCM
259 // has none at all.
260 (t, _) if t.lossless() => Vec::new(),
261 (_, Some(Quality::Bitrate(kbps))) => vec!["-b:a".into(), format!("{kbps}k")],
262 (_, Some(Quality::Variable(level))) => vec!["-q:a".into(), level.to_string()],
263 // The defaults are the ones each encoder is actually good at, not a
264 // single number applied to all of them.
265 (Target::Mp3, None) => vec!["-q:a".into(), "0".into()],
266 (Target::Vorbis, None) => vec!["-q:a".into(), "6".into()],
267 (Target::Opus, None) => vec!["-b:a".into(), "128k".into()],
268 (Target::Aac, None) => vec!["-b:a".into(), "192k".into()],
269 (_, None) => Vec::new(),
270 }
271}
272
273/// Roughly what a converted file will weigh, for a report given before the work.
274///
275/// An estimate, and said to be one wherever it is shown. The alternative is to
276/// answer "unknown" to "will this fit on my card", which is the one question
277/// somebody filling a card is asking.
278pub fn estimated_size(
279 target: Target,
280 quality: Option<Quality>,
281 duration_ms: u64,
282 source: u64,
283) -> u64 {
284 match target.nominal_kbps(quality) {
285 // kbps is kilobits per second, so a millisecond holds kbps/8 bytes:
286 // ms × kbps ÷ 8. Dividing by 8000 instead — seconds and bits confused
287 // in one step — made every estimate a thousand times too small, and a
288 // card that could "obviously" hold the whole library. A test caught it.
289 Some(kbps) => duration_ms.saturating_mul(kbps as u64) / 8,
290 // Lossless from lossless: FLAC lands around 60 % of PCM on real music,
291 // and PCM from FLAC around the inverse.
292 None if target == Target::Flac => source.saturating_mul(3) / 5,
293 None => source.saturating_mul(2),
294 }
295}
296
297/// Where ffmpeg is, or `None` when it is not installed.
298///
299/// Kept as a name here, and answered in [`crate::ffmpeg`]: `spectrum` drives
300/// the same program for another purpose, and two searches that could disagree
301/// about which ffmpeg is "the" ffmpeg is one too many.
302pub fn find_ffmpeg() -> Option<String> {
303 crate::ffmpeg::find()
304}
305
306/// What to tell somebody who has no ffmpeg, worded so they can act on it.
307///
308/// One wording for every feature that needs it: see [`crate::ffmpeg::missing`].
309pub fn missing_ffmpeg() -> String {
310 crate::ffmpeg::missing("--compress")
311}
312
313/// Refuses lossless conversions that cannot represent the source samples.
314///
315/// FLAC stores integer PCM; arbitrary floating-point PCM must remain in a
316/// floating-capable container such as WAV rather than being quantized silently.
317/// Unknown source precision cannot establish a lossless conversion either.
318pub fn validate_conversion(
319 properties: &crate::tags::AudioProperties,
320 target: Target,
321) -> Result<(), String> {
322 if target == Target::Flac && matches!(properties.codec.as_str(), "pcm_float" | "fl32" | "fl64")
323 {
324 return Err("FLAC cannot preserve arbitrary floating-point PCM; use WAV or copy the source unchanged".into());
325 }
326 if target.lossless() && properties.bit_depth.is_none_or(|bits| bits == 0) {
327 return Err(
328 "cannot preserve unknown source PCM precision in a lossless conversion; copy the source unchanged or choose an explicit lossy target".into(),
329 );
330 }
331 Ok(())
332}
333
334/// Converts one file, and reports what went wrong in words rather than in an
335/// exit status.
336///
337/// The metadata travels from the input's global and first audio-stream tags, and the
338/// embedded cover is copied as a picture stream where the container takes one.
339/// Neither is perfect — no two tag formats hold quite the same fields, and a
340/// Vorbis comment with three values for one field cannot be an ID3 frame that
341/// holds one — but losing the artist and the title on the way to a player is
342/// not a trade anybody would accept, and ffmpeg's mapping is what beets relies
343/// on for the same reason.
344///
345/// Sources must resolve to ordinary local files. Output is encoded in an
346/// isolated temporary directory, then atomically replaces an ordinary target;
347/// final symlinks and the source itself are refused. A different hard-link
348/// alias is replaced without writing through to the original source inode.
349pub fn convert(
350 ffmpeg: &str,
351 source: &Path,
352 destination: &Path,
353 target: Target,
354 quality: Option<Quality>,
355) -> Result<(), String> {
356 if let Some(quality) = quality {
357 let valid = match quality {
358 Quality::Variable(level) => {
359 matches!(target, Target::Mp3) && level <= 9
360 || matches!(target, Target::Vorbis) && level <= 10
361 }
362 Quality::Bitrate(kbps) => Quality::parse_for(target, &format!("{kbps}k")).is_some(),
363 };
364 if !valid {
365 return Err("quality is not supported by the selected encoder".into());
366 }
367 }
368 super::validate_source(source)?;
369 // Absolute input prevents a literal filename such as `pipe:123` from
370 // becoming an ffmpeg protocol, and keeps local conversion local.
371 let source = source.canonicalize().map_err(|error| error.to_string())?;
372 if destination.canonicalize().is_ok_and(|path| path == source) {
373 return Err("source and destination are the same file".into());
374 }
375 let temporary = super::TemporaryOutput::new(destination)?;
376 encode(ffmpeg, &source, temporary.path(), target, quality)?;
377 temporary.publish()
378}
379
380fn encode(
381 ffmpeg: &str,
382 source: &Path,
383 destination: &Path,
384 target: Target,
385 quality: Option<Quality>,
386) -> Result<(), String> {
387 let high_precision_flac = if target == Target::Flac {
388 let properties = crate::tags::read(source)
389 .map_err(|error| format!("cannot determine source PCM representation: {error}"))?
390 .properties;
391 validate_conversion(&properties, target)?;
392 properties.bit_depth.is_some_and(|bits| bits > 24)
393 } else {
394 false
395 };
396 let (_, default_codec, _) = target.row();
397 let codec = if target == Target::Wav {
398 wav_codec(source)?
399 } else {
400 default_codec
401 };
402 let mut command = Command::new(ffmpeg);
403 command
404 // Without this a run in a terminal can stop dead waiting for an answer
405 // to a question nobody saw, in the middle of nine hundred files.
406 .arg("-nostdin")
407 .args(["-loglevel", "error"])
408 .arg("-y")
409 .arg("-i")
410 .arg(source)
411 .args(["-map", "0:a"]);
412
413 // The cover, where the container holds one. `?` makes the stream optional,
414 // so a file without art is converted rather than refused.
415 if target.keeps_embedded_art() {
416 command.args([
417 "-map",
418 "0:v?",
419 "-c:v",
420 "copy",
421 "-disposition:v:0",
422 "attached_pic",
423 ]);
424 }
425 if high_precision_flac {
426 // FFmpeg otherwise quantizes s32 input to 24 bits. Versions with
427 // 32-bit FLAC support require this explicit experimental opt-in;
428 // older versions must fail instead of publishing lower precision.
429 command.args([
430 "-sample_fmt",
431 "s32",
432 "-strict",
433 "experimental",
434 "-bits_per_raw_sample",
435 "32",
436 ]);
437 }
438 command
439 .args(["-map_metadata", "0"])
440 // Ogg FLAC keeps tags on the audio stream rather than globally. Merge
441 // those missing values while retaining the global metadata already copied.
442 .args(["-map_metadata", "0:s:a:0"])
443 .args(["-c:a", codec])
444 .args(quality_arguments(target, quality))
445 .arg(destination);
446
447 let mut child = command
448 .stdin(std::process::Stdio::null())
449 .stdout(std::process::Stdio::null())
450 .stderr(std::process::Stdio::piped())
451 .spawn()
452 .map_err(|e| format!("ffmpeg could not be run: {e}"))?;
453 let Some(stderr) = child.stderr.take() else {
454 let _ = child.kill();
455 let _ = child.wait();
456 return Err("ffmpeg did not open its error pipe".into());
457 };
458 // Only stderr is piped, so draining before wait cannot block on stdout.
459 let diagnostic = crate::ffmpeg::diagnostic_tail(stderr);
460 if diagnostic.is_err() {
461 let _ = child.kill();
462 }
463 let status = child
464 .wait()
465 .map_err(|error| format!("ffmpeg could not be reaped: {error}"))?;
466 let diagnostic = diagnostic.map_err(|error| format!("cannot read ffmpeg errors: {error}"))?;
467 if status.success() {
468 if target.lossless() {
469 let source = crate::tags::read(source)
470 .map_err(|error| format!("cannot read source precision: {error}"))?
471 .properties;
472 let written = crate::tags::read(destination)
473 .map_err(|error| format!("cannot read encoded precision: {error}"))?
474 .properties;
475 verify_lossless_properties(&source, &written)?;
476 }
477 return Ok(());
478 }
479 // ffmpeg's own words, trimmed to the last few lines: the first ones are
480 // usually the banner, and the reason is at the end.
481 let said = String::from_utf8_lossy(&diagnostic);
482 let reason: Vec<&str> = said.lines().filter(|l| !l.trim().is_empty()).collect();
483 let tail = reason
484 .iter()
485 .rev()
486 .take(2)
487 .rev()
488 .copied()
489 .collect::<Vec<_>>()
490 .join("; ");
491 Err(match tail.is_empty() {
492 true => "ffmpeg failed without saying why".to_string(),
493 false => format!("ffmpeg: {tail}"),
494 })
495}
496
497/// A successful encoder must still retain the source's PCM representation.
498fn verify_lossless_properties(
499 source: &crate::tags::AudioProperties,
500 written: &crate::tags::AudioProperties,
501) -> Result<(), String> {
502 if !written.lossless
503 || source
504 .bit_depth
505 .is_some_and(|bits| written.bit_depth.is_none_or(|output| output < bits))
506 || source
507 .sample_rate
508 .is_some_and(|rate| written.sample_rate != Some(rate))
509 || source
510 .channels
511 .is_some_and(|channels| written.channels != Some(channels))
512 {
513 return Err(
514 "lossless encoder cannot preserve the source precision, sample rate or channel count"
515 .into(),
516 );
517 }
518 if matches!(source.codec.as_str(), "pcm_float" | "fl32" | "fl64")
519 && !matches!(written.codec.as_str(), "pcm_float" | "fl32" | "fl64")
520 {
521 return Err("lossless encoder quantized floating-point PCM to integer samples".into());
522 }
523 Ok(())
524}
525
526/// Uses enough PCM precision for the source rather than forcing CD precision.
527/// Unsupported/unknown precision is refused instead of silently quantized.
528fn wav_codec(source: &Path) -> Result<&'static str, String> {
529 let properties = crate::tags::read(source)
530 .map_err(|error| format!("cannot determine source PCM precision: {error}"))?
531 .properties;
532 if matches!(properties.codec.as_str(), "pcm_float" | "fl32" | "fl64") {
533 return match properties.bit_depth {
534 Some(32) => Ok("pcm_f32le"),
535 Some(64) => Ok("pcm_f64le"),
536 _ => Err("cannot preserve unsupported floating-point WAV precision".into()),
537 };
538 }
539 match properties.bit_depth {
540 Some(1..=16) => Ok("pcm_s16le"),
541 Some(17..=24) => Ok("pcm_s24le"),
542 Some(25..=32) => Ok("pcm_s32le"),
543 _ => Err("cannot preserve unknown or unsupported source PCM precision in WAV".into()),
544 }
545}
546
547/// Longest a converted file's playing time may differ from its source.
548///
549/// Encoders pad and trim: an MP3 frame is 1152 samples and the encoder delay is
550/// real, so an exact match would fail on correct output. Two seconds is far
551/// larger than any of that and far smaller than a truncated encode.
552const DURATION_TOLERANCE_MS: u64 = 2_000;
553
554/// Checks a converted file the only way a converted file can be checked.
555///
556/// Comparing checksums is meaningless here: the bytes are different by
557/// construction, which is the whole point. What can be checked is that the
558/// result is a file this program can read, that it holds audio, and that the
559/// audio lasts as long as what went in — which is what catches the failure that
560/// actually happens, an encode cut short by a full disk or a killed process.
561///
562/// It reads the output with Aède's own parsers rather than asking ffmpeg again:
563/// a verification that trusts the tool being verified is not one.
564pub fn verify(destination: &Path, expected_ms: Option<u64>) -> Result<(), String> {
565 let tags = crate::tags::read(destination)
566 .map_err(|e| format!("what was written cannot be read back: {e}"))?;
567 let Some(written) = tags.properties.duration_ms else {
568 return Err("what was written has no readable duration".into());
569 };
570 if written == 0 {
571 return Err("what was written holds no audio".into());
572 }
573 let Some(expected) = expected_ms.filter(|ms| *ms > 0) else {
574 return Ok(());
575 };
576 if written.abs_diff(expected) > DURATION_TOLERANCE_MS {
577 return Err(format!(
578 "the result lasts {} where the source lasts {}: the encode was cut short",
579 crate::text::format_duration(written),
580 crate::text::format_duration(expected)
581 ));
582 }
583 Ok(())
584}
585
586#[cfg(test)]
587#[path = "transcode_tests.rs"]
588mod tests;