1use 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;
19const 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 #[arg(default_value = DEFAULT_PATH)]
32 path: String,
33 #[arg(long, short = 'o', value_name = "FILE")]
35 output: Option<PathBuf>,
36 #[arg(long)]
38 absolute: bool,
39 #[arg(long)]
41 json: bool,
42}
43
44struct 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
65fn num(v: f64) -> Value {
68 xil_core::pyjson::py_float(v)
69}
70
71fn round2(x: f64) -> f64 {
73 if !x.is_finite() {
74 return x;
75 }
76 format!("{x:.2}").parse().unwrap_or(x)
79}
80
81pub 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
114fn 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
143fn 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
153fn 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
295pub 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 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}