Skip to main content

max / audiofiles

15.1 KB · 418 lines History Blame Raw
1 //! A starter pack of filename rules for Layer A.
2 //!
3 //! Where instrument identity is genuinely wanted, the filename beats the audio by
4 //! a wide margin. Our own ground truth proves it: `scripts/corpus.py` labelled
5 //! 1,049 of 1,074 one-shots from filenames alone, 97.7%, against the retired DSP
6 //! classifier's 33.4% on those exact files. Real packs name their files `Kick.wav`.
7 //!
8 //! So this is the vocabulary from that script, carried into the rules engine
9 //! (`crate::rules`, Layer A) as ordinary rules: ordered, deterministic,
10 //! re-runnable, provenance-tracked as `source = 'rule'`, and fully editable once
11 //! seeded. Nothing here is privileged over a rule the user writes.
12 //!
13 //! Seeded rules arrive **disabled**, matching how imported `.afcl` rules arrive, so
14 //! the pack is a starting point offered for review rather than a library-wide
15 //! retag the user did not ask for.
16 //!
17 //! ## Family-level tags, via the hierarchy
18 //!
19 //! Across 9,493 Freesound sounds, 60.5% carry an instrument-family word and only
20 //! 13.5% a specific instrument; of sounds tagged with a drum family word, just 21%
21 //! name the specific drum. The tags here are specific where the filename is
22 //! (`instrument.drum.kick`), which serves family queries anyway because tag search
23 //! is prefix-based: filtering on `instrument.drum` matches every drum below it. A
24 //! separate bare family tag would say the same thing twice.
25 //!
26 //! ## Ambiguity: a name that hits two classes gets neither
27 //!
28 //! `corpus.py` drops a file matching two classes rather than resolving by rule
29 //! order, because these packs contain genuinely layered hits (`Kick_Cowbell.wav`,
30 //! `Tom-Cymbal.wav`) and first-match-wins would pick one at random. That behaviour
31 //! carries over here, since a wrong tag is worse than no tag.
32 //!
33 //! Expressing it takes two passes, because [`MatchMode`] is per-rule: there is no
34 //! way to write "any of these keywords AND none of those" as one rule. So each
35 //! drum class gets a keyword rule that adds its tag, and then a guard rule that
36 //! removes that same tag when any OTHER drum class's keyword is also present.
37 //! `RuleAction::RemoveTag` suppresses an earlier add in the same pass, so the guards
38 //! run after the adds (higher priority number) and the layered hit ends up with
39 //! neither tag. On an unambiguous name the guard simply does not match.
40 //!
41 //! Only the drum classes guard each other. "Bass Guitar Loop" being tagged both
42 //! `instrument.bass` and `instrument.guitar` is correct, not ambiguous.
43
44 use crate::db::Database;
45 use crate::error::Result;
46 use crate::rules::{MatchMode, NewRule, RuleAction, RuleCondition, RuleField, RuleOp};
47
48 /// One class in the starter vocabulary: the tag it applies and the filename
49 /// keywords that trigger it.
50 pub struct StarterClass {
51 /// Short label, used to build the rule name.
52 pub label: &'static str,
53 /// The tag applied on a match.
54 pub tag: &'static str,
55 /// Case-insensitive substrings tested against the filename. Any one matches.
56 pub keywords: &'static [&'static str],
57 }
58
59 /// The drum classes, which guard each other against layered-hit names.
60 ///
61 /// Keywords are `corpus.py`'s `CLASS_RULES` minus its two-letter abbreviations
62 /// (` bd`, `_sd`, ` hh`). Those were tuned for one specific set of drum-machine
63 /// packs, where the surrounding whitespace made them safe; against an arbitrary
64 /// library they are false-positive bait, and this pack has to work on a library it
65 /// has never seen.
66 pub const DRUM_CLASSES: &[StarterClass] = &[
67 StarterClass {
68 label: "kick",
69 tag: "instrument.drum.kick",
70 keywords: &["kick", "bassdrum", "bass drum", "kik"],
71 },
72 StarterClass {
73 label: "snare",
74 tag: "instrument.drum.snare",
75 keywords: &["snare", "rimshot", "rim shot", "sidestick", "side stick"],
76 },
77 StarterClass {
78 label: "hi-hat",
79 tag: "instrument.drum.hihat",
80 keywords: &["hihat", "hi hat", "hi-hat", "hat"],
81 },
82 StarterClass {
83 label: "cymbal",
84 tag: "instrument.drum.cymbal",
85 keywords: &["cymbal", "crash", "ride", "splash", "china", "gong"],
86 },
87 StarterClass {
88 label: "clap",
89 tag: "instrument.drum.clap",
90 keywords: &["clap", "handclap"],
91 },
92 StarterClass {
93 label: "tom",
94 tag: "instrument.drum.tom",
95 keywords: &["tom"],
96 },
97 StarterClass {
98 label: "percussion",
99 tag: "instrument.percussion",
100 keywords: &[
101 "perc",
102 "cowbell",
103 "clave",
104 "maraca",
105 "bongo",
106 "conga",
107 "guiro",
108 "shaker",
109 "tambourine",
110 "triangle",
111 "agogo",
112 "cabasa",
113 "timbale",
114 "woodblock",
115 "chime",
116 ],
117 },
118 ];
119
120 /// Everything else: instrument families and format words, no cross-guarding.
121 ///
122 /// Format first on purpose. Format and tempo are the most-used tag kind in the
123 /// FSL10K survey at 91.8%, ahead of instrument family at 60.5%, and "is this a
124 /// loop or a one-shot" is the cheapest true thing a filename says.
125 pub const OTHER_CLASSES: &[StarterClass] = &[
126 StarterClass {
127 label: "loop",
128 tag: "type.loop",
129 keywords: &["loop"],
130 },
131 StarterClass {
132 label: "one-shot",
133 tag: "type.one-shot",
134 keywords: &["oneshot", "one-shot", "one shot"],
135 },
136 StarterClass {
137 label: "bass",
138 tag: "instrument.bass",
139 keywords: &["bass", "sub bass", "808"],
140 },
141 StarterClass {
142 label: "vocal",
143 tag: "instrument.vocal",
144 keywords: &["vocal", "vox", "acapella", "a capella"],
145 },
146 StarterClass {
147 label: "synth",
148 tag: "instrument.synth",
149 keywords: &["synth", "arp"],
150 },
151 StarterClass {
152 label: "pad",
153 tag: "instrument.pad",
154 keywords: &["pad"],
155 },
156 StarterClass {
157 label: "guitar",
158 tag: "instrument.guitar",
159 keywords: &["guitar", "gtr"],
160 },
161 StarterClass {
162 label: "piano",
163 tag: "instrument.piano",
164 keywords: &["piano", "rhodes"],
165 },
166 StarterClass {
167 label: "strings",
168 tag: "instrument.strings",
169 keywords: &["strings", "violin", "cello", "viola"],
170 },
171 StarterClass {
172 label: "brass",
173 tag: "instrument.brass",
174 keywords: &["brass", "trumpet", "trombone", " sax", "saxophone"],
175 },
176 StarterClass {
177 label: "fx",
178 tag: "character.fx",
179 keywords: &["riser", "downlifter", "uplifter", "whoosh", "sweep"],
180 },
181 ];
182
183 /// `instrument.bass` needs the same suppression the drum classes give each other,
184 /// for one specific reason: "bassdrum" and "bass drum" contain "bass". Without this
185 /// a kick named `BassDrum.wav` would carry `instrument.bass`, which is not a
186 /// layered hit, just a substring accident.
187 const BASS_GUARD_KEYWORDS: &[&str] = &["bassdrum", "bass drum", "kick"];
188
189 /// Name a seeded rule. Prefixed so the pack is identifiable in the rules list, and
190 /// so [`seed`] can tell an already-seeded database from a fresh one.
191 fn rule_name(label: &str) -> String {
192 format!("Filename: {label}")
193 }
194
195 fn guard_name(label: &str) -> String {
196 format!("Filename: {label} (skip if ambiguous)")
197 }
198
199 fn contains(keyword: &str) -> RuleCondition {
200 RuleCondition {
201 field: RuleField::Name,
202 op: RuleOp::Contains,
203 value: keyword.to_string(),
204 }
205 }
206
207 /// The rules this pack would create, in evaluation order: every keyword rule
208 /// first, then the guards that suppress an ambiguous match.
209 ///
210 /// Split out from [`seed`] so the shape is testable without a database.
211 pub fn rules() -> Vec<NewRule> {
212 let mut out = Vec::new();
213
214 for class in DRUM_CLASSES.iter().chain(OTHER_CLASSES) {
215 out.push(NewRule {
216 name: rule_name(class.label),
217 enabled: false,
218 priority: None,
219 match_mode: MatchMode::Any,
220 conditions: class.keywords.iter().copied().map(contains).collect(),
221 actions: vec![RuleAction::AddTag(class.tag.to_string())],
222 });
223 }
224
225 // Guards run after every add, so they get later priorities by construction
226 // (priority: None appends). A guard fires when a name carries some OTHER drum
227 // class's keyword, and drops its own class's tag.
228 for class in DRUM_CLASSES {
229 let others: Vec<RuleCondition> = DRUM_CLASSES
230 .iter()
231 .filter(|c| c.tag != class.tag)
232 .flat_map(|c| c.keywords.iter().copied())
233 .map(contains)
234 .collect();
235 out.push(NewRule {
236 name: guard_name(class.label),
237 enabled: false,
238 priority: None,
239 match_mode: MatchMode::Any,
240 conditions: others,
241 actions: vec![RuleAction::RemoveTag(class.tag.to_string())],
242 });
243 }
244
245 out.push(NewRule {
246 name: guard_name("bass"),
247 enabled: false,
248 priority: None,
249 match_mode: MatchMode::Any,
250 conditions: BASS_GUARD_KEYWORDS.iter().copied().map(contains).collect(),
251 actions: vec![RuleAction::RemoveTag("instrument.bass".to_string())],
252 });
253
254 out
255 }
256
257 /// Insert the starter pack, skipping any rule whose name is already present.
258 ///
259 /// Returns the number of rules created, so a second call reports 0 rather than
260 /// duplicating the pack. Rules arrive disabled; nothing is tagged until the user
261 /// enables them and applies.
262 pub fn seed(db: &Database) -> Result<usize> {
263 let existing: Vec<String> = crate::rules::list_rules(db)?
264 .into_iter()
265 .map(|r| r.name)
266 .collect();
267
268 let mut created = 0;
269 for rule in rules() {
270 if existing.contains(&rule.name) {
271 continue;
272 }
273 crate::rules::create_rule(db, rule)?;
274 created += 1;
275 }
276 Ok(created)
277 }
278
279 #[cfg(test)]
280 mod tests {
281 use super::*;
282 use crate::rules::{self};
283
284 /// Seed the pack, enable every rule, then run the real engine over a sample
285 /// named `name` and return the tags it ended up with.
286 ///
287 /// Deliberately not a reimplementation of `evaluate()`: the guards depend on
288 /// RemoveTag suppressing an earlier add within one pass, which is the engine's
289 /// behaviour to define, not this test's to restate.
290 fn tags_for(name: &str) -> Vec<String> {
291 let db = Database::open_in_memory().unwrap();
292 seed(&db).unwrap();
293 for rule in rules::list_rules(&db).unwrap() {
294 rules::set_rule_enabled(&db, &rule.id, true).unwrap();
295 }
296
297 let hash = "a".repeat(64);
298 let now = crate::error::unix_now();
299 db.conn()
300 .execute(
301 "INSERT INTO samples (hash, original_name, file_extension, file_size,
302 import_date, last_modified)
303 VALUES (?1, ?2, 'wav', 100, ?3, ?3)",
304 rusqlite::params![hash, name, now],
305 )
306 .unwrap();
307
308 rules::apply_rules_to_sample(&db, &hash).unwrap();
309 let mut tags = crate::tags::get_sample_tags(&db, &hash).unwrap();
310 tags.sort();
311 tags
312 }
313
314 #[test]
315 fn every_tag_in_the_pack_is_valid() {
316 // create_rule validates tags and would error at seed time; catching it here
317 // names the offending tag instead.
318 for class in DRUM_CLASSES.iter().chain(OTHER_CLASSES) {
319 crate::tags::validate_tag(class.tag)
320 .unwrap_or_else(|e| panic!("{} is not a valid tag: {e}", class.tag));
321 }
322 }
323
324 #[test]
325 fn unambiguous_names_get_their_tag() {
326 assert_eq!(tags_for("Kick 01.wav"), ["instrument.drum.kick"]);
327 assert_eq!(tags_for("BD_Snare_Hard.aiff"), ["instrument.drum.snare"]);
328 assert_eq!(tags_for("Closed Hat.wav"), ["instrument.drum.hihat"]);
329 assert_eq!(tags_for("Crash Cymbal.wav"), ["instrument.drum.cymbal"]);
330 assert_eq!(tags_for("Cowbell.wav"), ["instrument.percussion"]);
331 }
332
333 #[test]
334 fn layered_hit_names_get_neither_tag() {
335 // The corpus.py behaviour: two classes in one name means the name cannot be
336 // trusted for either, so both are dropped rather than one picked by order.
337 assert!(tags_for("Kick_Cowbell.wav").is_empty());
338 assert!(tags_for("Tom-Cymbal.wav").is_empty());
339 assert!(tags_for("Kick-Hat 3.wav").is_empty());
340 assert!(tags_for("Snare+Crash.wav").is_empty());
341 }
342
343 #[test]
344 fn bass_drum_is_a_kick_and_not_a_bass() {
345 // "bassdrum" contains "bass"; the bass guard is what keeps a kick out of
346 // the bass family.
347 assert_eq!(tags_for("BassDrum 2.wav"), ["instrument.drum.kick"]);
348 assert_eq!(tags_for("Bass Drum Long.wav"), ["instrument.drum.kick"]);
349 // A real bass still lands.
350 assert_eq!(tags_for("Reese Bass F.wav"), ["instrument.bass"]);
351 }
352
353 #[test]
354 fn format_words_stack_with_instrument_families() {
355 // Format and instrument are orthogonal, so unlike the drum classes these
356 // are meant to co-occur.
357 let tags = tags_for("Guitar Loop 120.wav");
358 assert!(tags.contains(&"type.loop".to_string()));
359 assert!(tags.contains(&"instrument.guitar".to_string()));
360
361 let tags = tags_for("Bass Guitar Oneshot.wav");
362 assert!(tags.contains(&"instrument.bass".to_string()));
363 assert!(tags.contains(&"instrument.guitar".to_string()));
364 assert!(tags.contains(&"type.one-shot".to_string()));
365 }
366
367 #[test]
368 fn a_name_with_nothing_recognisable_gets_nothing() {
369 assert!(tags_for("Untitled-3.wav").is_empty());
370 assert!(tags_for("MJ_92_04.wav").is_empty());
371 }
372
373 #[test]
374 fn seeding_is_idempotent() {
375 let db = Database::open_in_memory().unwrap();
376 let first = seed(&db).unwrap();
377 assert_eq!(first, rules().len());
378 assert_eq!(seed(&db).unwrap(), 0, "second seed must create nothing");
379 assert_eq!(rules::list_rules(&db).unwrap().len(), first);
380 }
381
382 #[test]
383 fn seeded_rules_arrive_disabled() {
384 // The pack is an offer, not a retag. Enabling is the user's call, and until
385 // then applying rules changes nothing.
386 let db = Database::open_in_memory().unwrap();
387 seed(&db).unwrap();
388 assert!(rules::list_rules(&db).unwrap().iter().all(|r| !r.enabled));
389 assert_eq!(rules::apply_all_rules(&db).unwrap(), 0);
390 }
391
392 #[test]
393 fn guards_evaluate_after_the_adds() {
394 // RemoveTag only suppresses an add that already happened, so the guards
395 // must sort after every keyword rule. Seeding appends in order, which is
396 // what makes that true; assert it rather than trusting it.
397 let db = Database::open_in_memory().unwrap();
398 seed(&db).unwrap();
399 let rules = rules::list_rules(&db).unwrap();
400 let last_add = rules
401 .iter()
402 .rposition(|r| r.actions.iter().any(|a| matches!(a, RuleAction::AddTag(_))))
403 .unwrap();
404 let first_guard = rules
405 .iter()
406 .position(|r| {
407 r.actions
408 .iter()
409 .any(|a| matches!(a, RuleAction::RemoveTag(_)))
410 })
411 .unwrap();
412 assert!(
413 first_guard > last_add,
414 "a guard at {first_guard} runs before the add at {last_add}"
415 );
416 }
417 }
418