aede_core/
spectrum.rs

1//! Spectrogram pictures, drawn by ffmpeg, one per track.
2//!
3//! A spectrogram is the last arbiter when the provenance of a file is in
4//! doubt: a lossless container filled from an MP3 shows a wall at 16 kHz that
5//! no tag will ever mention. Aède does not decode and will not draw one
6//! itself — it hands the file to ffmpeg and puts the picture where the person
7//! looking for it will find it, beside the music.
8//!
9//! **The filter and the layout are FlacCompagnon's**, deliberately and to the
10//! character. The two programs are used together on the same library, and a
11//! spectrogram that differed in gain or colour map from one tool to the other
12//! would be unreadable *as a pair* — the whole point of looking at two is to
13//! compare them. The frame size no longer has to match: see [`Size`].
14//! `--size full` still draws FlacCompagnon's own dimensions, character for
15//! character, for whoever wants the two side by side; the default is half of
16//! that. A smaller plotted graph can reduce storage use, but the encoded PNG
17//! size depends on its content and compression.
18//!
19//! The folder is another thing that does not match: it is `spectrograms`, in
20//! English like everything else here, where FlacCompagnon writes `spectres`.
21//! Matching a *picture* is what makes the pair comparable; matching a *folder
22//! name* buys nothing, and a French word in an otherwise English codebase is a
23//! seam nobody would guess at.
24
25use std::path::{Path, PathBuf};
26use std::process::Command;
27
28use crate::tags::AudioProperties;
29
30/// Folder written beside the audio, holding one picture per track.
31pub const FOLDER: &str = "spectrograms";
32
33/// How large the plotted spectrogram graph is drawn.
34///
35/// [`Size::Full`] is FlacCompagnon's own graph, `1800x940`, for putting the
36/// two tools' pictures side by side. [`Size::Half`] is the default, `900x470`:
37/// halving both graph dimensions quarters the plotted pixel count. FFmpeg adds
38/// legend margins to the final image. Encoded PNG size depends on content and
39/// compression, so there is no fixed storage ratio or library-size guarantee.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum Size {
42    /// A `1800x940` plotted graph, matching FlacCompagnon.
43    Full,
44    /// A `900x470` plotted graph, used unless `--size full` is given.
45    Half,
46}
47
48impl Size {
49    /// Reads what a reader typed after `--size`.
50    pub fn parse(text: &str) -> Option<Size> {
51        match text.trim().to_ascii_lowercase().as_str() {
52            "full" => Some(Size::Full),
53            "half" => Some(Size::Half),
54            _ => None,
55        }
56    }
57
58    /// The `WxH` fed to ffmpeg's `showspectrumpic`.
59    fn dimensions(self) -> &'static str {
60        match self {
61            Size::Full => "1800x940",
62            Size::Half => "900x470",
63        }
64    }
65
66    /// How this size reads in a message.
67    pub fn as_str(self) -> &'static str {
68        match self {
69            Size::Full => "full",
70            Size::Half => "half",
71        }
72    }
73}
74
75/// The ffmpeg filter, character for character as FlacCompagnon draws it,
76/// except for the frame size, which [`Size`] now chooses.
77///
78/// `legend=1` draws the labelled frequency axis, whose top is the Nyquist
79/// limit — without it the picture is pretty and says nothing, because there is
80/// no way to tell 16 kHz from 22 kHz by eye.
81fn filter(size: Size) -> String {
82    format!(
83        "showspectrumpic=s={}:mode=combined:legend=1:color=intensity:scale=log:gain=3",
84        size.dimensions()
85    )
86}
87
88/// Where the picture of a file belongs: `<its folder>/spectrograms/<name>.png`.
89pub fn picture_for(audio: &Path) -> PathBuf {
90    let folder = audio.parent().unwrap_or_else(|| Path::new("."));
91    let stem = audio
92        .file_stem()
93        .and_then(|s| s.to_str())
94        .unwrap_or("track");
95    folder.join(FOLDER).join(format!("{stem}.png"))
96}
97
98/// `true` when the picture has to be drawn: it is missing, or it is older than
99/// the music it describes.
100///
101/// Both dates are read from the **disk**, not from the catalog. The catalog
102/// remembers when it last read a file, which is a different fact and the wrong
103/// one here: the question is whether this picture was drawn from the bytes that
104/// are there now, and a library edited since the last scan would otherwise keep
105/// pictures of music nobody has any more.
106///
107/// The modification date rather than a checksum, because it is the same test
108/// the incremental scan uses, and because a picture is cheap to redraw and
109/// expensive to verify. A run over a library that has not changed draws nothing
110/// at all, which is what makes the command safe to repeat.
111pub fn out_of_date(audio: &Path, picture: &Path) -> bool {
112    let Some(drawn) = modified(picture) else {
113        return true;
114    };
115    // A track whose date cannot be read is left alone rather than redrawn on
116    // every run: an unreadable date is not evidence that anything changed.
117    modified(audio).is_some_and(|music| drawn < music)
118}
119
120fn modified(path: &Path) -> Option<u64> {
121    std::fs::metadata(path)
122        .ok()?
123        .modified()
124        .ok()?
125        .duration_since(std::time::UNIX_EPOCH)
126        .ok()
127        .map(|d| d.as_secs())
128}
129
130/// The line drawn across the top of the picture.
131///
132/// The legend already carries the frequency axis; this says in words what the
133/// file claims to be, so that a picture kept on its own — sent to somebody,
134/// looked at a year later — still answers "at what sample rate?".
135///
136/// The whole string is embedded in an ffmpeg `drawtext` expression, and part of
137/// it comes from the file itself, so it is restricted to a character set that
138/// cannot end the argument, start another filter, or escape the expression.
139/// See the test: this is the one function here with an attacker on the other
140/// side of it.
141pub fn caption(properties: &AudioProperties) -> String {
142    let depth = match (properties.bit_depth, properties.bitrate_kbps) {
143        (Some(bits), _) => format!("{bits}-bit"),
144        // A lossy codec has no bit depth at all — printing "float" there, as a
145        // tool that only ever sees lossless files can afford to, would state
146        // something untrue about every MP3 in the library.
147        (None, Some(kbps)) => format!("{kbps} kbps"),
148        (None, None) => "unknown depth".to_string(),
149    };
150    let rate = properties.sample_rate.unwrap_or(0);
151    let raw = format!(
152        "{rate} Hz | {depth} | {} ch | {} | Nyquist {} Hz",
153        properties.channels.unwrap_or(0),
154        properties.codec.to_uppercase(),
155        rate / 2,
156    );
157    raw.chars()
158        .filter(|c| c.is_ascii_alphanumeric() || matches!(c, ' ' | '|' | '-' | '/' | '.'))
159        .collect()
160}
161
162/// Draws the picture for one file.
163///
164/// Two attempts: with the caption, then without. `drawtext` needs a font, and
165/// an installation that has none fails the whole filter graph — losing the
166/// picture over a line of text would be a poor trade, and the legend still
167/// carries the axis that matters.
168///
169/// Both attempts write inside a private temporary directory beside the
170/// destination. Only a completed picture replaces the old one; destination
171/// links and special files are refused before rendering and publication.
172pub fn render(
173    ffmpeg: &str,
174    audio: &Path,
175    picture: &Path,
176    size: Size,
177    caption: Option<&str>,
178) -> Result<(), String> {
179    render_with(picture, size, caption, |filter, output| {
180        run(ffmpeg, audio, filter, output)
181    })
182}
183
184fn render_with(
185    picture: &Path,
186    size: Size,
187    caption: Option<&str>,
188    mut render: impl FnMut(&str, &Path) -> Result<(), String>,
189) -> Result<(), String> {
190    let output = crate::copy::TemporaryOutput::new(picture)?;
191    let base = filter(size);
192    let with_text = caption.map(|text| {
193        format!(
194            "{base},drawtext=text='{text}':fontcolor=white:fontsize=24:\
195             x=14:y=12:box=1:boxcolor=black@0.55"
196        )
197    });
198    if let Some(with_text) = &with_text
199        && render(with_text, output.path()).is_ok()
200    {
201        return output.publish();
202    }
203    render(&base, output.path())?;
204    output.publish()
205}
206
207fn run(ffmpeg: &str, audio: &Path, filter: &str, picture: &Path) -> Result<(), String> {
208    let output = Command::new(ffmpeg)
209        .args(["-hide_banner", "-loglevel", "error", "-y", "-i"])
210        .arg(audio)
211        .args(["-lavfi", filter, "-frames:v", "1"])
212        .arg(picture)
213        .output()
214        .map_err(|e| format!("ffmpeg could not be run: {e}"))?;
215    if output.status.success() {
216        return Ok(());
217    }
218    let said = String::from_utf8_lossy(&output.stderr);
219    let last: Vec<&str> = said.lines().filter(|l| !l.trim().is_empty()).collect();
220    Err(last
221        .iter()
222        .rev()
223        .take(2)
224        .rev()
225        .cloned()
226        .collect::<Vec<_>>()
227        .join(" — "))
228}
229
230#[cfg(test)]
231#[path = "spectrum_tests.rs"]
232mod tests;