Skip to main content

xil/cmd/
db_profile.rs

1//! `xil db-profile` — peak, average and minimum dBFS per MP3. Port of
2//! `XILU010_db_profile.py`.
3
4use std::ffi::OsString;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use clap::Parser;
9use serde_json::{Map, Value};
10use xil_audio::ffmpeg;
11use xil_audio::pcm::Pcm;
12use xil_core::fsutil::{abspath, basename, relpath};
13use xil_core::pycsv;
14use xil_core::pyjson::{dumps, Style};
15use xil_core::workspace::workspace_root;
16use xil_core::{banner, log};
17
18const CHUNK_MS: i64 = 500;
19/// Below this a chunk counts as silence or dead air and is not a candidate
20/// for the quietest-window measurement.
21const SILENCE_FLOOR_DB: f64 = -96.0;
22const DEFAULT_PATH: &str = "__sfx_default__";
23
24#[derive(Parser)]
25#[command(
26    name = "xil-db-profile",
27    about = "Profile MP3 audio levels: peak, average, and minimum dBFS"
28)]
29struct Args {
30    /// MP3 file or directory to scan recursively (default: workspace SFX/ folder)
31    #[arg(default_value = DEFAULT_PATH)]
32    path: String,
33    /// Write results to FILE as CSV in addition to logging
34    #[arg(long, short = 'o', value_name = "FILE")]
35    output: Option<PathBuf>,
36    /// Print absolute paths (default: relative to scan root)
37    #[arg(long)]
38    absolute: bool,
39    /// Output a JSON array to stdout — no banner, safe to pipe to jq
40    #[arg(long)]
41    json: bool,
42}
43
44/// One file's measurements, in the key order the JSON and CSV use.
45struct Record {
46    path: String,
47    duration_s: f64,
48    peak_dbfs: f64,
49    avg_dbfs: f64,
50    min_dbfs: f64,
51}
52
53impl Record {
54    fn to_map(&self) -> Map<String, Value> {
55        let mut m = Map::new();
56        m.insert("path".into(), Value::String(self.path.clone()));
57        m.insert("duration_s".into(), num(self.duration_s));
58        m.insert("peak_dBFS".into(), num(self.peak_dbfs));
59        m.insert("avg_dBFS".into(), num(self.avg_dbfs));
60        m.insert("min_dBFS".into(), num(self.min_dbfs));
61        m
62    }
63}
64
65/// A silent file profiles as `-inf`, which `json.dumps` writes as the
66/// bare token `-Infinity`; `py_float` carries that through.
67fn num(v: f64) -> Value {
68    xil_core::pyjson::py_float(v)
69}
70
71/// Python's `round(x, 2)`: round-half-to-even on the decimal value.
72fn round2(x: f64) -> f64 {
73    if !x.is_finite() {
74        return x;
75    }
76    // Formatting then re-parsing gives the same half-to-even behaviour
77    // CPython's float repr path produces.
78    format!("{x:.2}").parse().unwrap_or(x)
79}
80
81/// Peak, RMS and quietest-500ms-window for one file.
82pub fn profile_file(path: &Path) -> Result<(f64, f64, f64, f64), ffmpeg::AudioError> {
83    let seg = ffmpeg::decode(path)?;
84    let peak = seg.max_dbfs();
85    let avg = seg.dbfs();
86    let duration_s = seg.len_ms() as f64 / 1000.0;
87
88    let mut levels: Vec<f64> = Vec::new();
89    let total = seg.len_ms();
90    let mut i = 0;
91    while i < total {
92        let chunk: Pcm = seg.slice_ms(i, i + CHUNK_MS);
93        let d = chunk.dbfs();
94        if d > SILENCE_FLOOR_DB {
95            levels.push(d);
96        }
97        i += CHUNK_MS;
98    }
99    let min_db = levels.into_iter().fold(f64::INFINITY, f64::min);
100    let min_db = if min_db.is_finite() {
101        min_db
102    } else {
103        f64::NEG_INFINITY
104    };
105
106    Ok((
107        round2(duration_s),
108        round2(peak),
109        round2(avg),
110        round2(min_db),
111    ))
112}
113
114/// `os.walk` order: this directory's files sorted, then subdirectories.
115fn scan_mp3s(root: &Path) -> Vec<PathBuf> {
116    let mut out = Vec::new();
117    walk(root, &mut out);
118    out
119}
120
121fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
122    let Ok(rd) = fs::read_dir(dir) else { return };
123    let (mut files, mut dirs) = (Vec::new(), Vec::new());
124    for e in rd.filter_map(Result::ok) {
125        let p = e.path();
126        if p.is_dir() {
127            dirs.push(p);
128        } else {
129            files.push(p);
130        }
131    }
132    files.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
133    for f in files {
134        if basename(&f).to_lowercase().ends_with(".mp3") {
135            out.push(abspath(&f));
136        }
137    }
138    for d in dirs {
139        walk(&d, out);
140    }
141}
142
143/// Pad to `width` by character count (`f"{s:<*}"`).
144fn pad(s: &str, width: usize) -> String {
145    let n = s.chars().count();
146    if n >= width {
147        s.to_string()
148    } else {
149        format!("{s}{}", " ".repeat(width - n))
150    }
151}
152
153/// `%10.2f` on a value that may be infinite — Python prints `-inf`.
154fn fmt_fixed(v: f64, width: usize) -> String {
155    let s = if v.is_infinite() {
156        (if v > 0.0 { "inf" } else { "-inf" }).to_string()
157    } else {
158        format!("{v:.2}")
159    };
160    let n = s.chars().count();
161    if n >= width {
162        s
163    } else {
164        format!("{}{s}", " ".repeat(width - n))
165    }
166}
167
168fn execute(a: &Args) -> anyhow::Result<i32> {
169    let quiet = a.json;
170    let resolved = if a.path == DEFAULT_PATH {
171        workspace_root().join("SFX")
172    } else {
173        abspath(Path::new(&a.path))
174    };
175
176    let (mp3_paths, scan_root) = if resolved.is_file() {
177        (
178            vec![resolved.clone()],
179            resolved.parent().map(Path::to_path_buf).unwrap_or_default(),
180        )
181    } else if resolved.is_dir() {
182        if !quiet {
183            log::info(&format!("Scanning {} for MP3 files…", resolved.display()));
184        }
185        (scan_mp3s(&resolved), resolved.clone())
186    } else {
187        log::error(&format!("Not a file or directory: {}", resolved.display()));
188        return Ok(0);
189    };
190
191    if mp3_paths.is_empty() {
192        if !quiet {
193            log::info(&format!("No MP3 files found under {}", resolved.display()));
194        }
195        return Ok(0);
196    }
197
198    let mut records: Vec<Record> = Vec::new();
199    for (i, p) in mp3_paths.iter().enumerate() {
200        if !quiet {
201            log::info(&format!(
202                "[{}/{}] Profiling {}",
203                i + 1,
204                mp3_paths.len(),
205                basename(p)
206            ));
207        }
208        let (duration_s, peak, avg, min_db) = match profile_file(p) {
209            Ok(v) => v,
210            Err(e) => {
211                log::warning(&format!("Skipping {} — {e}", p.display()));
212                continue;
213            }
214        };
215        let display = if a.absolute {
216            p.display().to_string()
217        } else {
218            relpath(p, &scan_root).display().to_string()
219        };
220        records.push(Record {
221            path: display,
222            duration_s,
223            peak_dbfs: peak,
224            avg_dbfs: avg,
225            min_dbfs: min_db,
226        });
227    }
228
229    if records.is_empty() {
230        return Ok(0);
231    }
232
233    if a.json {
234        let arr = Value::Array(records.iter().map(|r| Value::Object(r.to_map())).collect());
235        println!("{}", dumps(&arr, Style::INDENT2));
236        return Ok(0);
237    }
238
239    let col_w = records
240        .iter()
241        .map(|r| r.path.chars().count())
242        .max()
243        .unwrap_or(0)
244        .max(8);
245    let header = format!(
246        "{}  {:>10}  {:>9}  {:>9}  {:>7}",
247        pad("filename", col_w),
248        "peak_dBFS",
249        "avg_dBFS",
250        "min_dBFS",
251        "dur_s"
252    );
253    let rule = "-".repeat(header.chars().count());
254    log::info(&header);
255    log::info(&rule);
256    for r in &records {
257        log::info(&format!(
258            "{}  {}  {}  {}  {}",
259            pad(&r.path, col_w),
260            fmt_fixed(r.peak_dbfs, 10),
261            fmt_fixed(r.avg_dbfs, 9),
262            fmt_fixed(r.min_dbfs, 9),
263            fmt_fixed(r.duration_s, 7)
264        ));
265    }
266
267    if let Some(out) = &a.output {
268        let cols = ["path", "duration_s", "peak_dBFS", "avg_dBFS", "min_dBFS"];
269        let rows: Vec<Map<String, Value>> = records.iter().map(Record::to_map).collect();
270        let mut f = fs::File::create(out)?;
271        pycsv::write_dicts(&mut f, &cols, &rows)?;
272        if !quiet {
273            log::info(&format!("Written: {}", out.display()));
274        }
275    }
276
277    log::info(&format!("Profiled {} MP3 file(s)", records.len()));
278    Ok(0)
279}
280
281pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
282    log::init("db-profile");
283    let a: Args = match super::parse_or_exit("xil-db-profile", args) {
284        Ok(a) => a,
285        Err(code) => return Ok(code),
286    };
287    if a.json {
288        execute(&a)
289    } else {
290        let _banner = banner::begin(super::prog(), &super::argv_line(args));
291        execute(&a)
292    }
293}
294
295/// The clap definition behind `--help`, for man pages.
296pub fn command() -> clap::Command {
297    <Args as clap::CommandFactory>::command()
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    #[test]
305    fn round_two_places_matches_python() {
306        assert_eq!(round2(1.005), 1.0, "1.005 is really 1.00499… in binary");
307        assert_eq!(round2(-45.448237), -45.45);
308        assert_eq!(round2(2.675), 2.67);
309        assert_eq!(round2(f64::NEG_INFINITY), f64::NEG_INFINITY);
310    }
311
312    #[test]
313    fn infinities_render_as_python_writes_them() {
314        assert_eq!(dumps(&num(f64::NEG_INFINITY), Style::COMPACT), "-Infinity");
315        assert_eq!(fmt_fixed(f64::NEG_INFINITY, 9), "     -inf");
316        assert_eq!(fmt_fixed(-45.45, 9), "   -45.45");
317        assert_eq!(fmt_fixed(-1234.5678, 3), "-1234.57", "too wide to pad");
318    }
319
320    #[test]
321    fn scan_is_os_walk_order_and_case_insensitive() {
322        let tmp = tempfile::tempdir().unwrap();
323        let r = tmp.path();
324        fs::create_dir_all(r.join("sub")).unwrap();
325        for f in ["b.mp3", "a.MP3", "c.wav", "sub/d.mp3"] {
326            fs::write(r.join(f), "x").unwrap();
327        }
328        let names: Vec<String> = scan_mp3s(r)
329            .iter()
330            .map(|p| relpath(p, r).display().to_string())
331            .collect();
332        // os.path.relpath uses the native separator, so Windows shows sub\d.mp3.
333        let nested = format!("sub{}d.mp3", std::path::MAIN_SEPARATOR);
334        assert_eq!(names, vec!["a.MP3", "b.mp3", nested.as_str()]);
335    }
336
337    #[test]
338    fn a_silent_file_profiles_as_negative_infinity() {
339        if !xil_audio::ffmpeg::available() {
340            return;
341        }
342        let tmp = tempfile::tempdir().unwrap();
343        let p = tmp.path().join("q.mp3");
344        std::process::Command::new("ffmpeg")
345            .args([
346                "-v",
347                "quiet",
348                "-y",
349                "-f",
350                "lavfi",
351                "-i",
352                "anullsrc=r=8000:cl=mono:d=1",
353            ])
354            .arg(&p)
355            .status()
356            .unwrap();
357        let (_dur, peak, avg, min_db) = profile_file(&p).unwrap();
358        assert_eq!(peak, f64::NEG_INFINITY);
359        assert_eq!(avg, f64::NEG_INFINITY);
360        assert_eq!(
361            min_db,
362            f64::NEG_INFINITY,
363            "every chunk is below the silence floor"
364        );
365    }
366}