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;