max / audiofiles
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
5 files changed,
+1204 insertions,
-2 deletions
| @@ -43,3 +43,6 @@ | |||
| 43 | 43 | ||
| 44 | 44 | # Private working files — live in _private/, synced via Syncthing | |
| 45 | 45 | todo.md | |
| 46 | + | ||
| 47 | + | # Python bytecode from scripts/ | |
| 48 | + | __pycache__/ |
| @@ -3,7 +3,17 @@ | |||
| 3 | 3 | //! Measures per-stage timing, throughput, resource usage, and classification accuracy | |
| 4 | 4 | //! against labeled training data. | |
| 5 | 5 | //! | |
| 6 | - | //! Usage: `cargo run --release -p audiofiles-bench` | |
| 6 | + | //! Usage: | |
| 7 | + | //! `cargo run --release -p audiofiles-bench` analysis pipeline | |
| 8 | + | //! `cargo run --release -p audiofiles-bench -- ingest` vault ingest + queries | |
| 9 | + | //! `cargo run --release -p audiofiles-bench -- accuracy` bpm/key vs ground truth | |
| 10 | + | //! | |
| 11 | + | //! Env: `AF_BENCH_CORPUS` (corpus root, default `<repo>/samples`), | |
| 12 | + | //! `AF_BENCH_VAULT` (scratch vault for ingest), `AF_BENCH_FSL10K` (FSL10K root | |
| 13 | + | //! for accuracy), `AF_BENCH_BATCH`, `AF_BENCH_LIMIT`. | |
| 14 | + | ||
| 15 | + | mod accuracy; | |
| 16 | + | mod ingest; | |
| 7 | 17 | ||
| 8 | 18 | use std::collections::HashMap; | |
| 9 | 19 | use std::path::{Path, PathBuf}; | |
| @@ -219,14 +229,62 @@ | |||
| 219 | 229 | values[idx.min(values.len() - 1)] | |
| 220 | 230 | } | |
| 221 | 231 | ||
| 232 | + | /// Locate the corpus. | |
| 233 | + | /// | |
| 234 | + | /// The corpus is gitignored and rebuilt per machine (see `scripts/corpus.py`), | |
| 235 | + | /// and on a dev box it is usually on an external drive rather than in the | |
| 236 | + | /// checkout, so the in-repo path is only the fallback. | |
| 237 | + | fn corpus_dir() -> PathBuf { | |
| 238 | + | if let Ok(dir) = std::env::var("AF_BENCH_CORPUS") { | |
| 239 | + | return PathBuf::from(dir); | |
| 240 | + | } | |
| 241 | + | PathBuf::from(env!("CARGO_MANIFEST_DIR")) | |
| 242 | + | .parent() | |
| 243 | + | .unwrap() | |
| 244 | + | .parent() | |
| 245 | + | .unwrap() | |
| 246 | + | .join("samples") | |
| 247 | + | } | |
| 248 | + | ||
| 222 | 249 | fn main() { | |
| 250 | + | let args: Vec<String> = std::env::args().skip(1).collect(); | |
| 251 | + | let samples_dir = corpus_dir(); | |
| 223 | 252 | let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) | |
| 224 | 253 | .parent() | |
| 225 | 254 | .unwrap() | |
| 226 | 255 | .parent() | |
| 227 | 256 | .unwrap() | |
| 228 | 257 | .to_path_buf(); | |
| 229 | - | let samples_dir = project_root.join("samples"); | |
| 258 | + | ||
| 259 | + | if args.first().map(String::as_str) == Some("ingest") { | |
| 260 | + | let vault = std::env::var("AF_BENCH_VAULT").map_or_else( | |
| 261 | + | |_| std::env::temp_dir().join("af-bench-vault"), | |
| 262 | + | PathBuf::from, | |
| 263 | + | ); | |
| 264 | + | let batch = std::env::var("AF_BENCH_BATCH") | |
| 265 | + | .ok() | |
| 266 | + | .and_then(|v| v.parse().ok()) | |
| 267 | + | .unwrap_or(500); | |
| 268 | + | let limit = std::env::var("AF_BENCH_LIMIT") | |
| 269 | + | .ok() | |
| 270 | + | .and_then(|v| v.parse().ok()); | |
| 271 | + | ingest::run(&samples_dir, &vault, batch, limit); | |
| 272 | + | return; | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | if args.first().map(String::as_str) == Some("accuracy") { | |
| 276 | + | let Ok(root) = std::env::var("AF_BENCH_FSL10K") else { | |
| 277 | + | eprintln!("set AF_BENCH_FSL10K to the extracted FSL10K root"); | |
| 278 | + | eprintln!("fetch it with: ./scripts/corpus.py --datasets fsl10k"); | |
| 279 | + | std::process::exit(1); | |
| 280 | + | }; | |
| 281 | + | let limit = std::env::var("AF_BENCH_LIMIT") | |
| 282 | + | .ok() | |
| 283 | + | .and_then(|v| v.parse().ok()); | |
| 284 | + | accuracy::run(&PathBuf::from(root), limit); | |
| 285 | + | return; | |
| 286 | + | } | |
| 287 | + | ||
| 230 | 288 | let training_dir = samples_dir.join("training"); | |
| 231 | 289 | let test_suite_dir = samples_dir.join("test-suite"); | |
| 232 | 290 |
| @@ -1,0 +1,449 @@ | |||
| 1 | + | //! BPM and key accuracy against FSL10K ground truth. | |
| 2 | + | //! | |
| 3 | + | //! Section 5 of the analysis bench already scores classification against folder | |
| 4 | + | //! labels. This scores the other two things the pipeline emits and that nothing | |
| 5 | + | //! else can check: `bpm::detect_bpm_key`'s tempo and key. | |
| 6 | + | //! | |
| 7 | + | //! Ground truth comes from the Freesound Loop Dataset (CC-BY 4.0), which is the | |
| 8 | + | //! only corpus here carrying expert tempo and key annotations. Fetch it with | |
| 9 | + | //! `scripts/corpus.py --datasets fsl10k`, then point this at the extracted root: | |
| 10 | + | //! | |
| 11 | + | //! AF_BENCH_FSL10K=/media/max/T9/af-corpus/_raw/fsl10k \ | |
| 12 | + | //! cargo run --release -p audiofiles-bench -- accuracy | |
| 13 | + | //! | |
| 14 | + | //! Tempo is scored with the standard MIREX pair rather than a single number, | |
| 15 | + | //! because a tempo estimator that is "wrong" is usually wrong by a factor of | |
| 16 | + | //! two, and collapsing that into one accuracy figure hides which kind of wrong | |
| 17 | + | //! it is: | |
| 18 | + | //! Acc1 = within 4% of ground truth. | |
| 19 | + | //! Acc2 = Acc1, or within 4% of truth scaled by 1/3, 1/2, 2, or 3. | |
| 20 | + | //! A large Acc2-minus-Acc1 gap means octave errors, which are a different fix | |
| 21 | + | //! from being diffusely inaccurate. | |
| 22 | + | ||
| 23 | + | use std::collections::HashMap; | |
| 24 | + | use std::path::{Path, PathBuf}; | |
| 25 | + | ||
| 26 | + | use audiofiles_core::analysis::{bpm, decode}; | |
| 27 | + | use rayon::prelude::*; | |
| 28 | + | ||
| 29 | + | /// Ground truth for one sound, after consensus across annotators. | |
| 30 | + | struct Truth { | |
| 31 | + | bpm: f64, | |
| 32 | + | /// Pitch class 0..=11 with mode, or None when the annotators marked the | |
| 33 | + | /// loop as having no key (drum loops, percussion, fx). | |
| 34 | + | key: Option<(u8, Mode)>, | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | #[derive(Clone, Copy, PartialEq, Eq, Debug)] | |
| 38 | + | enum Mode { | |
| 39 | + | Major, | |
| 40 | + | Minor, | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | /// Parse a note name to a pitch class. | |
| 44 | + | /// | |
| 45 | + | /// Comparing pitch classes rather than strings sidesteps enharmonic spelling | |
| 46 | + | /// entirely: the annotations use sharps only, but nothing guarantees the | |
| 47 | + | /// detector does, and "A#" and "Bb" are the same key. | |
| 48 | + | fn pitch_class(note: &str) -> Option<u8> { | |
| 49 | + | let s = note.trim().to_lowercase(); | |
| 50 | + | let mut chars = s.chars(); | |
| 51 | + | let base = match chars.next()? { | |
| 52 | + | 'c' => 0, | |
| 53 | + | 'd' => 2, | |
| 54 | + | 'e' => 4, | |
| 55 | + | 'f' => 5, | |
| 56 | + | 'g' => 7, | |
| 57 | + | 'a' => 9, | |
| 58 | + | 'b' => 11, | |
| 59 | + | _ => return None, | |
| 60 | + | }; | |
| 61 | + | let accidental: i32 = match chars.next() { | |
| 62 | + | Some('#' | 's') => 1, | |
| 63 | + | Some('b' | 'f') => -1, | |
| 64 | + | None => 0, | |
| 65 | + | _ => return None, | |
| 66 | + | }; | |
| 67 | + | Some(((base + accidental).rem_euclid(12)) as u8) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | /// Parse a detected key string into a pitch class and mode. | |
| 71 | + | /// | |
| 72 | + | /// Handles both shapes on purpose. `detect_bpm_key` now normalises to | |
| 73 | + | /// "A minor" / "C major", but before that it passed through | |
| 74 | + | /// `stratum_dsp::Key::name()` unchanged, which is "C" / "F#" for major and | |
| 75 | + | /// "Am" / "C#m" for minor. Vaults analysed before migration 034 can still hold | |
| 76 | + | /// the compact form, and accepting only one spelling silently scores 0% | |
| 77 | + | /// forever rather than failing loudly, so both are parsed. | |
| 78 | + | fn parse_app_key(s: &str) -> Option<(u8, Mode)> { | |
| 79 | + | let s = s.trim(); | |
| 80 | + | if let Some((note, word)) = s.rsplit_once(' ') { | |
| 81 | + | let mode = match word.trim().to_lowercase().as_str() { | |
| 82 | + | "minor" | "min" => Mode::Minor, | |
| 83 | + | "major" | "maj" => Mode::Major, | |
| 84 | + | _ => return None, | |
| 85 | + | }; | |
| 86 | + | return Some((pitch_class(note)?, mode)); | |
| 87 | + | } | |
| 88 | + | // Bare form: trailing 'm' means minor, otherwise major. Checked after the | |
| 89 | + | // spaced form so "F major" is not read as a note named "F majo" + m. | |
| 90 | + | if let Some(note) = s.strip_suffix('m') { | |
| 91 | + | return Some((pitch_class(note)?, Mode::Minor)); | |
| 92 | + | } | |
| 93 | + | Some((pitch_class(s)?, Mode::Major)) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | /// Load and reconcile annotations. | |
| 97 | + | /// | |
| 98 | + | /// Half the annotated sounds in FSL10K carry more than one annotator's | |
| 99 | + | /// judgement, and annotators disagree. Rather than picking one arbitrarily, | |
| 100 | + | /// disagreements are dropped and counted: scoring a detector against a | |
| 101 | + | /// contested label measures the disagreement, not the detector. | |
| 102 | + | fn load_truth(annotations: &Path) -> (HashMap<String, Truth>, usize, usize) { | |
| 103 | + | let mut by_sound: HashMap<String, Vec<serde_json::Value>> = HashMap::new(); | |
| 104 | + | let mut discarded = 0usize; | |
| 105 | + | ||
| 106 | + | let mut stack = vec![annotations.to_path_buf()]; | |
| 107 | + | while let Some(dir) = stack.pop() { | |
| 108 | + | let Ok(entries) = std::fs::read_dir(&dir) else { | |
| 109 | + | continue; | |
| 110 | + | }; | |
| 111 | + | for entry in entries.flatten() { | |
| 112 | + | let path = entry.path(); | |
| 113 | + | if path.is_dir() { | |
| 114 | + | stack.push(path); | |
| 115 | + | continue; | |
| 116 | + | } | |
| 117 | + | let Some(name) = path.file_name().and_then(|n| n.to_str()) else { | |
| 118 | + | continue; | |
| 119 | + | }; | |
| 120 | + | let Some(id) = name | |
| 121 | + | .strip_prefix("sound-") | |
| 122 | + | .and_then(|n| n.strip_suffix(".json")) | |
| 123 | + | else { | |
| 124 | + | continue; | |
| 125 | + | }; | |
| 126 | + | let Ok(text) = std::fs::read_to_string(&path) else { | |
| 127 | + | continue; | |
| 128 | + | }; | |
| 129 | + | let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) else { | |
| 130 | + | continue; | |
| 131 | + | }; | |
| 132 | + | if v.get("discard").and_then(serde_json::Value::as_bool) == Some(true) { | |
| 133 | + | discarded += 1; | |
| 134 | + | continue; | |
| 135 | + | } | |
| 136 | + | by_sound.entry(id.to_string()).or_default().push(v); | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | let mut truth = HashMap::new(); | |
| 141 | + | let mut disputed = 0usize; | |
| 142 | + | ||
| 143 | + | for (id, annots) in by_sound { | |
| 144 | + | // BPM is stored as a string in these files. | |
| 145 | + | let bpms: Vec<f64> = annots | |
| 146 | + | .iter() | |
| 147 | + | .filter_map(|a| a.get("bpm").and_then(serde_json::Value::as_str)) | |
| 148 | + | .filter_map(|s| s.trim().parse::<f64>().ok()) | |
| 149 | + | .filter(|b| *b > 0.0) | |
| 150 | + | .collect(); | |
| 151 | + | if bpms.is_empty() { | |
| 152 | + | continue; | |
| 153 | + | } | |
| 154 | + | // Annotators agree if they are all within 4% of the first, the same | |
| 155 | + | // tolerance the scoring uses. | |
| 156 | + | let first = bpms[0]; | |
| 157 | + | if bpms.iter().any(|b| (b - first).abs() / first > 0.04) { | |
| 158 | + | disputed += 1; | |
| 159 | + | continue; | |
| 160 | + | } | |
| 161 | + | let bpm_truth = bpms.iter().sum::<f64>() / bpms.len() as f64; | |
| 162 | + | ||
| 163 | + | let keys: Vec<Option<(u8, Mode)>> = annots | |
| 164 | + | .iter() | |
| 165 | + | .map(|a| { | |
| 166 | + | let k = a.get("key").and_then(serde_json::Value::as_str)?; | |
| 167 | + | let m = a.get("mode").and_then(serde_json::Value::as_str)?; | |
| 168 | + | let mode = match m { | |
| 169 | + | "min" => Mode::Minor, | |
| 170 | + | "maj" => Mode::Major, | |
| 171 | + | _ => return None, | |
| 172 | + | }; | |
| 173 | + | Some((pitch_class(k)?, mode)) | |
| 174 | + | }) | |
| 175 | + | .collect(); | |
| 176 | + | // "none"/"unknown" parse to None, which is itself a meaningful answer | |
| 177 | + | // (the loop has no key), so only disagreement between Some values is a | |
| 178 | + | // dispute. | |
| 179 | + | let key_truth = if keys.iter().all(|k| *k == keys[0]) { | |
| 180 | + | keys[0] | |
| 181 | + | } else { | |
| 182 | + | None | |
| 183 | + | }; | |
| 184 | + | ||
| 185 | + | truth.insert( | |
| 186 | + | id, | |
| 187 | + | Truth { | |
| 188 | + | bpm: bpm_truth, | |
| 189 | + | key: key_truth, | |
| 190 | + | }, | |
| 191 | + | ); | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | (truth, discarded, disputed) | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | /// Index audio files by Freesound sound ID (the filename stem). | |
| 198 | + | fn index_audio(root: &Path) -> HashMap<String, PathBuf> { | |
| 199 | + | let mut out = HashMap::new(); | |
| 200 | + | let mut stack = vec![root.to_path_buf()]; | |
| 201 | + | while let Some(dir) = stack.pop() { | |
| 202 | + | let Ok(entries) = std::fs::read_dir(&dir) else { | |
| 203 | + | continue; | |
| 204 | + | }; | |
| 205 | + | for entry in entries.flatten() { | |
| 206 | + | let path = entry.path(); | |
| 207 | + | if path.is_dir() { | |
| 208 | + | stack.push(path); | |
| 209 | + | continue; | |
| 210 | + | } | |
| 211 | + | let is_audio = path.extension().and_then(|e| e.to_str()).is_some_and(|e| { | |
| 212 | + | matches!( | |
| 213 | + | e.to_lowercase().as_str(), | |
| 214 | + | "wav" | "flac" | "mp3" | "ogg" | "aif" | "aiff" | |
| 215 | + | ) | |
| 216 | + | }); | |
| 217 | + | if !is_audio { | |
| 218 | + | continue; | |
| 219 | + | } | |
| 220 | + | if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { | |
| 221 | + | // FSL10K names audio by sound ID, sometimes with a suffix after | |
| 222 | + | // the ID; take the leading digit run. | |
| 223 | + | let id: String = stem.chars().take_while(char::is_ascii_digit).collect(); | |
| 224 | + | if !id.is_empty() { | |
| 225 | + | out.insert(id, path); | |
| 226 | + | } | |
| 227 | + | } | |
| 228 | + | } | |
| 229 | + | } | |
| 230 | + | out | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | fn within(a: f64, b: f64, tol: f64) -> bool { | |
| 234 | + | b > 0.0 && (a - b).abs() / b <= tol | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | struct Scored { | |
| 238 | + | bpm_acc1: bool, | |
| 239 | + | bpm_acc2: bool, | |
| 240 | + | /// Set only when both truth and detection carry a key. | |
| 241 | + | key_exact: Option<bool>, | |
| 242 | + | key_related: Option<bool>, | |
| 243 | + | /// Tracked separately so a zero key score can be attributed. "No key was | |
| 244 | + | /// scored" has three very different causes -- the loop has no key in the | |
| 245 | + | /// ground truth, the detector returned nothing, or the detected string | |
| 246 | + | /// failed to parse -- and collapsing them hides a parser bug as a result. | |
| 247 | + | had_truth_key: bool, | |
| 248 | + | had_detected_key: bool, | |
| 249 | + | detected_key_unparsed: bool, | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | fn score(detected_bpm: Option<f64>, detected_key: Option<&str>, truth: &Truth) -> Scored { | |
| 253 | + | let (acc1, acc2) = match detected_bpm { | |
| 254 | + | Some(d) => { | |
| 255 | + | let a1 = within(d, truth.bpm, 0.04); | |
| 256 | + | // Octave and triplet confusions: the classic failure mode of | |
| 257 | + | // autocorrelation tempo estimators. | |
| 258 | + | let a2 = a1 | |
| 259 | + | || [1.0 / 3.0, 0.5, 2.0, 3.0] | |
| 260 | + | .iter() | |
| 261 | + | .any(|f| within(d, truth.bpm * f, 0.04)); | |
| 262 | + | (a1, a2) | |
| 263 | + | } | |
| 264 | + | None => (false, false), | |
| 265 | + | }; | |
| 266 | + | ||
| 267 | + | let (key_exact, key_related) = match (truth.key, detected_key.and_then(parse_app_key)) { | |
| 268 | + | (Some((tp, tm)), Some((dp, dm))) => { | |
| 269 | + | let exact = tp == dp && tm == dm; | |
| 270 | + | // Related = relative major/minor, or a perfect fifth apart in the | |
| 271 | + | // same mode. These are the confusions a chroma-based estimator makes | |
| 272 | + | // because the pitch content genuinely overlaps. | |
| 273 | + | let relative = match tm { | |
| 274 | + | Mode::Major => dm == Mode::Minor && dp == (tp + 9) % 12, | |
| 275 | + | Mode::Minor => dm == Mode::Major && dp == (tp + 3) % 12, | |
| 276 | + | }; | |
| 277 | + | let fifth = tm == dm && (dp == (tp + 7) % 12 || dp == (tp + 5) % 12); | |
| 278 | + | (Some(exact), Some(exact || relative || fifth)) | |
| 279 | + | } | |
| 280 | + | _ => (None, None), | |
| 281 | + | }; | |
| 282 | + | ||
| 283 | + | Scored { | |
| 284 | + | bpm_acc1: acc1, | |
| 285 | + | bpm_acc2: acc2, | |
| 286 | + | key_exact, | |
| 287 | + | key_related, | |
| 288 | + | had_truth_key: truth.key.is_some(), | |
| 289 | + | had_detected_key: detected_key.is_some(), | |
| 290 | + | detected_key_unparsed: detected_key.is_some_and(|k| parse_app_key(k).is_none()), | |
| 291 | + | } | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | pub(crate) fn run(root: &Path, limit: Option<usize>) { | |
| 295 | + | println!("━━━ BPM + KEY ACCURACY (FSL10K ground truth) ━━━"); | |
| 296 | + | println!(); | |
| 297 | + | ||
| 298 | + | // The zip lays out FSL10K/audio/...; accept either the zip root or the | |
| 299 | + | // directory that directly contains audio/ and annotations/. | |
| 300 | + | let annotations = [ | |
| 301 | + | "annotations", | |
| 302 | + | "FSL10K/annotations", | |
| 303 | + | "../fsl10k-annotations/annotations", | |
| 304 | + | ] | |
| 305 | + | .iter() | |
| 306 | + | .map(|p| root.join(p)) | |
| 307 | + | .find(|p| p.is_dir()); | |
| 308 | + | let audio = ["FSL10K/audio", "audio", "FSL10K/FSL10K/audio"] | |
| 309 | + | .iter() | |
| 310 | + | .map(|p| root.join(p)) | |
| 311 | + | .find(|p| p.is_dir()); | |
| 312 | + | ||
| 313 | + | let (Some(annotations), Some(audio)) = (annotations, audio) else { | |
| 314 | + | eprintln!( | |
| 315 | + | "could not find annotations/ and audio/ under {}", | |
| 316 | + | root.display() | |
| 317 | + | ); | |
| 318 | + | eprintln!("fetch with: ./scripts/corpus.py --datasets fsl10k"); | |
| 319 | + | return; | |
| 320 | + | }; | |
| 321 | + | ||
| 322 | + | let (truth, discarded, disputed) = load_truth(&annotations); | |
| 323 | + | let index = index_audio(&audio); | |
| 324 | + | println!( | |
| 325 | + | " annotations: {} sounds with agreed ground truth", | |
| 326 | + | truth.len() | |
| 327 | + | ); | |
| 328 | + | println!( | |
| 329 | + | " {discarded} annotation(s) flagged discard, {disputed} sound(s) dropped as disputed" | |
| 330 | + | ); | |
| 331 | + | println!(" audio files indexed: {}", index.len()); | |
| 332 | + | ||
| 333 | + | let mut pairs: Vec<(&String, &Truth, &PathBuf)> = truth | |
| 334 | + | .iter() | |
| 335 | + | .filter_map(|(id, t)| index.get(id).map(|p| (id, t, p))) | |
| 336 | + | .collect(); | |
| 337 | + | pairs.sort_by_key(|(id, _, _)| (*id).clone()); | |
| 338 | + | if let Some(lim) = limit { | |
| 339 | + | pairs.truncate(lim); | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | if pairs.is_empty() { | |
| 343 | + | eprintln!("no annotated sound matched an audio file"); | |
| 344 | + | return; | |
| 345 | + | } | |
| 346 | + | println!(" scoring {} loops", pairs.len()); | |
| 347 | + | println!(); | |
| 348 | + | ||
| 349 | + | let results: Vec<Scored> = pairs | |
| 350 | + | .par_iter() | |
| 351 | + | .filter_map(|(_, t, path)| { | |
| 352 | + | let decoded = decode::decode_to_mono(path).ok()?; | |
| 353 | + | // 2.0 matches the analysis pipeline's min_duration gate, so this | |
| 354 | + | // measures what the app actually runs rather than a variant. | |
| 355 | + | let r = bpm::detect_bpm_key(&decoded.samples, decoded.sample_rate, 2.0); | |
| 356 | + | Some(score(r.bpm, r.key.as_deref(), t)) | |
| 357 | + | }) | |
| 358 | + | .collect(); | |
| 359 | + | ||
| 360 | + | let n = results.len() as f64; | |
| 361 | + | let acc1 = results.iter().filter(|r| r.bpm_acc1).count() as f64 / n * 100.0; | |
| 362 | + | let acc2 = results.iter().filter(|r| r.bpm_acc2).count() as f64 / n * 100.0; | |
| 363 | + | ||
| 364 | + | println!(" TEMPO ({} scored)", results.len()); | |
| 365 | + | println!(" Acc1 (within 4%) {acc1:>6.1}%"); | |
| 366 | + | println!(" Acc2 (octave/triplet ok) {acc2:>6.1}%"); | |
| 367 | + | let gap = acc2 - acc1; | |
| 368 | + | println!(" octave-error gap {gap:>6.1}%"); | |
| 369 | + | if gap > 15.0 { | |
| 370 | + | println!(" ^ large gap: most misses are half/double time, not noise"); | |
| 371 | + | } | |
| 372 | + | println!(); | |
| 373 | + | ||
| 374 | + | let with_truth = results.iter().filter(|r| r.had_truth_key).count(); | |
| 375 | + | let with_detected = results.iter().filter(|r| r.had_detected_key).count(); | |
| 376 | + | let unparsed = results.iter().filter(|r| r.detected_key_unparsed).count(); | |
| 377 | + | ||
| 378 | + | let keyed: Vec<&Scored> = results.iter().filter(|r| r.key_exact.is_some()).collect(); | |
| 379 | + | println!(" KEY coverage"); | |
| 380 | + | println!( | |
| 381 | + | " loops with ground-truth key {with_truth:>5} / {}", | |
| 382 | + | results.len() | |
| 383 | + | ); | |
| 384 | + | println!( | |
| 385 | + | " loops with detected key {with_detected:>5} / {}", | |
| 386 | + | results.len() | |
| 387 | + | ); | |
| 388 | + | if unparsed > 0 { | |
| 389 | + | println!(" detected but UNPARSED {unparsed:>5} <- parser/format mismatch"); | |
| 390 | + | } | |
| 391 | + | println!(" scorable (both present) {:>5}", keyed.len()); | |
| 392 | + | ||
| 393 | + | // Precision on keyless material, which the accuracy figures cannot show. | |
| 394 | + | // Most loops in a sample library are drums, and annotators mark those as | |
| 395 | + | // having no key. A detector that emits a key anyway is not inaccurate by | |
| 396 | + | // the measures above -- those only score loops that have a key -- but it | |
| 397 | + | // fills the library with confident wrong labels, and a key filter that | |
| 398 | + | // returns drum loops is a user-visible bug. | |
| 399 | + | let keyless = results.len() - with_truth; | |
| 400 | + | let spurious = results | |
| 401 | + | .iter() | |
| 402 | + | .filter(|r| !r.had_truth_key && r.had_detected_key) | |
| 403 | + | .count(); | |
| 404 | + | if keyless > 0 { | |
| 405 | + | let rate = spurious as f64 / keyless as f64 * 100.0; | |
| 406 | + | println!(" key emitted on keyless loops {spurious:>5} / {keyless} ({rate:.1}%)"); | |
| 407 | + | } | |
| 408 | + | println!(); | |
| 409 | + | ||
| 410 | + | if keyed.is_empty() { | |
| 411 | + | println!(" KEY: nothing scorable. Check the coverage lines above for why."); | |
| 412 | + | } else { | |
| 413 | + | let kn = keyed.len() as f64; | |
| 414 | + | let exact = keyed.iter().filter(|r| r.key_exact == Some(true)).count() as f64 / kn * 100.0; | |
| 415 | + | let related = | |
| 416 | + | keyed.iter().filter(|r| r.key_related == Some(true)).count() as f64 / kn * 100.0; | |
| 417 | + | println!(" KEY ({} scored, of {} total)", keyed.len(), results.len()); | |
| 418 | + | println!(" exact {exact:>6.1}%"); | |
| 419 | + | println!(" exact or relative/fifth {related:>6.1}%"); | |
| 420 | + | // Chance is 1/24 for exact over 12 pitch classes x 2 modes. | |
| 421 | + | println!(" (chance for exact is 4.2%)"); | |
| 422 | + | } | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | #[cfg(test)] | |
| 426 | + | mod tests { | |
| 427 | + | use super::{Mode, parse_app_key, pitch_class}; | |
| 428 | + | ||
| 429 | + | #[test] | |
| 430 | + | fn parses_stratum_key_format() { | |
| 431 | + | // The format detect_bpm_key actually emits. | |
| 432 | + | assert_eq!(parse_app_key("C"), Some((0, Mode::Major))); | |
| 433 | + | assert_eq!(parse_app_key("F#"), Some((6, Mode::Major))); | |
| 434 | + | assert_eq!(parse_app_key("Am"), Some((9, Mode::Minor))); | |
| 435 | + | assert_eq!(parse_app_key("C#m"), Some((1, Mode::Minor))); | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | #[test] | |
| 439 | + | fn parses_documented_key_format() { | |
| 440 | + | assert_eq!(parse_app_key("A minor"), Some((9, Mode::Minor))); | |
| 441 | + | assert_eq!(parse_app_key("C major"), Some((0, Mode::Major))); | |
| 442 | + | } | |
| 443 | + | ||
| 444 | + | #[test] | |
| 445 | + | fn enharmonics_are_the_same_pitch_class() { | |
| 446 | + | assert_eq!(pitch_class("a#"), pitch_class("bb")); | |
| 447 | + | assert_eq!(pitch_class("c#"), pitch_class("db")); | |
| 448 | + | } | |
| 449 | + | } |
| @@ -1,0 +1,387 @@ | |||
| 1 | + | //! Ingest and query benchmarks: the vault layer rather than the DSP layer. | |
| 2 | + | //! | |
| 3 | + | //! The analysis bench in `main.rs` measures per-file DSP cost. This measures | |
| 4 | + | //! what happens to a vault as it fills up: import throughput, dedup, and the | |
| 5 | + | //! query latency that backs the browser UI. | |
| 6 | + | //! | |
| 7 | + | //! Two properties of the store make scale worth measuring rather than | |
| 8 | + | //! assuming. Blobs live in one flat directory (`store_blob_path` is | |
| 9 | + | //! `root.join("{hash}.{ext}")`, no fanout), so a 100k-sample vault is 100k | |
| 10 | + | //! entries in a single directory, and directory-lookup cost is filesystem | |
| 11 | + | //! dependent. And the DB runs in WAL mode with several worker connections, so | |
| 12 | + | //! insert cost moves with index depth. | |
| 13 | + | //! | |
| 14 | + | //! Reported per batch rather than as one average, because the number that | |
| 15 | + | //! matters is whether throughput is flat or degrading as the vault grows. | |
| 16 | + | ||
| 17 | + | use std::path::{Path, PathBuf}; | |
| 18 | + | use std::time::Instant; | |
| 19 | + | ||
| 20 | + | use audiofiles_core::db::Database; | |
| 21 | + | use audiofiles_core::id_types::SampleHash; | |
| 22 | + | use audiofiles_core::search::{self, SearchFilter, SearchScope}; | |
| 23 | + | use audiofiles_core::store::SampleStore; | |
| 24 | + | use audiofiles_core::vfs; | |
| 25 | + | ||
| 26 | + | /// Deterministic reorder of the file list. | |
| 27 | + | /// | |
| 28 | + | /// Without this the list arrives sorted by path, which groups files by class, | |
| 29 | + | /// and classes have very different mean file sizes. Batch N would then differ | |
| 30 | + | /// from batch 1 in content as well as in vault size, so a files/s trend across | |
| 31 | + | /// batches would measure file-size composition rather than scaling behaviour. | |
| 32 | + | /// FNV-1a over the path keeps it deterministic without pulling in `rand`. | |
| 33 | + | fn shuffle_deterministic(files: &mut [PathBuf]) { | |
| 34 | + | fn fnv1a(s: &str) -> u64 { | |
| 35 | + | let mut h: u64 = 0xcbf2_9ce4_8422_2325; | |
| 36 | + | for b in s.as_bytes() { | |
| 37 | + | h ^= u64::from(*b); | |
| 38 | + | h = h.wrapping_mul(0x100_0000_01b3); | |
| 39 | + | } | |
| 40 | + | h | |
| 41 | + | } | |
| 42 | + | files.sort_by_key(|p| fnv1a(&p.to_string_lossy())); | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | /// One batch of imports. | |
| 46 | + | struct BatchStat { | |
| 47 | + | /// Cumulative sample count in the vault after this batch. | |
| 48 | + | cumulative: usize, | |
| 49 | + | files: usize, | |
| 50 | + | bytes: u64, | |
| 51 | + | elapsed_s: f64, | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | impl BatchStat { | |
| 55 | + | fn files_per_sec(&self) -> f64 { | |
| 56 | + | if self.elapsed_s <= 0.0 { | |
| 57 | + | return 0.0; | |
| 58 | + | } | |
| 59 | + | self.files as f64 / self.elapsed_s | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | fn mb_per_sec(&self) -> f64 { | |
| 63 | + | if self.elapsed_s <= 0.0 { | |
| 64 | + | return 0.0; | |
| 65 | + | } | |
| 66 | + | (self.bytes as f64 / 1e6) / self.elapsed_s | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | /// Collect audio files under `dir`, recursively. | |
| 71 | + | fn collect(dir: &Path, out: &mut Vec<PathBuf>) { | |
| 72 | + | let Ok(entries) = std::fs::read_dir(dir) else { | |
| 73 | + | return; | |
| 74 | + | }; | |
| 75 | + | for entry in entries.flatten() { | |
| 76 | + | let path = entry.path(); | |
| 77 | + | if path.is_dir() { | |
| 78 | + | collect(&path, out); | |
| 79 | + | } else if path.extension().and_then(|e| e.to_str()).is_some_and(|e| { | |
| 80 | + | matches!( | |
| 81 | + | e.to_lowercase().as_str(), | |
| 82 | + | "wav" | "aif" | "aiff" | "flac" | "mp3" | "ogg" | |
| 83 | + | ) | |
| 84 | + | }) { | |
| 85 | + | out.push(path); | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | fn count_samples(db: &Database) -> i64 { | |
| 91 | + | db.conn() | |
| 92 | + | .query_row("SELECT count(*) FROM samples", [], |r| r.get(0)) | |
| 93 | + | .unwrap_or(-1) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | /// Time a query, returning milliseconds. Runs it `reps` times and takes the | |
| 97 | + | /// median, since a single cold query mostly measures page-cache state. | |
| 98 | + | fn time_query(reps: usize, mut f: impl FnMut()) -> f64 { | |
| 99 | + | let mut times: Vec<f64> = Vec::with_capacity(reps); | |
| 100 | + | for _ in 0..reps { | |
| 101 | + | let t = Instant::now(); | |
| 102 | + | f(); | |
| 103 | + | times.push(t.elapsed().as_secs_f64() * 1000.0); | |
| 104 | + | } | |
| 105 | + | times.sort_by(f64::total_cmp); | |
| 106 | + | times[times.len() / 2] | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | /// Measure the query paths the browser list and filter panel depend on. | |
| 110 | + | fn report_query_latency(db: &Database) { | |
| 111 | + | let n = count_samples(db); | |
| 112 | + | let nodes: i64 = db | |
| 113 | + | .conn() | |
| 114 | + | .query_row("SELECT count(*) FROM vfs_nodes", [], |r| r.get(0)) | |
| 115 | + | .unwrap_or(-1); | |
| 116 | + | // Printed together on purpose: a large sample count with zero nodes means | |
| 117 | + | // the timings below are measuring empty result sets. | |
| 118 | + | println!(" {n} samples / {nodes} vfs nodes (median of 5):"); | |
| 119 | + | let rows = search::search_global( | |
| 120 | + | db, | |
| 121 | + | &SearchFilter { | |
| 122 | + | scope: SearchScope::Global, | |
| 123 | + | ..Default::default() | |
| 124 | + | }, | |
| 125 | + | ) | |
| 126 | + | .map_or(0, |r| r.len()); | |
| 127 | + | // search.rs caps every result set at SEARCH_RESULT_LIMIT (500), so list | |
| 128 | + | // latency is bounded by design no matter how large the vault gets. The | |
| 129 | + | // scan and sort underneath it are not bounded, which is what these numbers | |
| 130 | + | // actually track. | |
| 131 | + | println!(" unfiltered search returns {rows} rows (capped at 500 by SEARCH_RESULT_LIMIT)"); | |
| 132 | + | ||
| 133 | + | let analyzed: i64 = db | |
| 134 | + | .conn() | |
| 135 | + | .query_row("SELECT count(*) FROM audio_analysis", [], |r| r.get(0)) | |
| 136 | + | .unwrap_or(0); | |
| 137 | + | if analyzed == 0 { | |
| 138 | + | println!(" NOTE: audio_analysis is empty, so the class and bpm filters below"); | |
| 139 | + | println!(" match nothing and their timings are not meaningful. Run"); | |
| 140 | + | println!(" the analysis pipeline over this vault to benchmark them."); | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | let ms = time_query(5, || { | |
| 144 | + | let _ = count_samples(db); | |
| 145 | + | }); | |
| 146 | + | println!(" count(*) {ms:>8.2} ms"); | |
| 147 | + | ||
| 148 | + | let mut filter = SearchFilter { | |
| 149 | + | scope: SearchScope::Global, | |
| 150 | + | ..Default::default() | |
| 151 | + | }; | |
| 152 | + | let ms = time_query(5, || { | |
| 153 | + | let _ = search::search_global(db, &filter); | |
| 154 | + | }); | |
| 155 | + | println!(" search_global (no filter) {ms:>6.2} ms <- worst-case list load"); | |
| 156 | + | ||
| 157 | + | filter.text_query = "kick".to_string(); | |
| 158 | + | let ms = time_query(5, || { | |
| 159 | + | let _ = search::search_global(db, &filter); | |
| 160 | + | }); | |
| 161 | + | println!(" search_global (text) {ms:>6.2} ms <- search box keystroke"); | |
| 162 | + | ||
| 163 | + | filter.text_query.clear(); | |
| 164 | + | filter.classifications = vec!["kick".to_string()]; | |
| 165 | + | let ms = time_query(5, || { | |
| 166 | + | let _ = search::search_global(db, &filter); | |
| 167 | + | }); | |
| 168 | + | println!(" search_global (class) {ms:>6.2} ms <- filter panel"); | |
| 169 | + | ||
| 170 | + | filter.classifications.clear(); | |
| 171 | + | filter.bpm_min = Some(120.0); | |
| 172 | + | filter.bpm_max = Some(130.0); | |
| 173 | + | let ms = time_query(5, || { | |
| 174 | + | let _ = search::search_global(db, &filter); | |
| 175 | + | }); | |
| 176 | + | println!(" search_global (bpm range) {ms:>6.2} ms"); | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | /// Run the ingest benchmark against `corpus`, building a scratch vault at | |
| 180 | + | /// `vault`. Any existing scratch vault is removed first so runs are comparable. | |
| 181 | + | pub(crate) fn run(corpus: &Path, vault: &Path, batch: usize, limit: Option<usize>) { | |
| 182 | + | println!("━━━ INGEST AT SCALE ━━━"); | |
| 183 | + | println!(); | |
| 184 | + | println!(" corpus: {}", corpus.display()); | |
| 185 | + | println!(" vault: {}", vault.display()); | |
| 186 | + | ||
| 187 | + | let mut files = Vec::new(); | |
| 188 | + | collect(corpus, &mut files); | |
| 189 | + | files.sort(); | |
| 190 | + | shuffle_deterministic(&mut files); | |
| 191 | + | if let Some(lim) = limit { | |
| 192 | + | files.truncate(lim); | |
| 193 | + | } | |
| 194 | + | if files.is_empty() { | |
| 195 | + | eprintln!("no audio files under {}", corpus.display()); | |
| 196 | + | return; | |
| 197 | + | } | |
| 198 | + | println!(" files: {}", files.len()); | |
| 199 | + | println!(); | |
| 200 | + | ||
| 201 | + | if vault.exists() | |
| 202 | + | && let Err(e) = std::fs::remove_dir_all(vault) | |
| 203 | + | { | |
| 204 | + | eprintln!("could not clear scratch vault: {e}"); | |
| 205 | + | return; | |
| 206 | + | } | |
| 207 | + | let samples_dir = vault.join("samples"); | |
| 208 | + | if let Err(e) = std::fs::create_dir_all(&samples_dir) { | |
| 209 | + | eprintln!("could not create scratch vault: {e}"); | |
| 210 | + | return; | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | let db = match Database::open(vault.join("audiofiles.db")) { | |
| 214 | + | Ok(db) => db, | |
| 215 | + | Err(e) => { | |
| 216 | + | // Worth surfacing loudly: on a filesystem that cannot support WAL | |
| 217 | + | // this is exactly where a vault fails, and the app surfaces it as a | |
| 218 | + | // generic init error. | |
| 219 | + | eprintln!("Database::open failed (WAL unsupported on this fs?): {e}"); | |
| 220 | + | return; | |
| 221 | + | } | |
| 222 | + | }; | |
| 223 | + | let store = match SampleStore::new(&samples_dir) { | |
| 224 | + | Ok(s) => s, | |
| 225 | + | Err(e) => { | |
| 226 | + | eprintln!("SampleStore::new failed: {e}"); | |
| 227 | + | return; | |
| 228 | + | } | |
| 229 | + | }; | |
| 230 | + | ||
| 231 | + | // `store.import` writes the blob and the `samples` row but no VFS node. | |
| 232 | + | // The browser's import workflow creates those separately, and every query | |
| 233 | + | // the UI runs goes through `vfs_nodes`. Without them `search_global` | |
| 234 | + | // returns an empty set instantly and the query numbers below would be | |
| 235 | + | // measuring nothing. | |
| 236 | + | let vfs_id = match vfs::create_vfs(&db, "bench") { | |
| 237 | + | Ok(id) => id, | |
| 238 | + | Err(e) => { | |
| 239 | + | eprintln!("could not create bench vfs: {e}"); | |
| 240 | + | return; | |
| 241 | + | } | |
| 242 | + | }; | |
| 243 | + | ||
| 244 | + | println!(" batch cumulative files/s MB/s elapsed"); | |
| 245 | + | println!(" ---------------------------------------------------------"); | |
| 246 | + | ||
| 247 | + | let mut stats: Vec<BatchStat> = Vec::new(); | |
| 248 | + | let mut cumulative = 0usize; | |
| 249 | + | let mut failures = 0usize; | |
| 250 | + | let mut link_failures = 0usize; | |
| 251 | + | ||
| 252 | + | for chunk in files.chunks(batch) { | |
| 253 | + | let mut bytes = 0u64; | |
| 254 | + | let start = Instant::now(); | |
| 255 | + | for path in chunk { | |
| 256 | + | match store.import(path, &db) { | |
| 257 | + | Ok(hash) => { | |
| 258 | + | // Name links by index: sample names must be unique among | |
| 259 | + | // siblings, and the corpus has repeated basenames across | |
| 260 | + | // packs. | |
| 261 | + | let name = format!( | |
| 262 | + | "{cumulative:06}_{}", | |
| 263 | + | path.file_name().unwrap_or_default().to_string_lossy() | |
| 264 | + | ); | |
| 265 | + | if vfs::create_sample_link( | |
| 266 | + | &db, | |
| 267 | + | vfs_id, | |
| 268 | + | None, | |
| 269 | + | &name, | |
| 270 | + | &SampleHash::from_trusted(hash), | |
| 271 | + | ) | |
| 272 | + | .is_err() | |
| 273 | + | { | |
| 274 | + | link_failures += 1; | |
| 275 | + | } | |
| 276 | + | bytes += std::fs::metadata(path).map_or(0, |m| m.len()); | |
| 277 | + | cumulative += 1; | |
| 278 | + | } | |
| 279 | + | Err(_) => failures += 1, | |
| 280 | + | } | |
| 281 | + | } | |
| 282 | + | let stat = BatchStat { | |
| 283 | + | cumulative, | |
| 284 | + | files: chunk.len(), | |
| 285 | + | bytes, | |
| 286 | + | elapsed_s: start.elapsed().as_secs_f64(), | |
| 287 | + | }; | |
| 288 | + | println!( | |
| 289 | + | " {:>5} {:>10} {:>10.1} {:>10.1} {:>7.2}s", | |
| 290 | + | stats.len() + 1, | |
| 291 | + | stat.cumulative, | |
| 292 | + | stat.files_per_sec(), | |
| 293 | + | stat.mb_per_sec(), | |
| 294 | + | stat.elapsed_s, | |
| 295 | + | ); | |
| 296 | + | stats.push(stat); | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | println!(); | |
| 300 | + | if failures > 0 { | |
| 301 | + | println!(" {failures} file(s) failed to import"); | |
| 302 | + | } | |
| 303 | + | if link_failures > 0 { | |
| 304 | + | println!(" {link_failures} vfs link(s) failed -- query numbers below undercount"); | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | // Degradation is the actual question. Comparing first batch to last is the | |
| 308 | + | // cheapest signal that the flat blob directory or an index has started to | |
| 309 | + | // bite; a flat profile means it has not. | |
| 310 | + | if stats.len() >= 2 { | |
| 311 | + | let first = stats[0].files_per_sec(); | |
| 312 | + | let last = stats[stats.len() - 1].files_per_sec(); | |
| 313 | + | let delta = if first > 0.0 { | |
| 314 | + | (last - first) / first * 100.0 | |
| 315 | + | } else { | |
| 316 | + | 0.0 | |
| 317 | + | }; | |
| 318 | + | println!(" first batch: {first:.1} files/s"); | |
| 319 | + | println!(" last batch: {last:.1} files/s ({delta:+.1}%)"); | |
| 320 | + | if delta < -25.0 { | |
| 321 | + | println!(" ^ throughput degraded as the vault grew"); | |
| 322 | + | } | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | let total_files: usize = stats.iter().map(|s| s.files).sum(); | |
| 326 | + | let total_bytes: u64 = stats.iter().map(|s| s.bytes).sum(); | |
| 327 | + | let total_s: f64 = stats.iter().map(|s| s.elapsed_s).sum(); | |
| 328 | + | println!(); | |
| 329 | + | println!( | |
| 330 | + | " total: {total_files} files, {:.2} GB in {total_s:.1}s ({:.1} files/s, {:.1} MB/s)", | |
| 331 | + | total_bytes as f64 / 1e9, | |
| 332 | + | total_files as f64 / total_s, | |
| 333 | + | (total_bytes as f64 / 1e6) / total_s, | |
| 334 | + | ); | |
| 335 | + | ||
| 336 | + | // Dedup: re-importing the same files must hit the content-addressed store | |
| 337 | + | // and skip the copy. If this is not dramatically faster, dedup is not | |
| 338 | + | // working and every duplicate costs a full hash-and-copy. | |
| 339 | + | println!(); | |
| 340 | + | println!("━━━ DEDUP (re-import of identical content) ━━━"); | |
| 341 | + | println!(); | |
| 342 | + | let blobs_before = std::fs::read_dir(&samples_dir).map_or(0, std::iter::Iterator::count); | |
| 343 | + | let rows_before = count_samples(&db); | |
| 344 | + | ||
| 345 | + | let redo: Vec<&PathBuf> = files.iter().take(batch.min(files.len())).collect(); | |
| 346 | + | let start = Instant::now(); | |
| 347 | + | for path in &redo { | |
| 348 | + | let _ = store.import(path, &db); | |
| 349 | + | } | |
| 350 | + | let redo_s = start.elapsed().as_secs_f64(); | |
| 351 | + | ||
| 352 | + | let blobs_after = std::fs::read_dir(&samples_dir).map_or(0, std::iter::Iterator::count); | |
| 353 | + | let rows_after = count_samples(&db); | |
| 354 | + | ||
| 355 | + | println!( | |
| 356 | + | " re-imported {} files in {redo_s:.2}s ({:.1} files/s)", | |
| 357 | + | redo.len(), | |
| 358 | + | redo.len() as f64 / redo_s.max(1e-9) | |
| 359 | + | ); | |
| 360 | + | println!(" blobs on disk: {blobs_before} -> {blobs_after} (want: unchanged)"); | |
| 361 | + | println!(" sample rows: {rows_before} -> {rows_after}"); | |
| 362 | + | ||
| 363 | + | // Distinguishing these two matters: equal blob counts with equal row counts | |
| 364 | + | // means full dedup; equal blobs with more rows means the blob was reused but | |
| 365 | + | // a duplicate row was still written. | |
| 366 | + | if blobs_after == blobs_before { | |
| 367 | + | println!(" blob dedup: OK (no new blobs written)"); | |
| 368 | + | } else { | |
| 369 | + | println!( | |
| 370 | + | " blob dedup: {} new blob(s) written", | |
| 371 | + | blobs_after - blobs_before | |
| 372 | + | ); | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | println!(); | |
| 376 | + | println!("━━━ QUERY LATENCY (backs the browser UI) ━━━"); | |
| 377 | + | println!(); | |
| 378 | + | report_query_latency(&db); | |
| 379 | + | ||
| 380 | + | // Left in place deliberately: the vault is the artifact to point the app at | |
| 381 | + | // for eyeballing UI responsiveness at this size. | |
| 382 | + | println!(); | |
| 383 | + | println!( | |
| 384 | + | " scratch vault left at {} for UI inspection", | |
| 385 | + | vault.display() | |
| 386 | + | ); | |
| 387 | + | } |
| @@ -1,0 +1,305 @@ | |||
| 1 | + | #!/usr/bin/env python3 | |
| 2 | + | """Fetch and lay out the benchmark sample corpus. | |
| 3 | + | ||
| 4 | + | audiofiles-bench scores classification against folder labels, so it needs a | |
| 5 | + | corpus laid out as samples/training/<class>/ and samples/test-suite/<kind>/. | |
| 6 | + | That tree is gitignored and has to be rebuilt per machine. This script fetches | |
| 7 | + | the source datasets and maps them into that layout. | |
| 8 | + | ||
| 9 | + | Only CC-BY datasets are enabled by default. The large ones are opt-in because | |
| 10 | + | they are tens of gigabytes and, for NSynth, 16 kHz mono, which skews per-file | |
| 11 | + | decode timings away from what a real 44.1/48 kHz library costs. | |
| 12 | + | ||
| 13 | + | ./scripts/corpus.py --list | |
| 14 | + | ./scripts/corpus.py --dest /media/max/T9/af-corpus | |
| 15 | + | ./scripts/corpus.py --dest /media/max/T9/af-corpus --datasets nsynth | |
| 16 | + | ||
| 17 | + | Datasets land in <dest>/_downloads (archives), <dest>/_raw (extracted), and | |
| 18 | + | <dest>/samples (the layout the bench reads). Point the bench at it with | |
| 19 | + | AF_BENCH_CORPUS=<dest>/samples. | |
| 20 | + | """ | |
| 21 | + | ||
| 22 | + | import argparse | |
| 23 | + | import json | |
| 24 | + | import shutil | |
| 25 | + | import subprocess | |
| 26 | + | import sys | |
| 27 | + | import urllib.request | |
| 28 | + | from pathlib import Path | |
| 29 | + | ||
| 30 | + | # Dataset registry. | |
| 31 | + | # | |
| 32 | + | # `default` marks the ones that run without --datasets. The two defaults are | |
| 33 | + | # both CC-BY 4.0, which matters here: docs/ml_classifier.md keeps a trained | |
| 34 | + | # model out of the binary specifically to avoid training-data copyright | |
| 35 | + | # surface, so anything that could feed a shipped model needs a clean license. | |
| 36 | + | DATASETS = { | |
| 37 | + | "reverb-drums": { | |
| 38 | + | "default": True, | |
| 39 | + | "size": "570 MB", | |
| 40 | + | "count": "1,786 files", | |
| 41 | + | "license": "CC-BY 4.0", | |
| 42 | + | "url": "https://archive.org/download/reverb-drum-machines-complete-collection/" | |
| 43 | + | "Reverb%20Drum%20Machines%20_%20The%20Complete%20Collection.7z", | |
| 44 | + | "archive": "reverb-drums.7z", | |
| 45 | + | "desc": "Reverb drum machine packs. Instrument in the filename; the " | |
| 46 | + | "only default source of labeled kick/snare/hihat one-shots.", | |
| 47 | + | }, | |
| 48 | + | "fsl10k": { | |
| 49 | + | "default": True, | |
| 50 | + | "size": "8.8 GB", | |
| 51 | + | "count": "9,455 loops", | |
| 52 | + | "license": "CC-BY 4.0", | |
| 53 | + | "url": "https://zenodo.org/api/records/3967852/files/FSL10K.zip/content", | |
| 54 | + | "archive": "FSL10K.zip", | |
| 55 | + | "extra": { | |
| 56 | + | "annotations.zip": "https://zenodo.org/api/records/3967852/files/annotations.zip/content" | |
| 57 | + | }, | |
| 58 | + | "desc": "Freesound Loop Dataset. The only source here with ground-truth " | |
| 59 | + | "tempo and key, so it is what BPM/key accuracy can be scored against.", | |
| 60 | + | }, | |
| 61 | + | "nsynth": { | |
| 62 | + | "default": False, | |
| 63 | + | "size": "~30 GB", | |
| 64 | + | "count": "305,979 notes", | |
| 65 | + | "license": "CC-BY 4.0", | |
| 66 | + | "url": "http://download.magenta.tensorflow.org/datasets/nsynth/nsynth-train.jsonwav.tar.gz", | |
| 67 | + | "archive": "nsynth-train.jsonwav.tar.gz", | |
| 68 | + | "desc": "NSynth. Use for count-scale (300k rows, 300k blobs in one flat " | |
| 69 | + | "dir). 16 kHz mono, so do not mix its timings into a throughput number.", | |
| 70 | + | }, | |
| 71 | + | "fsd50k": { | |
| 72 | + | "default": False, | |
| 73 | + | "size": "~30 GB", | |
| 74 | + | "count": "51,197 clips", | |
| 75 | + | "license": "CC-BY (per-clip varies, includes CC-BY-NC)", | |
| 76 | + | "url": "https://zenodo.org/api/records/4060432/files/FSD50K.dev_audio.zip/content", | |
| 77 | + | "archive": "FSD50K.dev_audio.zip", | |
| 78 | + | "desc": "FSD50K, 200 AudioSet classes. Contains CC-BY-NC clips: fine for " | |
| 79 | + | "measurement, not for anything that ships.", | |
| 80 | + | }, | |
| 81 | + | "percussive": { | |
| 82 | + | "default": False, | |
| 83 | + | "size": "119 MB", | |
| 84 | + | "count": "10,254 sounds", | |
| 85 | + | "license": "Attribution", | |
| 86 | + | "url": "https://zenodo.org/api/records/3665275/files/one_shot_percussive_sounds.zip/content", | |
| 87 | + | "archive": "one_shot_percussive_sounds.zip", | |
| 88 | + | "desc": "Freesound one-shot percussion. No class labels and 16 kHz " | |
| 89 | + | "normalized to 1 s, so it is bulk filler, not accuracy material.", | |
| 90 | + | }, | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | # Filename/dirname keyword -> bench training class. | |
| 94 | + | # | |
| 95 | + | # Ordered, first match wins. Order is load-bearing: "loop" has to be tested | |
| 96 | + | # before any instrument keyword because loop files in these packs are named | |
| 97 | + | # "<machine> Loop3.wav" with no instrument in the name at all, and several | |
| 98 | + | # instrument keywords are substrings of each other. | |
| 99 | + | CLASS_RULES = [ | |
| 100 | + | (["kick", "bassdrum", "bass drum", " bd", "_bd", "kik"], "kick"), | |
| 101 | + | (["snare", " sd", "_sd", "rimshot", "rim shot", " rim", "_rim", | |
| 102 | + | "sidestick", "side stick", "stick"], "snare"), | |
| 103 | + | (["hihat", "hi hat", "hi-hat", "chh", "ohh", " hh", "_hh", "hat"], "hihat"), | |
| 104 | + | (["cymbal", "crash", "ride", "splash", "china", "gong"], "cymbal"), | |
| 105 | + | (["clap", "handclap", " cp", "_cp"], "clap"), | |
| 106 | + | (["tom"], "tom"), | |
| 107 | + | ( | |
| 108 | + | [ | |
| 109 | + | "perc", "cowbell", "clave", "clava", "maraca", "tambor", "tambour", | |
| 110 | + | "bongo", "conga", "guiro", "shaker", "shake", "block", "triangle", | |
| 111 | + | "agogo", "cabasa", "timbale", "whistle", "bell", "scratch", | |
| 112 | + | "chime", "click", "beep", "quijada", "steel drum", | |
| 113 | + | ], | |
| 114 | + | "percussion", | |
| 115 | + | ), | |
| 116 | + | ] | |
| 117 | + | ||
| 118 | + | TRAINING_CLASSES = ["kick", "snare", "hihat", "cymbal", "clap", "tom", "percussion"] | |
| 119 | + | ||
| 120 | + | ||
| 121 | + | def classify_name(path: Path) -> tuple[str | None, str]: | |
| 122 | + | """Map a source file to a training class from its name and parent dirs. | |
| 123 | + | ||
| 124 | + | Returns (class, status) where status is one of "ok", "loop", "ambiguous", | |
| 125 | + | "unlabeled". | |
| 126 | + | ||
| 127 | + | Matches against the filename plus its two parent directories, since these | |
| 128 | + | packs sometimes put the instrument in a subdirectory ("..._Tom/") and | |
| 129 | + | sometimes only in the filename ("... Tom3.wav"). | |
| 130 | + | ||
| 131 | + | Files matching more than one class are dropped rather than resolved by rule | |
| 132 | + | order. These packs contain genuinely ambiguous names -- "Kick_Cowbell.wav", | |
| 133 | + | "Tom-Cymbal.wav" -- and first-match-wins would silently assign one of the | |
| 134 | + | two at random. A wrong label is worse than a missing one here, because the | |
| 135 | + | bench reports accuracy against these folders and a mislabeled file looks | |
| 136 | + | like a classifier error forever. | |
| 137 | + | """ | |
| 138 | + | hay = " ".join([path.name, path.parent.name, path.parent.parent.name]).lower() | |
| 139 | + | ||
| 140 | + | # Loops carry no instrument in the name at all in these packs, so they are | |
| 141 | + | # checked first and routed away from the training set entirely. | |
| 142 | + | if "loop" in hay: | |
| 143 | + | return None, "loop" | |
| 144 | + | ||
| 145 | + | matched = {cls for keywords, cls in CLASS_RULES if any(k in hay for k in keywords)} | |
| 146 | + | if len(matched) > 1: | |
| 147 | + | return None, "ambiguous" | |
| 148 | + | if len(matched) == 1: | |
| 149 | + | return matched.pop(), "ok" | |
| 150 | + | return None, "unlabeled" | |
| 151 | + | ||
| 152 | + | ||
| 153 | + | def run(cmd: list[str]) -> None: | |
| 154 | + | subprocess.run(cmd, check=True) | |
| 155 | + | ||
| 156 | + | ||
| 157 | + | def download(url: str, dest: Path) -> None: | |
| 158 | + | if dest.exists() and dest.stat().st_size > 0: | |
| 159 | + | print(f" have {dest.name} ({dest.stat().st_size / 1e9:.2f} GB), skipping") | |
| 160 | + | return | |
| 161 | + | print(f" downloading {dest.name} ...") | |
| 162 | + | tmp = dest.with_suffix(dest.suffix + ".part") | |
| 163 | + | urllib.request.urlretrieve(url, tmp) | |
| 164 | + | tmp.rename(dest) | |
| 165 | + | print(f" got {dest.name} ({dest.stat().st_size / 1e9:.2f} GB)") | |
| 166 | + | ||
| 167 | + | ||
| 168 | + | def extract(archive: Path, into: Path, marker: Path | None = None) -> None: | |
| 169 | + | """Extract `archive` into `into`. | |
| 170 | + | ||
| 171 | + | `marker` is the path whose existence means this archive is already | |
| 172 | + | unpacked. It defaults to "`into` is non-empty", which is right for the first | |
| 173 | + | archive into a directory but wrong for a second one (annotations landing | |
| 174 | + | next to audio), where the directory is already full. | |
| 175 | + | """ | |
| 176 | + | done = marker.exists() if marker is not None else (into.exists() and any(into.iterdir())) | |
| 177 | + | if done: | |
| 178 | + | print(f" already extracted {archive.name}, skipping") | |
| 179 | + | return | |
| 180 | + | into.mkdir(parents=True, exist_ok=True) | |
| 181 | + | print(f" extracting {archive.name} ...") | |
| 182 | + | name = archive.name.lower() | |
| 183 | + | if name.endswith(".7z"): | |
| 184 | + | run(["7z", "x", "-y", f"-o{into}", str(archive)]) | |
| 185 | + | elif name.endswith(".zip"): | |
| 186 | + | run(["unzip", "-q", "-o", str(archive), "-d", str(into)]) | |
| 187 | + | elif name.endswith((".tar.gz", ".tgz")): | |
| 188 | + | run(["tar", "xzf", str(archive), "-C", str(into)]) | |
| 189 | + | else: | |
| 190 | + | sys.exit(f"unknown archive type: {archive.name}") | |
| 191 | + | ||
| 192 | + | ||
| 193 | + | def build_training(raw: Path, samples: Path) -> dict[str, int]: | |
| 194 | + | """Copy labeled one-shots into samples/training/<class>/. | |
| 195 | + | ||
| 196 | + | Copies rather than symlinks: the corpus is expected to live on the exFAT | |
| 197 | + | test drive, which has no symlink support at all. | |
| 198 | + | """ | |
| 199 | + | counts: dict[str, int] = {c: 0 for c in TRAINING_CLASSES} | |
| 200 | + | counts["_loops"] = 0 | |
| 201 | + | counts["_ambiguous"] = 0 | |
| 202 | + | counts["_unlabeled"] = 0 | |
| 203 | + | ||
| 204 | + | training = samples / "training" | |
| 205 | + | loops = samples / "test-suite" / "genres" / "loops" | |
| 206 | + | for c in TRAINING_CLASSES: | |
| 207 | + | (training / c).mkdir(parents=True, exist_ok=True) | |
| 208 | + | loops.mkdir(parents=True, exist_ok=True) | |
| 209 | + | ||
| 210 | + | dropped: list[str] = [] | |
| 211 | + | ||
| 212 | + | for src in sorted(raw.rglob("*")): | |
| 213 | + | if not src.is_file() or src.suffix.lower() not in (".wav", ".aif", ".aiff", ".flac"): | |
| 214 | + | continue | |
| 215 | + | cls, status = classify_name(src) | |
| 216 | + | if status == "loop": | |
| 217 | + | dest_dir, key = loops, "_loops" | |
| 218 | + | elif status == "ok": | |
| 219 | + | dest_dir, key = training / cls, cls | |
| 220 | + | else: | |
| 221 | + | counts[f"_{status}"] += 1 | |
| 222 | + | dropped.append(f"{status}: {src.name}") | |
| 223 | + | continue | |
| 224 | + | ||
| 225 | + | # Flatten with a pack-qualified name so same-named files across packs | |
| 226 | + | # do not collide (every pack has a "Kick.wav"). | |
| 227 | + | flat = f"{src.parent.parent.name}__{src.name}".replace("/", "_") | |
| 228 | + | dest = dest_dir / flat | |
| 229 | + | if not dest.exists(): | |
| 230 | + | shutil.copy2(src, dest) | |
| 231 | + | counts[key] += 1 | |
| 232 | + | ||
| 233 | + | # Written out rather than just counted: when accuracy looks off, the first | |
| 234 | + | # question is always whether the corpus or the classifier is wrong. | |
| 235 | + | (samples / "DROPPED.txt").write_text("\n".join(sorted(dropped)) + "\n") | |
| 236 | + | ||
| 237 | + | return counts | |
| 238 | + | ||
| 239 | + | ||
| 240 | + | def main() -> None: | |
| 241 | + | ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) | |
| 242 | + | ap.add_argument("--dest", type=Path, default=Path("/media/max/T9/af-corpus")) | |
| 243 | + | ap.add_argument("--datasets", help="comma-separated; default = the CC-BY defaults") | |
| 244 | + | ap.add_argument("--list", action="store_true", help="show the registry and exit") | |
| 245 | + | ap.add_argument("--no-build", action="store_true", help="fetch and extract only") | |
| 246 | + | args = ap.parse_args() | |
| 247 | + | ||
| 248 | + | if args.list: | |
| 249 | + | print(f"{'dataset':<14} {'size':>8} {'default':<8} {'license':<38} count") | |
| 250 | + | for name, d in DATASETS.items(): | |
| 251 | + | print( | |
| 252 | + | f"{name:<14} {d['size']:>8} {str(d['default']):<8} " | |
| 253 | + | f"{d['license']:<38} {d['count']}" | |
| 254 | + | ) | |
| 255 | + | print(f"{'':>14} {d['desc']}") | |
| 256 | + | return | |
| 257 | + | ||
| 258 | + | if args.datasets: | |
| 259 | + | wanted = [d.strip() for d in args.datasets.split(",")] | |
| 260 | + | unknown = [d for d in wanted if d not in DATASETS] | |
| 261 | + | if unknown: | |
| 262 | + | sys.exit(f"unknown dataset(s): {', '.join(unknown)}") | |
| 263 | + | else: | |
| 264 | + | wanted = [n for n, d in DATASETS.items() if d["default"]] | |
| 265 | + | ||
| 266 | + | downloads = args.dest / "_downloads" | |
| 267 | + | raw = args.dest / "_raw" | |
| 268 | + | samples = args.dest / "samples" | |
| 269 | + | downloads.mkdir(parents=True, exist_ok=True) | |
| 270 | + | ||
| 271 | + | manifest = {"datasets": [], "layout": str(samples)} | |
| 272 | + | ||
| 273 | + | for name in wanted: | |
| 274 | + | d = DATASETS[name] | |
| 275 | + | print(f"\n=== {name} ({d['size']}, {d['license']}) ===") | |
| 276 | + | archive = downloads / d["archive"] | |
| 277 | + | download(d["url"], archive) | |
| 278 | + | extract(archive, raw / name) | |
| 279 | + | # Extras unpack alongside the main archive, not into their own tree: | |
| 280 | + | # the accuracy bench expects annotations/ next to audio/ under one root. | |
| 281 | + | for extra_name, extra_url in d.get("extra", {}).items(): | |
| 282 | + | extra_path = downloads / extra_name | |
| 283 | + | download(extra_url, extra_path) | |
| 284 | + | if extra_path.suffix.lower() in (".zip", ".7z", ".gz"): | |
| 285 | + | extract(extra_path, raw / name, marker=raw / name / Path(extra_name).stem) | |
| 286 | + | manifest["datasets"].append( | |
| 287 | + | {"name": name, "license": d["license"], "source": d["url"]} | |
| 288 | + | ) | |
| 289 | + | ||
| 290 | + | if not args.no_build and "reverb-drums" in wanted: | |
| 291 | + | print("\n=== building training layout ===") | |
| 292 | + | counts = build_training(raw / "reverb-drums", samples) | |
| 293 | + | for k, v in counts.items(): | |
| 294 | + | print(f" {k:<14} {v}") | |
| 295 | + | manifest["training_counts"] = counts | |
| 296 | + | ||
| 297 | + | # Provenance matters here: the corpus mixes licenses, and anything that | |
| 298 | + | # feeds a model needs that recorded rather than reconstructed later. | |
| 299 | + | (args.dest / "MANIFEST.json").write_text(json.dumps(manifest, indent=2)) | |
| 300 | + | print(f"\nwrote {args.dest / 'MANIFEST.json'}") | |
| 301 | + | print(f"point the bench at it: export AF_BENCH_CORPUS={samples}") | |
| 302 | + | ||
| 303 | + | ||
| 304 | + | if __name__ == "__main__": | |
| 305 | + | main() |