Skip to main content

max / audiofiles

25.8 KB · 687 lines History Blame Raw
1 //! Pipeline benchmark for audiofiles analysis.
2 //!
3 //! Measures per-stage timing, throughput, resource usage, and classification accuracy
4 //! against labeled training data.
5 //!
6 //! Usage: `cargo run --release -p audiofiles-bench`
7
8 use std::collections::HashMap;
9 use std::path::{Path, PathBuf};
10 use std::time::Instant;
11
12 use audiofiles_core::analysis::classify::{self, ClassifyInput, SampleClass};
13 use audiofiles_core::analysis::config::AnalysisConfig;
14 use audiofiles_core::analysis::loudness;
15 use audiofiles_core::analysis::{self, basic, bpm, decode, loop_detect, mfcc, spectral};
16 use audiofiles_core::fingerprint;
17 use rayon::prelude::*;
18
19 // ── Timing Helpers ──
20
21 struct StageTiming {
22 decode_ms: f64,
23 loudness_ms: f64,
24 spectral_ms: f64,
25 mfcc_ms: f64,
26 classify_ms: f64,
27 bpm_key_ms: f64,
28 loop_ms: f64,
29 fingerprint_ms: f64,
30 total_ms: f64,
31 }
32
33 fn time_stages(path: &Path) -> Option<(StageTiming, f64, u32)> {
34 let total_start = Instant::now();
35
36 // Decode
37 let t = Instant::now();
38 let decoded = decode::decode_to_mono(path).ok()?;
39 let decode_ms = t.elapsed().as_secs_f64() * 1000.0;
40
41 let duration = decoded.duration;
42 let sr = decoded.sample_rate;
43 let max_secs = 30.0;
44 let max_samples = (max_secs * sr as f64) as usize;
45 let capped = &decoded.samples[..decoded.samples.len().min(max_samples)];
46
47 // Loudness
48 let t = Instant::now();
49 let _ = basic::peak_db(&decoded.samples);
50 let _ = basic::rms_db(&decoded.samples);
51 let crest = basic::crest_factor(&decoded.samples);
52 let attack = basic::attack_time(&decoded.samples, sr);
53 let _ = loudness::measure_lufs(&decoded.samples, sr);
54 let loudness_ms = t.elapsed().as_secs_f64() * 1000.0;
55
56 // Spectral
57 let t = Instant::now();
58 let (features, magnitude_frames) =
59 spectral::compute_spectral_features_with_frames(capped, sr);
60 let spectral_ms = t.elapsed().as_secs_f64() * 1000.0;
61
62 // MFCC
63 let t = Instant::now();
64 let mfcc_features = mfcc::compute_mfccs(&magnitude_frames, sr, 1024);
65 let mfcc_ms = t.elapsed().as_secs_f64() * 1000.0;
66
67 // Classify
68 let t = Instant::now();
69 let input = ClassifyInput::with_mfccs(&features, duration, crest, attack, &mfcc_features);
70 let _ = classify::classify_ml(&input);
71 let classify_ms = t.elapsed().as_secs_f64() * 1000.0;
72
73 // BPM + Key
74 let t = Instant::now();
75 let bpm_result = bpm::detect_bpm_key(capped, sr, 2.0);
76 let bpm_key_ms = t.elapsed().as_secs_f64() * 1000.0;
77
78 // Loop detect
79 let t = Instant::now();
80 let _ = loop_detect::is_loop(&decoded.samples, sr, bpm_result.bpm);
81 let loop_ms = t.elapsed().as_secs_f64() * 1000.0;
82
83 // Fingerprint
84 let t = Instant::now();
85 let _ = fingerprint::compute_envelope(&decoded.samples, sr);
86 let fingerprint_ms = t.elapsed().as_secs_f64() * 1000.0;
87
88 let total_ms = total_start.elapsed().as_secs_f64() * 1000.0;
89
90 Some((
91 StageTiming {
92 decode_ms,
93 loudness_ms,
94 spectral_ms,
95 mfcc_ms,
96 classify_ms,
97 bpm_key_ms,
98 loop_ms,
99 fingerprint_ms,
100 total_ms,
101 },
102 duration,
103 sr,
104 ))
105 }
106
107 // ── Classification Accuracy ──
108
109 fn expected_class_from_dir(dir_name: &str) -> Option<SampleClass> {
110 match dir_name {
111 "kick" => Some(SampleClass::Kick),
112 "snare" => Some(SampleClass::Snare),
113 "hihat" => Some(SampleClass::HiHat),
114 "cymbal" => Some(SampleClass::Cymbal),
115 "clap" => Some(SampleClass::Clap),
116 "tom" => Some(SampleClass::Tom),
117 "percussion" => Some(SampleClass::Percussion),
118 _ => None,
119 }
120 }
121
122 /// True if predicted is "correct enough":
123 /// - Exact match, OR
124 /// - Both are drum types (permissive: kick/snare/hihat/cymbal/percussion)
125 fn is_drum_class(c: SampleClass) -> bool {
126 matches!(
127 c,
128 SampleClass::Kick
129 | SampleClass::Snare
130 | SampleClass::HiHat
131 | SampleClass::Cymbal
132 | SampleClass::Clap
133 | SampleClass::Tom
134 | SampleClass::Percussion
135 )
136 }
137
138 struct ClassifyResult {
139 expected: SampleClass,
140 predicted: SampleClass,
141 confidence: f64,
142 }
143
144 fn classify_file(path: &Path) -> Option<(SampleClass, f64)> {
145 let decoded = decode::decode_to_mono(path).ok()?;
146 let sr = decoded.sample_rate;
147 let max_samples = (30.0 * sr as f64) as usize;
148 let capped = &decoded.samples[..decoded.samples.len().min(max_samples)];
149
150 let crest = basic::crest_factor(&decoded.samples);
151 let attack = basic::attack_time(&decoded.samples, sr);
152 let (features, magnitude_frames) =
153 spectral::compute_spectral_features_with_frames(capped, sr);
154 let mfcc_features = mfcc::compute_mfccs(&magnitude_frames, sr, 1024);
155 let input = ClassifyInput::with_mfccs(&features, decoded.duration, crest, attack, &mfcc_features);
156 let result = classify::classify_ml(&input);
157 Some((result.class, result.confidence))
158 }
159
160 // ── File Discovery ──
161
162 fn audio_extensions() -> &'static [&'static str] {
163 &[".wav", ".aif", ".aiff", ".mp3", ".ogg", ".flac"]
164 }
165
166 fn is_audio(name: &str) -> bool {
167 let lower = name.to_lowercase();
168 audio_extensions().iter().any(|ext| lower.ends_with(ext))
169 }
170
171 fn collect_audio_files(dir: &Path, limit: Option<usize>) -> Vec<PathBuf> {
172 let mut files: Vec<PathBuf> = Vec::new();
173 if !dir.exists() {
174 return files;
175 }
176 for entry in walkdir(dir) {
177 if let Some(lim) = limit {
178 if files.len() >= lim {
179 break;
180 }
181 }
182 files.push(entry);
183 }
184 files
185 }
186
187 fn walkdir(dir: &Path) -> Vec<PathBuf> {
188 let mut out = Vec::new();
189 if let Ok(entries) = std::fs::read_dir(dir) {
190 for entry in entries.flatten() {
191 let path = entry.path();
192 if path.is_dir() {
193 out.extend(walkdir(&path));
194 } else {
195 let name = entry.file_name().to_string_lossy().to_string();
196 if is_audio(&name) {
197 // Resolve symlinks
198 let resolved = std::fs::read_link(&path).unwrap_or(path);
199 if resolved.exists() {
200 out.push(resolved);
201 }
202 }
203 }
204 }
205 }
206 out
207 }
208
209 // ── Report Formatting ──
210
211 fn percentile(values: &mut [f64], p: f64) -> f64 {
212 if values.is_empty() {
213 return 0.0;
214 }
215 values.sort_by(|a, b| a.total_cmp(b));
216 let idx = (p / 100.0 * (values.len() - 1) as f64).round() as usize;
217 values[idx.min(values.len() - 1)]
218 }
219
220 fn main() {
221 let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
222 .parent()
223 .unwrap()
224 .parent()
225 .unwrap()
226 .to_path_buf();
227 let samples_dir = project_root.join("samples");
228 let training_dir = samples_dir.join("training");
229 let test_suite_dir = samples_dir.join("test-suite");
230
231 println!("╔══════════════════════════════════════════════════════════════╗");
232 println!("║ AudioFiles Analysis Pipeline — Benchmark Report ║");
233 println!("╚══════════════════════════════════════════════════════════════╝");
234 println!();
235
236 // ─────────────────────────────────────────────────────────────
237 // Section 1: Per-Stage Timing (representative sample set)
238 // ─────────────────────────────────────────────────────────────
239 println!("━━━ 1. PER-STAGE TIMING ━━━");
240 println!();
241
242 // Collect samples from different sources for timing
243 let timing_sources: Vec<(&str, PathBuf, Option<usize>)> = vec![
244 ("drum one-shots (WAV)", training_dir.join("kick"), Some(200)),
245 ("drum one-shots (WAV)", training_dir.join("snare"), Some(200)),
246 ("drum one-shots (WAV)", training_dir.join("hihat"), Some(200)),
247 ("philharmonia (MP3)", test_suite_dir.join("formats/mp3"), Some(50)),
248 ("AIFF samples", test_suite_dir.join("formats/aiff"), Some(10)),
249 ("FLAC samples", test_suite_dir.join("formats/flac"), Some(10)),
250 ("ambient loops (WAV)", test_suite_dir.join("genres/ambient"), Some(50)),
251 ("synth loops (WAV)", test_suite_dir.join("genres/synth"), Some(50)),
252 ("guitar loops (WAV)", test_suite_dir.join("genres/guitar"), Some(50)),
253 ];
254
255 let mut all_timings: Vec<(StageTiming, f64, u32, String)> = Vec::new();
256
257 for (label, dir, limit) in &timing_sources {
258 let files = collect_audio_files(dir, *limit);
259 if files.is_empty() {
260 continue;
261 }
262 let results: Vec<_> = files
263 .par_iter()
264 .filter_map(|f| {
265 time_stages(f).map(|(t, dur, sr)| (t, dur, sr, label.to_string()))
266 })
267 .collect();
268 all_timings.extend(results);
269 }
270
271 if all_timings.is_empty() {
272 eprintln!("No files found for timing benchmarks!");
273 std::process::exit(1);
274 }
275
276 let n = all_timings.len();
277 println!("Benchmarked {} files", n);
278 println!();
279
280 // Aggregate per-stage
281 let mut decode = Vec::new();
282 let mut loud = Vec::new();
283 let mut spec = Vec::new();
284 let mut mfcc_t = Vec::new();
285 let mut class = Vec::new();
286 let mut bpm_t = Vec::new();
287 let mut loop_t = Vec::new();
288 let mut fp_t = Vec::new();
289 let mut total = Vec::new();
290 let mut durations = Vec::new();
291
292 for (t, dur, _sr, _) in &all_timings {
293 decode.push(t.decode_ms);
294 loud.push(t.loudness_ms);
295 spec.push(t.spectral_ms);
296 mfcc_t.push(t.mfcc_ms);
297 class.push(t.classify_ms);
298 bpm_t.push(t.bpm_key_ms);
299 loop_t.push(t.loop_ms);
300 fp_t.push(t.fingerprint_ms);
301 total.push(t.total_ms);
302 durations.push(*dur);
303 }
304
305 fn stats_line(name: &str, vals: &mut Vec<f64>) {
306 let mean = vals.iter().sum::<f64>() / vals.len() as f64;
307 let p50 = percentile(vals, 50.0);
308 let p95 = percentile(vals, 95.0);
309 let p99 = percentile(vals, 99.0);
310 let max = percentile(vals, 100.0);
311 println!(
312 " {:<16} {:>8.2} {:>8.2} {:>8.2} {:>8.2} {:>8.2}",
313 name, mean, p50, p95, p99, max
314 );
315 }
316
317 println!(" {:<16} {:>8} {:>8} {:>8} {:>8} {:>8}", "Stage", "Mean", "P50", "P95", "P99", "Max");
318 println!(" {}", "".repeat(58));
319 stats_line("Decode", &mut decode);
320 stats_line("Loudness+LUFS", &mut loud);
321 stats_line("Spectral/STFT", &mut spec);
322 stats_line("MFCC", &mut mfcc_t);
323 stats_line("Classify", &mut class);
324 stats_line("BPM+Key", &mut bpm_t);
325 stats_line("Loop Detect", &mut loop_t);
326 stats_line("Fingerprint", &mut fp_t);
327 println!(" {}", "".repeat(58));
328 stats_line("TOTAL", &mut total);
329 println!();
330
331 // Duration stats
332 let avg_dur = durations.iter().sum::<f64>() / durations.len() as f64;
333 let avg_total = total.iter().sum::<f64>() / total.len() as f64;
334 let realtime_ratio = avg_dur * 1000.0 / avg_total;
335 println!(" Avg sample duration: {:.2}s", avg_dur);
336 println!(" Avg analysis time: {:.1}ms", avg_total);
337 println!(" Real-time ratio: {:.0}× (analysis is {:.0}× faster than real-time)", realtime_ratio, realtime_ratio);
338 println!();
339
340 // ─────────────────────────────────────────────────────────────
341 // Section 2: Format-Specific Performance
342 // ─────────────────────────────────────────────────────────────
343 println!("━━━ 2. FORMAT-SPECIFIC DECODE PERFORMANCE ━━━");
344 println!();
345
346 let format_dirs: Vec<(&str, PathBuf)> = vec![
347 ("WAV", test_suite_dir.join("formats/wav")),
348 ("AIFF", test_suite_dir.join("formats/aiff")),
349 ("MP3", test_suite_dir.join("formats/mp3")),
350 ("FLAC", test_suite_dir.join("formats/flac")),
351 ];
352
353 println!(" {:<8} {:>6} {:>10} {:>10} {:>10}", "Format", "Files", "Mean(ms)", "P95(ms)", "Max(ms)");
354 println!(" {}", "".repeat(50));
355
356 for (fmt, dir) in &format_dirs {
357 let files = collect_audio_files(dir, Some(100));
358 if files.is_empty() {
359 println!(" {:<8} {:>6} {:>10} {:>10} {:>10}", fmt, 0, "-", "-", "-");
360 continue;
361 }
362 let mut decode_times: Vec<f64> = files
363 .par_iter()
364 .filter_map(|f| {
365 let t = Instant::now();
366 decode::decode_to_mono(f).ok()?;
367 Some(t.elapsed().as_secs_f64() * 1000.0)
368 })
369 .collect();
370 let count = decode_times.len();
371 let mean = decode_times.iter().sum::<f64>() / count as f64;
372 let p95 = percentile(&mut decode_times, 95.0);
373 let max = percentile(&mut decode_times, 100.0);
374 println!(" {:<8} {:>6} {:>10.2} {:>10.2} {:>10.2}", fmt, count, mean, p95, max);
375 }
376 println!();
377
378 // ─────────────────────────────────────────────────────────────
379 // Section 3: Throughput
380 // ─────────────────────────────────────────────────────────────
381 println!("━━━ 3. THROUGHPUT ━━━");
382 println!();
383
384 // Parallel full-pipeline throughput on 500 drum one-shots
385 let throughput_files = collect_audio_files(&training_dir.join("kick"), Some(250));
386 let mut throughput_files_ext = throughput_files;
387 throughput_files_ext.extend(collect_audio_files(&training_dir.join("snare"), Some(250)));
388
389 let config = AnalysisConfig {
390 loudness: true,
391 spectral: true,
392 bpm: true,
393 key: true,
394 loop_detect: true,
395 classify: true,
396 fingerprint: true,
397 auto_suggest_tags: false,
398 max_analysis_seconds: Some(30.0),
399 smart_skip: false,
400 };
401
402 let tp_count = throughput_files_ext.len();
403 let tp_start = Instant::now();
404 let tp_ok: usize = throughput_files_ext
405 .par_iter()
406 .filter(|f| analysis::analyze_sample("bench", f, &config).is_ok())
407 .count();
408 let tp_elapsed = tp_start.elapsed().as_secs_f64();
409 let tp_rate = tp_ok as f64 / tp_elapsed;
410
411 println!(" Full pipeline (all stages, parallel):");
412 println!(" Files: {} ({} succeeded)", tp_count, tp_ok);
413 println!(" Wall time: {:.1}s", tp_elapsed);
414 println!(" Throughput: {:.1} files/sec", tp_rate);
415 println!(" Avg/file: {:.1}ms", tp_elapsed * 1000.0 / tp_ok as f64);
416 println!();
417
418 // Single-threaded throughput for comparison
419 let st_files = collect_audio_files(&training_dir.join("kick"), Some(100));
420 let st_start = Instant::now();
421 let st_ok: usize = st_files
422 .iter()
423 .filter(|f| analysis::analyze_sample("bench", f, &config).is_ok())
424 .count();
425 let st_elapsed = st_start.elapsed().as_secs_f64();
426 let st_rate = st_ok as f64 / st_elapsed;
427
428 println!(" Single-threaded comparison (100 files):");
429 println!(" Throughput: {:.1} files/sec", st_rate);
430 println!(" Speedup from parallelism: {:.1}×", tp_rate / st_rate);
431 println!();
432
433 // ─────────────────────────────────────────────────────────────
434 // Section 4: Resource Usage
435 // ─────────────────────────────────────────────────────────────
436 println!("━━━ 4. RESOURCE USAGE ━━━");
437 println!();
438
439 // Memory estimation: decode a few files of varying duration and measure buffer sizes
440 let mem_files: Vec<(&str, PathBuf)> = vec![
441 ("Short drum hit", training_dir.join("kick")),
442 ("Medium loop", test_suite_dir.join("genres/ambient")),
443 ];
444
445 println!(" Per-sample memory (mono f32 decode buffer):");
446 for (label, dir) in &mem_files {
447 let files = collect_audio_files(dir, Some(10));
448 if files.is_empty() {
449 continue;
450 }
451 let mut sizes: Vec<(f64, usize)> = Vec::new();
452 for f in &files {
453 if let Ok(decoded) = decode::decode_to_mono(f) {
454 let bytes = decoded.samples.len() * 4; // f32 = 4 bytes
455 sizes.push((decoded.duration, bytes));
456 }
457 }
458 if !sizes.is_empty() {
459 let avg_dur = sizes.iter().map(|(d, _)| d).sum::<f64>() / sizes.len() as f64;
460 let avg_bytes = sizes.iter().map(|(_, b)| *b).sum::<usize>() / sizes.len();
461 let max_bytes = sizes.iter().map(|(_, b)| *b).max().unwrap_or(0);
462 println!(
463 " {}: avg {:.2}s = {:.1} KB, max = {:.1} KB",
464 label,
465 avg_dur,
466 avg_bytes as f64 / 1024.0,
467 max_bytes as f64 / 1024.0
468 );
469 }
470 }
471 println!();
472
473 // STFT frame memory
474 let frame_samples = 1024usize;
475 let hop = 1024usize;
476 let thirty_sec_frames = (30.0 * 44100.0 / hop as f64) as usize;
477 let frame_mem = thirty_sec_frames * (frame_samples / 2 + 1) * 8; // f64 magnitude bins
478 println!(" STFT magnitude frames (30s @ 44.1kHz, 1024-sample window):");
479 println!(" Frames: {}", thirty_sec_frames);
480 println!(" Memory: {:.1} MB", frame_mem as f64 / 1_048_576.0);
481 println!();
482
483 // Model size
484 let model_path = project_root.join("crates/audiofiles-core/models/layer2_drum.json");
485 if let Ok(meta) = std::fs::metadata(&model_path) {
486 println!(" RF model (layer2_drum.json): {:.1} MB on disk, embedded at compile time", meta.len() as f64 / 1_048_576.0);
487 }
488 println!();
489
490 // ─────────────────────────────────────────────────────────────
491 // Section 5: Classification Accuracy
492 // ─────────────────────────────────────────────────────────────
493 println!("━━━ 5. CLASSIFICATION ACCURACY ━━━");
494 println!();
495
496 let class_dirs = ["kick", "snare", "hihat", "cymbal", "clap", "tom", "percussion"];
497 let max_per_class = 300; // enough for statistical significance, not too slow
498
499 let mut all_results: Vec<ClassifyResult> = Vec::new();
500
501 for dir_name in &class_dirs {
502 let dir = training_dir.join(dir_name);
503 let files = collect_audio_files(&dir, Some(max_per_class));
504 if files.is_empty() {
505 continue;
506 }
507
508 let expected = match expected_class_from_dir(dir_name) {
509 Some(c) => c,
510 None => continue,
511 };
512
513 let results: Vec<ClassifyResult> = files
514 .par_iter()
515 .filter_map(|f| {
516 let (predicted, confidence) = classify_file(f)?;
517 Some(ClassifyResult {
518 expected,
519 predicted,
520 confidence,
521 })
522 })
523 .collect();
524
525 all_results.extend(results);
526 }
527
528 let total_classified = all_results.len();
529 println!(" Evaluated {} samples (up to {} per class)", total_classified, max_per_class);
530 println!();
531
532 // Strict accuracy: predicted class == expected class exactly
533 let strict_correct = all_results
534 .iter()
535 .filter(|r| r.predicted == r.expected)
536 .count();
537 let strict_acc = strict_correct as f64 / total_classified as f64 * 100.0;
538
539 // Layer 1 accuracy: predicted is any drum class when expected is drum
540 let drum_correct = all_results
541 .iter()
542 .filter(|r| is_drum_class(r.predicted))
543 .count();
544 let drum_acc = drum_correct as f64 / total_classified as f64 * 100.0;
545
546 println!(" Overall:");
547 println!(" Strict accuracy (exact class match): {:.1}% ({}/{})", strict_acc, strict_correct, total_classified);
548 println!(" Layer 1 accuracy (drum detection): {:.1}% ({}/{})", drum_acc, drum_correct, total_classified);
549 println!();
550
551 // Per-class breakdown
552 println!(" Per-class (strict):");
553 println!(" {:<12} {:>6} {:>8} {:>10} {:>10}", "Expected", "N", "Correct", "Accuracy", "Avg Conf");
554 println!(" {}", "".repeat(52));
555
556 for dir_name in &class_dirs {
557 let expected = match expected_class_from_dir(dir_name) {
558 Some(c) => c,
559 None => continue,
560 };
561 let class_results: Vec<&ClassifyResult> = all_results
562 .iter()
563 .filter(|r| r.expected == expected)
564 .collect();
565 if class_results.is_empty() {
566 continue;
567 }
568 let n = class_results.len();
569 let correct = class_results.iter().filter(|r| r.predicted == r.expected).count();
570 let acc = correct as f64 / n as f64 * 100.0;
571 let avg_conf = class_results.iter().map(|r| r.confidence).sum::<f64>() / n as f64;
572 println!(
573 " {:<12} {:>6} {:>8} {:>9.1}% {:>9.2}",
574 dir_name, n, correct, acc, avg_conf
575 );
576 }
577 println!();
578
579 // Confusion matrix (7 classes)
580 let class_names = ["kick", "snare", "hihat", "cymbal", "clap", "tom", "perc"];
581 let class_ids: Vec<SampleClass> = vec![
582 SampleClass::Kick,
583 SampleClass::Snare,
584 SampleClass::HiHat,
585 SampleClass::Cymbal,
586 SampleClass::Clap,
587 SampleClass::Tom,
588 SampleClass::Percussion,
589 ];
590
591 println!(" Confusion matrix (rows=expected, cols=predicted):");
592 print!(" {:>12}", "");
593 for name in &class_names {
594 print!(" {:>7}", name);
595 }
596 println!(" {:>7}", "other");
597 println!(" {}", "".repeat(60));
598
599 for (i, expected) in class_ids.iter().enumerate() {
600 let class_results: Vec<&ClassifyResult> = all_results
601 .iter()
602 .filter(|r| r.expected == *expected)
603 .collect();
604 if class_results.is_empty() {
605 continue;
606 }
607 print!(" {:>12}", class_names[i]);
608 for pred in &class_ids {
609 let count = class_results.iter().filter(|r| r.predicted == *pred).count();
610 print!(" {:>7}", count);
611 }
612 let other = class_results
613 .iter()
614 .filter(|r| !is_drum_class(r.predicted))
615 .count();
616 println!(" {:>7}", other);
617 }
618 println!();
619
620 // Non-drum misclassifications
621 let non_drum: Vec<&ClassifyResult> = all_results
622 .iter()
623 .filter(|r| !is_drum_class(r.predicted))
624 .collect();
625 if !non_drum.is_empty() {
626 println!(" Samples classified as non-drum: {} ({:.1}%)", non_drum.len(), non_drum.len() as f64 / total_classified as f64 * 100.0);
627 let mut non_drum_classes: HashMap<&str, usize> = HashMap::new();
628 for r in &non_drum {
629 *non_drum_classes.entry(r.predicted.as_str()).or_default() += 1;
630 }
631 let mut sorted: Vec<_> = non_drum_classes.into_iter().collect();
632 sorted.sort_by(|a, b| b.1.cmp(&a.1));
633 for (class, count) in &sorted {
634 println!(" {}{}", count, class);
635 }
636 }
637 println!();
638
639 // ─────────────────────────────────────────────────────────────
640 // Section 6: Edge Cases
641 // ─────────────────────────────────────────────────────────────
642 println!("━━━ 6. EDGE CASE HANDLING ━━━");
643 println!();
644
645 let edge_cases = [
646 ("silent.wav", test_suite_dir.join("edge-cases/silent.wav")),
647 ("truncated.wav", test_suite_dir.join("edge-cases/truncated.wav")),
648 ("café-crème.wav", test_suite_dir.join("edge-cases/unicode-name/café-crème.wav")),
649 ("日本語テスト.wav", test_suite_dir.join("edge-cases/unicode-name/日本語テスト.wav")),
650 ];
651
652 for (name, path) in &edge_cases {
653 let config = AnalysisConfig {
654 loudness: true,
655 spectral: true,
656 bpm: true,
657 key: true,
658 loop_detect: true,
659 classify: true,
660 fingerprint: true,
661 auto_suggest_tags: false,
662 max_analysis_seconds: Some(30.0),
663 smart_skip: false,
664 };
665
666 match analysis::analyze_sample("edge", path, &config) {
667 Ok(r) => {
668 println!(
669 " {} → OK (dur={:.2}s, class={}, conf={:.2})",
670 name,
671 r.duration,
672 r.classification.map(|c| c.as_str()).unwrap_or("none"),
673 r.classification_confidence.unwrap_or(0.0)
674 );
675 }
676 Err(e) => {
677 println!(" {} → ERROR: {}", name, e);
678 }
679 }
680 }
681 println!();
682
683 println!("═══════════════════════════════════════════════════════════════");
684 println!(" Benchmark complete. {} files analyzed.", n + total_classified);
685 println!("═══════════════════════════════════════════════════════════════");
686 }
687