|
1 |
+ |
//! Build the bundled official `.afcl` from the labelled corpus.
|
|
2 |
+ |
//!
|
|
3 |
+ |
//! Like `layout`, this is not a measurement. It lives in the bench crate because
|
|
4 |
+ |
//! this is where corpus walking and scratch-vault fabrication already exist, and
|
|
5 |
+ |
//! duplicating both in a second binary to keep the crate's name honest would cost
|
|
6 |
+ |
//! more than the misnomer does.
|
|
7 |
+ |
//!
|
|
8 |
+ |
//! What it does: import every labelled file into a throwaway vault, analyse it so
|
|
9 |
+ |
//! `sample_features` holds a vector, tag it from the folder it came in, then call
|
|
10 |
+ |
//! [`build_export`] with `kind = "official"`. The exemplars in the resulting layer
|
|
11 |
+ |
//! are (vector, tags) pairs and carry no audio and no hashes, which is the whole
|
|
12 |
+ |
//! reason a `.afcl` can ship at all.
|
|
13 |
+ |
//!
|
|
14 |
+ |
//! The output is a build artifact, not a test fixture. Generate it deliberately,
|
|
15 |
+ |
//! check it in, and regenerate it whenever `FEATURE_VERSION` moves: import gates
|
|
16 |
+ |
//! on `feat_version` and a layer built under an old one is rejected outright, so a
|
|
17 |
+ |
//! stale layer is worse than no layer.
|
|
18 |
+ |
|
|
19 |
+ |
use std::collections::BTreeMap;
|
|
20 |
+ |
use std::path::{Path, PathBuf};
|
|
21 |
+ |
use std::time::Instant;
|
|
22 |
+ |
|
|
23 |
+ |
use audiofiles_core::analysis::afcl::{ExportOptions, LayerKind, export_to_path};
|
|
24 |
+ |
use audiofiles_core::analysis::features::FEATURE_VERSION;
|
|
25 |
+ |
use audiofiles_core::analysis::{self, config::AnalysisConfig};
|
|
26 |
+ |
use audiofiles_core::db::Database;
|
|
27 |
+ |
use audiofiles_core::starter_rules::DRUM_CLASSES;
|
|
28 |
+ |
use audiofiles_core::store::SampleStore;
|
|
29 |
+ |
use audiofiles_core::tags;
|
|
30 |
+ |
|
|
31 |
+ |
/// Corpus subdirectory holding the labelled one-shots, one folder per class.
|
|
32 |
+ |
const TRAINING_SUBDIR: &str = "training";
|
|
33 |
+ |
|
|
34 |
+ |
/// Corpus manifest naming the source datasets and their licences.
|
|
35 |
+ |
const MANIFEST: &str = "MANIFEST.json";
|
|
36 |
+ |
|
|
37 |
+ |
/// Map a corpus folder name to its canonical tag.
|
|
38 |
+ |
///
|
|
39 |
+ |
/// The folder names and [`DRUM_CLASSES`] are the same taxonomy, so this resolves
|
|
40 |
+ |
/// against that table rather than carrying a second copy of it. The only mismatch
|
|
41 |
+ |
/// is punctuation: the table labels the class `hi-hat` and the corpus folder is
|
|
42 |
+ |
/// `hihat`, so both sides are compared with `-` and spaces removed.
|
|
43 |
+ |
fn tag_for_folder(folder: &str) -> Option<&'static str> {
|
|
44 |
+ |
fn squash(s: &str) -> String {
|
|
45 |
+ |
s.chars()
|
|
46 |
+ |
.filter(|c| !matches!(c, '-' | ' ' | '_'))
|
|
47 |
+ |
.flat_map(char::to_lowercase)
|
|
48 |
+ |
.collect()
|
|
49 |
+ |
}
|
|
50 |
+ |
let want = squash(folder);
|
|
51 |
+ |
DRUM_CLASSES
|
|
52 |
+ |
.iter()
|
|
53 |
+ |
.find(|c| squash(c.label) == want)
|
|
54 |
+ |
.map(|c| c.tag)
|
|
55 |
+ |
}
|
|
56 |
+ |
|
|
57 |
+ |
/// Read the corpus manifest into an attribution line for `license_note`.
|
|
58 |
+ |
///
|
|
59 |
+ |
/// This is the CC-BY attribution, and it is the only place it travels: an `.afcl`
|
|
60 |
+ |
/// carries no audio, so the manifest is what records whose labels these derive
|
|
61 |
+ |
/// from. A missing or unreadable manifest is fatal rather than defaulted, because
|
|
62 |
+ |
/// a layer that ships without attribution is the one outcome worth failing over.
|
|
63 |
+ |
///
|
|
64 |
+ |
/// Looked up in `corpus_root` and then in its parent, because `AF_BENCH_CORPUS`
|
|
65 |
+ |
/// points at the `samples/` directory while `corpus.py` writes the manifest one
|
|
66 |
+ |
/// level up beside it.
|
|
67 |
+ |
fn manifest_path(corpus_root: &Path) -> Option<PathBuf> {
|
|
68 |
+ |
let here = corpus_root.join(MANIFEST);
|
|
69 |
+ |
if here.is_file() {
|
|
70 |
+ |
return Some(here);
|
|
71 |
+ |
}
|
|
72 |
+ |
let beside = corpus_root.parent()?.join(MANIFEST);
|
|
73 |
+ |
beside.is_file().then_some(beside)
|
|
74 |
+ |
}
|
|
75 |
+ |
|
|
76 |
+ |
fn license_note(corpus_root: &Path) -> Result<String, String> {
|
|
77 |
+ |
let path = manifest_path(corpus_root).ok_or_else(|| {
|
|
78 |
+ |
format!(
|
|
79 |
+ |
"no {MANIFEST} in {} or its parent; corpus.py writes it beside samples/",
|
|
80 |
+ |
corpus_root.display()
|
|
81 |
+ |
)
|
|
82 |
+ |
})?;
|
|
83 |
+ |
let raw = std::fs::read_to_string(&path)
|
|
84 |
+ |
.map_err(|e| format!("cannot read {}: {e}", path.display()))?;
|
|
85 |
+ |
let doc: serde_json::Value = serde_json::from_str(&raw)
|
|
86 |
+ |
.map_err(|e| format!("{} is not valid JSON: {e}", path.display()))?;
|
|
87 |
+ |
|
|
88 |
+ |
let datasets = doc
|
|
89 |
+ |
.get("datasets")
|
|
90 |
+ |
.and_then(|d| d.as_array())
|
|
91 |
+ |
.ok_or_else(|| format!("{} has no `datasets` array", path.display()))?;
|
|
92 |
+ |
if datasets.is_empty() {
|
|
93 |
+ |
return Err(format!("{} lists no datasets", path.display()));
|
|
94 |
+ |
}
|
|
95 |
+ |
|
|
96 |
+ |
// The manifest must actually describe the training data, not merely exist.
|
|
97 |
+ |
//
|
|
98 |
+ |
// `corpus.py` populates `samples/training/` only from `reverb-drums`, and
|
|
99 |
+ |
// records `training_counts` in the same pass. It also OVERWRITES the manifest
|
|
100 |
+ |
// rather than merging it, so a later `--datasets nsynth` run leaves the
|
|
101 |
+ |
// training folders in place while replacing the attribution with a dataset
|
|
102 |
+ |
// the labels did not come from. That is what the corpus on the T9 looks like
|
|
103 |
+ |
// today: 1,049 reverb-drums one-shots under `training/`, and a manifest
|
|
104 |
+ |
// naming only nsynth.
|
|
105 |
+ |
//
|
|
106 |
+ |
// Both are CC-BY 4.0, so this is not a licence-class error, but CC-BY
|
|
107 |
+ |
// requires crediting the work actually used. Refuse rather than emit a
|
|
108 |
+ |
// confident wrong credit into a file meant to ship.
|
|
109 |
+ |
if doc.get("training_counts").is_none() {
|
|
110 |
+ |
return Err(format!(
|
|
111 |
+ |
"{} has no `training_counts`, so it does not describe the labelled \
|
|
112 |
+ |
training data and its `datasets` list cannot be trusted as the \
|
|
113 |
+ |
attribution. Rebuild the corpus with reverb-drums (which is what \
|
|
114 |
+ |
fills samples/training/) so the manifest and the labels agree.",
|
|
115 |
+ |
path.display()
|
|
116 |
+ |
));
|
|
117 |
+ |
}
|
|
118 |
+ |
|
|
119 |
+ |
let mut parts = Vec::new();
|
|
120 |
+ |
for d in datasets {
|
|
121 |
+ |
let name = d.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
|
122 |
+ |
let license = d.get("license").and_then(|v| v.as_str()).unwrap_or("");
|
|
123 |
+ |
let source = d.get("source").and_then(|v| v.as_str()).unwrap_or("");
|
|
124 |
+ |
if name.is_empty() || license.is_empty() {
|
|
125 |
+ |
return Err(format!(
|
|
126 |
+ |
"{} has a dataset entry missing name or license",
|
|
127 |
+ |
path.display()
|
|
128 |
+ |
));
|
|
129 |
+ |
}
|
|
130 |
+ |
if source.is_empty() {
|
|
131 |
+ |
parts.push(format!("{name} ({license})"));
|
|
132 |
+ |
} else {
|
|
133 |
+ |
parts.push(format!("{name} ({license}), {source}"));
|
|
134 |
+ |
}
|
|
135 |
+ |
}
|
|
136 |
+ |
Ok(format!(
|
|
137 |
+ |
"Labels derived from: {}. This layer carries feature vectors and labels only, no audio.",
|
|
138 |
+ |
parts.join("; ")
|
|
139 |
+ |
))
|
|
140 |
+ |
}
|
|
141 |
+ |
|
|
142 |
+ |
/// Collect the labelled files as (path, tag) pairs, grouped for a stable report.
|
|
143 |
+ |
fn collect_labelled(training: &Path) -> Result<BTreeMap<&'static str, Vec<PathBuf>>, String> {
|
|
144 |
+ |
let entries = std::fs::read_dir(training)
|
|
145 |
+ |
.map_err(|e| format!("cannot read {}: {e}", training.display()))?;
|
|
146 |
+ |
|
|
147 |
+ |
let mut by_tag: BTreeMap<&'static str, Vec<PathBuf>> = BTreeMap::new();
|
|
148 |
+ |
let mut unmapped = Vec::new();
|
|
149 |
+ |
for entry in entries.flatten() {
|
|
150 |
+ |
if !entry.path().is_dir() {
|
|
151 |
+ |
continue;
|
|
152 |
+ |
}
|
|
153 |
+ |
let folder = entry.file_name().to_string_lossy().to_string();
|
|
154 |
+ |
let Some(tag) = tag_for_folder(&folder) else {
|
|
155 |
+ |
unmapped.push(folder);
|
|
156 |
+ |
continue;
|
|
157 |
+ |
};
|
|
158 |
+ |
let mut files: Vec<PathBuf> = std::fs::read_dir(entry.path())
|
|
159 |
+ |
.map_err(|e| format!("cannot read {}: {e}", entry.path().display()))?
|
|
160 |
+ |
.flatten()
|
|
161 |
+ |
.map(|f| f.path())
|
|
162 |
+ |
.filter(|p| p.is_file())
|
|
163 |
+ |
.collect();
|
|
164 |
+ |
// Deterministic order so two runs over the same corpus produce the same
|
|
165 |
+ |
// layer, which is what makes the checked-in artifact reviewable.
|
|
166 |
+ |
files.sort();
|
|
167 |
+ |
by_tag.entry(tag).or_default().extend(files);
|
|
168 |
+ |
}
|
|
169 |
+ |
|
|
170 |
+ |
if !unmapped.is_empty() {
|
|
171 |
+ |
// Loud rather than silent: a folder nobody mapped is a class silently
|
|
172 |
+ |
// missing from the shipped layer, which reads downstream as the
|
|
173 |
+ |
// classifier being bad at that class rather than never having seen it.
|
|
174 |
+ |
unmapped.sort();
|
|
175 |
+ |
return Err(format!(
|
|
176 |
+ |
"no tag mapping for corpus folder(s): {}. Add them to DRUM_CLASSES or move them out of {}",
|
|
177 |
+ |
unmapped.join(", "),
|
|
178 |
+ |
training.display()
|
|
179 |
+ |
));
|
|
180 |
+ |
}
|
|
181 |
+ |
if by_tag.is_empty() {
|
|
182 |
+ |
return Err(format!(
|
|
183 |
+ |
"no labelled class folders under {}",
|
|
184 |
+ |
training.display()
|
|
185 |
+ |
));
|
|
186 |
+ |
}
|
|
187 |
+ |
Ok(by_tag)
|
|
188 |
+ |
}
|
|
189 |
+ |
|
|
190 |
+ |
/// Generate the official layer from `corpus` into `out`, using `vault` as scratch.
|
|
191 |
+ |
pub(crate) fn run(corpus: &Path, vault: &Path, out: &Path, config: &AnalysisConfig) {
|
|
192 |
+ |
println!("━━━ OFFICIAL .afcl GENERATION ━━━");
|
|
193 |
+ |
println!();
|
|
194 |
+ |
println!(" corpus {}", corpus.display());
|
|
195 |
+ |
println!(" scratch {}", vault.display());
|
|
196 |
+ |
println!(" output {}", out.display());
|
|
197 |
+ |
println!(" features v{FEATURE_VERSION}");
|
|
198 |
+ |
println!();
|
|
199 |
+ |
|
|
200 |
+ |
let note = match license_note(corpus) {
|
|
201 |
+ |
Ok(n) => n,
|
|
202 |
+ |
Err(e) => {
|
|
203 |
+ |
eprintln!("attribution: {e}");
|
|
204 |
+ |
std::process::exit(1);
|
|
205 |
+ |
}
|
|
206 |
+ |
};
|
|
207 |
+ |
|
|
208 |
+ |
let by_tag = match collect_labelled(&corpus.join(TRAINING_SUBDIR)) {
|
|
209 |
+ |
Ok(m) => m,
|
|
210 |
+ |
Err(e) => {
|
|
211 |
+ |
eprintln!("corpus: {e}");
|
|
212 |
+ |
std::process::exit(1);
|
|
213 |
+ |
}
|
|
214 |
+ |
};
|
|
215 |
+ |
|
|
216 |
+ |
let total: usize = by_tag.values().map(Vec::len).sum();
|
|
217 |
+ |
println!(
|
|
218 |
+ |
" {} labelled file(s) across {} class(es):",
|
|
219 |
+ |
total,
|
|
220 |
+ |
by_tag.len()
|
|
221 |
+ |
);
|
|
222 |
+ |
for (tag, files) in &by_tag {
|
|
223 |
+ |
println!(" {:<28} {:>5}", tag, files.len());
|
|
224 |
+ |
}
|
|
225 |
+ |
println!();
|
|
226 |
+ |
|
|
227 |
+ |
// A fresh vault every run. The layer is a pure function of the corpus, and
|
|
228 |
+ |
// leftovers from a previous run would silently widen it.
|
|
229 |
+ |
if vault.exists()
|
|
230 |
+ |
&& let Err(e) = std::fs::remove_dir_all(vault)
|
|
231 |
+ |
{
|
|
232 |
+ |
eprintln!("could not clear scratch vault {}: {e}", vault.display());
|
|
233 |
+ |
std::process::exit(1);
|
|
234 |
+ |
}
|
|
235 |
+ |
let samples_dir = vault.join("samples");
|
|
236 |
+ |
if let Err(e) = std::fs::create_dir_all(&samples_dir) {
|
|
237 |
+ |
eprintln!("could not create {}: {e}", samples_dir.display());
|
|
238 |
+ |
std::process::exit(1);
|
|
239 |
+ |
}
|
|
240 |
+ |
|
|
241 |
+ |
let db = match Database::open(vault.join("audiofiles.db")) {
|
|
242 |
+ |
Ok(db) => db,
|
|
243 |
+ |
Err(e) => {
|
|
244 |
+ |
eprintln!("Database::open failed: {e}");
|
|
245 |
+ |
std::process::exit(1);
|
|
246 |
+ |
}
|
|
247 |
+ |
};
|
|
248 |
+ |
let store = match SampleStore::new(&samples_dir) {
|
|
249 |
+ |
Ok(s) => s,
|
|
250 |
+ |
Err(e) => {
|
|
251 |
+ |
eprintln!("SampleStore::new failed: {e}");
|
|
252 |
+ |
std::process::exit(1);
|
|
253 |
+ |
}
|
|
254 |
+ |
};
|
|
255 |
+ |
|
|
256 |
+ |
// Import and tag. No VFS nodes: nothing here runs a UI query, and the export
|
|
257 |
+ |
// reads `sample_features` joined to `tags`, neither of which needs one.
|
|
258 |
+ |
let start = Instant::now();
|
|
259 |
+ |
let mut to_analyze: Vec<(String, PathBuf)> = Vec::with_capacity(total);
|
|
260 |
+ |
let mut import_failures = 0usize;
|
|
261 |
+ |
for (tag, files) in &by_tag {
|
|
262 |
+ |
for path in files {
|
|
263 |
+ |
match store.import(path, &db) {
|
|
264 |
+ |
Ok(hash) => {
|
|
265 |
+ |
if let Err(e) = tags::add_tag(&db, &hash, tag) {
|
|
266 |
+ |
eprintln!(" tag {tag} on {}: {e}", path.display());
|
|
267 |
+ |
import_failures += 1;
|
|
268 |
+ |
continue;
|
|
269 |
+ |
}
|
|
270 |
+ |
to_analyze.push((hash, path.clone()));
|
|
271 |
+ |
}
|
|
272 |
+ |
Err(e) => {
|
|
273 |
+ |
eprintln!(" import {}: {e}", path.display());
|
|
274 |
+ |
import_failures += 1;
|
|
275 |
+ |
}
|
|
276 |
+ |
}
|
|
277 |
+ |
}
|
|
278 |
+ |
}
|
|
279 |
+ |
println!(
|
|
280 |
+ |
" imported {} file(s) in {:.1}s{}",
|
|
281 |
+ |
to_analyze.len(),
|
|
282 |
+ |
start.elapsed().as_secs_f64(),
|
|
283 |
+ |
if import_failures > 0 {
|
|
284 |
+ |
format!(", {import_failures} failed")
|
|
285 |
+ |
} else {
|
|
286 |
+ |
String::new()
|
|
287 |
+ |
}
|
|
288 |
+ |
);
|
|
289 |
+ |
|
|
290 |
+ |
// Analyse everything. The ingest benchmark caps this because analysis is the
|
|
291 |
+ |
// expensive stage and it only needs a sample; here every vector is the
|
|
292 |
+ |
// payload, so there is no budget to apply.
|
|
293 |
+ |
let start = Instant::now();
|
|
294 |
+ |
let results: Vec<_> = to_analyze
|
|
295 |
+ |
.iter()
|
|
296 |
+ |
.filter_map(|(hash, path)| analysis::analyze_sample(hash, path, config).ok())
|
|
297 |
+ |
.collect();
|
|
298 |
+ |
let analyzed = results.len();
|
|
299 |
+ |
if let Err(e) = analysis::save_analysis_batch(&db, &results) {
|
|
300 |
+ |
eprintln!("save_analysis_batch failed: {e}");
|
|
301 |
+ |
std::process::exit(1);
|
|
302 |
+ |
}
|
|
303 |
+ |
println!(
|
|
304 |
+ |
" analysed {} file(s) in {:.1}s{}",
|
|
305 |
+ |
analyzed,
|
|
306 |
+ |
start.elapsed().as_secs_f64(),
|
|
307 |
+ |
if analyzed < to_analyze.len() {
|
|
308 |
+ |
format!(", {} failed to analyse", to_analyze.len() - analyzed)
|
|
309 |
+ |
} else {
|
|
310 |
+ |
String::new()
|
|
311 |
+ |
}
|
|
312 |
+ |
);
|
|
313 |
+ |
|
|
314 |
+ |
// Exemplars only. Rules and policy are deliberately excluded: the app already
|
|
315 |
+ |
// ships `starter_rules` in the binary, so exporting them here would put a
|
|
316 |
+ |
// second, staler copy inside the layer for the user to review and enable.
|
|
317 |
+ |
let opts = ExportOptions {
|
|
318 |
+ |
name: "Official drum classifier".to_string(),
|
|
319 |
+ |
description: format!(
|
|
320 |
+ |
"Bundled default layer. {analyzed} exemplars across {} classes, built from the labelled corpus.",
|
|
321 |
+ |
by_tag.len()
|
|
322 |
+ |
),
|
|
323 |
+ |
license_note: note,
|
|
324 |
+ |
kind: LayerKind::Official,
|
|
325 |
+ |
include_exemplars: true,
|
|
326 |
+ |
include_rules: false,
|
|
327 |
+ |
include_policy: false,
|
|
328 |
+ |
};
|
|
329 |
+ |
|
|
330 |
+ |
if let Some(parent) = out.parent()
|
|
331 |
+ |
&& let Err(e) = std::fs::create_dir_all(parent)
|
|
332 |
+ |
{
|
|
333 |
+ |
eprintln!("could not create {}: {e}", parent.display());
|
|
334 |
+ |
std::process::exit(1);
|
|
335 |
+ |
}
|
|
336 |
+ |
if let Err(e) = export_to_path(&db, out, &opts) {
|
|
337 |
+ |
eprintln!("export failed: {e}");
|
|
338 |
+ |
std::process::exit(1);
|
|
339 |
+ |
}
|
|
340 |
+ |
|
|
341 |
+ |
let bytes = std::fs::metadata(out).map_or(0, |m| m.len());
|
|
342 |
+ |
println!();
|
|
343 |
+ |
println!(
|
|
344 |
+ |
" wrote {} ({:.1} KiB)",
|
|
345 |
+ |
out.display(),
|
|
346 |
+ |
bytes as f64 / 1024.0
|
|
347 |
+ |
);
|
|
348 |
+ |
println!();
|
|
349 |
+ |
println!(" Layer is built for features v{FEATURE_VERSION}. Regenerate on every");
|
|
350 |
+ |
println!(" FEATURE_VERSION bump: import rejects a layer built under another.");
|
|
351 |
+ |
|
|
352 |
+ |
if analyzed == 0 {
|
|
353 |
+ |
eprintln!();
|
|
354 |
+ |
eprintln!(" nothing analysed, so the layer carries no exemplars");
|
|
355 |
+ |
std::process::exit(1);
|
|
356 |
+ |
}
|
|
357 |
+ |
}
|
|
358 |
+ |
|
|
359 |
+ |
#[cfg(test)]
|
|
360 |
+ |
mod tests {
|
|
361 |
+ |
use super::*;
|
|
362 |
+ |
|
|
363 |
+ |
#[test]
|
|
364 |
+ |
fn folder_names_map_to_canonical_tags() {
|
|
365 |
+ |
assert_eq!(tag_for_folder("kick"), Some("instrument.drum.kick"));
|
|
366 |
+ |
assert_eq!(tag_for_folder("snare"), Some("instrument.drum.snare"));
|
|
367 |
+ |
assert_eq!(tag_for_folder("cymbal"), Some("instrument.drum.cymbal"));
|
|
368 |
+ |
assert_eq!(tag_for_folder("clap"), Some("instrument.drum.clap"));
|
|
369 |
+ |
assert_eq!(tag_for_folder("tom"), Some("instrument.drum.tom"));
|
|
370 |
+ |
assert_eq!(tag_for_folder("percussion"), Some("instrument.percussion"));
|
|
371 |
+ |
}
|
|
372 |
+ |
|
|
373 |
+ |
#[test]
|
|
374 |
+ |
fn hihat_folder_matches_the_hi_hat_label() {
|
|
375 |
+ |
// The one place the corpus and DRUM_CLASSES disagree on spelling.
|
|
376 |
+ |
assert_eq!(tag_for_folder("hihat"), Some("instrument.drum.hihat"));
|
|
377 |
+ |
assert_eq!(tag_for_folder("hi-hat"), Some("instrument.drum.hihat"));
|
|
378 |
+ |
assert_eq!(tag_for_folder("Hi Hat"), Some("instrument.drum.hihat"));
|
|
379 |
+ |
}
|
|
380 |
+ |
|
|
381 |
+ |
#[test]
|
|
382 |
+ |
fn unknown_folder_has_no_tag() {
|
|
383 |
+ |
assert_eq!(tag_for_folder("bass"), None);
|
|
384 |
+ |
assert_eq!(tag_for_folder(""), None);
|
|
385 |
+ |
}
|
|
386 |
+ |
|
|
387 |
+ |
#[test]
|
|
388 |
+ |
fn every_drum_class_is_reachable_from_some_folder_name() {
|
|
389 |
+ |
// Guards the mapping against a DRUM_CLASSES entry whose label stops
|
|
390 |
+ |
// resolving; without this, a renamed label silently drops a class.
|
|
391 |
+ |
for class in DRUM_CLASSES {
|
|
392 |
+ |
assert_eq!(
|
|
393 |
+ |
tag_for_folder(class.label),
|
|
394 |
+ |
Some(class.tag),
|
|
395 |
+ |
"class {} no longer resolves from its own label",
|
|
396 |
+ |
class.label
|
|
397 |
+ |
);
|
|
398 |
+ |
}
|
|
399 |
+ |
}
|
|
400 |
+ |
|
|
401 |
+ |
#[test]
|
|
402 |
+ |
fn license_note_requires_a_manifest() {
|
|
403 |
+ |
let dir = tempfile::tempdir().unwrap();
|
|
404 |
+ |
assert!(license_note(dir.path()).is_err());
|
|
405 |
+ |
}
|
|
406 |
+ |
|
|
407 |
+ |
#[test]
|
|
408 |
+ |
fn manifest_is_found_beside_the_samples_dir() {
|
|
409 |
+ |
// The real corpus layout: AF_BENCH_CORPUS points at <root>/samples and
|
|
410 |
+ |
// corpus.py writes MANIFEST.json at <root>.
|
|
411 |
+ |
let dir = tempfile::tempdir().unwrap();
|
|
412 |
+ |
let samples = dir.path().join("samples");
|
|
413 |
+ |
std::fs::create_dir_all(&samples).unwrap();
|
|
414 |
+ |
std::fs::write(
|
|
415 |
+ |
dir.path().join(MANIFEST),
|
|
416 |
+ |
r#"{"training_counts":{"kick":2},"datasets":[{"name":"nsynth","license":"CC-BY 4.0"}]}"#,
|
|
417 |
+ |
)
|
|
418 |
+ |
.unwrap();
|
|
419 |
+ |
assert!(license_note(&samples).unwrap().contains("nsynth"));
|
|
420 |
+ |
}
|
|
421 |
+ |
|
|
422 |
+ |
#[test]
|
|
423 |
+ |
fn license_note_names_every_dataset_and_its_licence() {
|
|
424 |
+ |
let dir = tempfile::tempdir().unwrap();
|
|
425 |
+ |
std::fs::write(
|
|
426 |
+ |
dir.path().join(MANIFEST),
|
|
427 |
+ |
r#"{"training_counts":{"kick":2},"datasets":[
|
|
428 |
+ |
{"name":"nsynth","license":"CC-BY 4.0","source":"http://example.invalid/nsynth"},
|
|
429 |
+ |
{"name":"other","license":"CC0 1.0"}
|
|
430 |
+ |
]}"#,
|
|
431 |
+ |
)
|
|
432 |
+ |
.unwrap();
|
|
433 |
+ |
let note = license_note(dir.path()).unwrap();
|
|
434 |
+ |
assert!(note.contains("nsynth (CC-BY 4.0)"));
|
|
435 |
+ |
assert!(note.contains("http://example.invalid/nsynth"));
|
|
436 |
+ |
assert!(note.contains("other (CC0 1.0)"));
|
|
437 |
+ |
assert!(note.contains("no audio"));
|
|
438 |
+ |
}
|
|
439 |
+ |
|
|
440 |
+ |
#[test]
|
|
441 |
+ |
fn license_note_rejects_a_dataset_missing_its_licence() {
|
|
442 |
+ |
let dir = tempfile::tempdir().unwrap();
|
|
443 |
+ |
std::fs::write(
|
|
444 |
+ |
dir.path().join(MANIFEST),
|
|
445 |
+ |
r#"{"training_counts":{"kick":2},"datasets":[{"name":"nsynth"}]}"#,
|
|
446 |
+ |
)
|
|
447 |
+ |
.unwrap();
|
|
448 |
+ |
assert!(license_note(dir.path()).is_err());
|
|
449 |
+ |
}
|
|
450 |
+ |
|
|
451 |
+ |
#[test]
|
|
452 |
+ |
fn license_note_rejects_a_manifest_that_does_not_describe_the_training_data() {
|
|
453 |
+ |
// The live failure: corpus.py overwrites the manifest, so a later fetch
|
|
454 |
+ |
// of a different dataset leaves training/ intact while replacing the
|
|
455 |
+ |
// attribution. Without `training_counts` the datasets list is not
|
|
456 |
+ |
// evidence of where the labels came from.
|
|
457 |
+ |
let dir = tempfile::tempdir().unwrap();
|
|
458 |
+ |
std::fs::write(
|
|
459 |
+ |
dir.path().join(MANIFEST),
|
|
460 |
+ |
r#"{"datasets":[{"name":"nsynth","license":"CC-BY 4.0"}]}"#,
|
|
461 |
+ |
)
|
|
462 |
+ |
.unwrap();
|
|
463 |
+ |
let err = license_note(dir.path()).unwrap_err();
|
|
464 |
+ |
assert!(err.contains("training_counts"), "{err}");
|
|
465 |
+ |
}
|
|
466 |
+ |
|
|
467 |
+ |
#[test]
|
|
468 |
+ |
fn collect_labelled_rejects_an_unmapped_folder() {
|
|
469 |
+ |
let dir = tempfile::tempdir().unwrap();
|
|
470 |
+ |
let training = dir.path().join(TRAINING_SUBDIR);
|
|
471 |
+ |
std::fs::create_dir_all(training.join("kick")).unwrap();
|
|
472 |
+ |
std::fs::create_dir_all(training.join("didgeridoo")).unwrap();
|
|
473 |
+ |
let err = collect_labelled(&training).unwrap_err();
|
|
474 |
+ |
assert!(err.contains("didgeridoo"), "{err}");
|
|
475 |
+ |
}
|
|
476 |
+ |
|
|
477 |
+ |
#[test]
|
|
478 |
+ |
fn collect_labelled_groups_files_under_their_tag() {
|
|
479 |
+ |
let dir = tempfile::tempdir().unwrap();
|
|
480 |
+ |
let training = dir.path().join(TRAINING_SUBDIR);
|
|
481 |
+ |
std::fs::create_dir_all(training.join("kick")).unwrap();
|
|
482 |
+ |
std::fs::create_dir_all(training.join("snare")).unwrap();
|
|
483 |
+ |
std::fs::write(training.join("kick/b.wav"), b"x").unwrap();
|
|
484 |
+ |
std::fs::write(training.join("kick/a.wav"), b"x").unwrap();
|
|
485 |
+ |
std::fs::write(training.join("snare/c.wav"), b"x").unwrap();
|
|
486 |
+ |
|
|
487 |
+ |
let got = collect_labelled(&training).unwrap();
|
|
488 |
+ |
assert_eq!(got["instrument.drum.kick"].len(), 2);
|
|
489 |
+ |
assert_eq!(got["instrument.drum.snare"].len(), 1);
|
|
490 |
+ |
// Sorted, so the artifact is reproducible across runs.
|
|
491 |
+ |
assert!(got["instrument.drum.kick"][0].ends_with("a.wav"));
|
|
492 |
+ |
}
|
|
493 |
+ |
}
|