//! A starter pack of filename rules for Layer A. //! //! Where instrument identity is genuinely wanted, the filename beats the audio by //! a wide margin. Our own ground truth proves it: `scripts/corpus.py` labelled //! 1,049 of 1,074 one-shots from filenames alone, 97.7%, against the retired DSP //! classifier's 33.4% on those exact files. Real packs name their files `Kick.wav`. //! //! So this is the vocabulary from that script, carried into the rules engine //! (`crate::rules`, Layer A) as ordinary rules: ordered, deterministic, //! re-runnable, provenance-tracked as `source = 'rule'`, and fully editable once //! seeded. Nothing here is privileged over a rule the user writes. //! //! Seeded rules arrive **disabled**, matching how imported `.afcl` rules arrive, so //! the pack is a starting point offered for review rather than a library-wide //! retag the user did not ask for. //! //! ## Family-level tags, via the hierarchy //! //! Across 9,493 Freesound sounds, 60.5% carry an instrument-family word and only //! 13.5% a specific instrument; of sounds tagged with a drum family word, just 21% //! name the specific drum. The tags here are specific where the filename is //! (`instrument.drum.kick`), which serves family queries anyway because tag search //! is prefix-based: filtering on `instrument.drum` matches every drum below it. A //! separate bare family tag would say the same thing twice. //! //! ## Ambiguity: a name that hits two classes gets neither //! //! `corpus.py` drops a file matching two classes rather than resolving by rule //! order, because these packs contain genuinely layered hits (`Kick_Cowbell.wav`, //! `Tom-Cymbal.wav`) and first-match-wins would pick one at random. That behaviour //! carries over here, since a wrong tag is worse than no tag. //! //! Expressing it takes two passes, because [`MatchMode`] is per-rule: there is no //! way to write "any of these keywords AND none of those" as one rule. So each //! drum class gets a keyword rule that adds its tag, and then a guard rule that //! removes that same tag when any OTHER drum class's keyword is also present. //! `RuleAction::RemoveTag` suppresses an earlier add in the same pass, so the guards //! run after the adds (higher priority number) and the layered hit ends up with //! neither tag. On an unambiguous name the guard simply does not match. //! //! Only the drum classes guard each other. "Bass Guitar Loop" being tagged both //! `instrument.bass` and `instrument.guitar` is correct, not ambiguous. use crate::db::Database; use crate::error::Result; use crate::rules::{MatchMode, NewRule, RuleAction, RuleCondition, RuleField, RuleOp}; /// One class in the starter vocabulary: the tag it applies and the filename /// keywords that trigger it. pub struct StarterClass { /// Short label, used to build the rule name. pub label: &'static str, /// The tag applied on a match. pub tag: &'static str, /// Case-insensitive substrings tested against the filename. Any one matches. pub keywords: &'static [&'static str], } /// The drum classes, which guard each other against layered-hit names. /// /// Keywords are `corpus.py`'s `CLASS_RULES` minus its two-letter abbreviations /// (` bd`, `_sd`, ` hh`). Those were tuned for one specific set of drum-machine /// packs, where the surrounding whitespace made them safe; against an arbitrary /// library they are false-positive bait, and this pack has to work on a library it /// has never seen. pub const DRUM_CLASSES: &[StarterClass] = &[ StarterClass { label: "kick", tag: "instrument.drum.kick", keywords: &["kick", "bassdrum", "bass drum", "kik"], }, StarterClass { label: "snare", tag: "instrument.drum.snare", keywords: &["snare", "rimshot", "rim shot", "sidestick", "side stick"], }, StarterClass { label: "hi-hat", tag: "instrument.drum.hihat", keywords: &["hihat", "hi hat", "hi-hat", "hat"], }, StarterClass { label: "cymbal", tag: "instrument.drum.cymbal", keywords: &["cymbal", "crash", "ride", "splash", "china", "gong"], }, StarterClass { label: "clap", tag: "instrument.drum.clap", keywords: &["clap", "handclap"], }, StarterClass { label: "tom", tag: "instrument.drum.tom", keywords: &["tom"], }, StarterClass { label: "percussion", tag: "instrument.percussion", keywords: &[ "perc", "cowbell", "clave", "maraca", "bongo", "conga", "guiro", "shaker", "tambourine", "triangle", "agogo", "cabasa", "timbale", "woodblock", "chime", ], }, ]; /// Everything else: instrument families and format words, no cross-guarding. /// /// Format first on purpose. Format and tempo are the most-used tag kind in the /// FSL10K survey at 91.8%, ahead of instrument family at 60.5%, and "is this a /// loop or a one-shot" is the cheapest true thing a filename says. pub const OTHER_CLASSES: &[StarterClass] = &[ StarterClass { label: "loop", tag: "type.loop", keywords: &["loop"], }, StarterClass { label: "one-shot", tag: "type.one-shot", keywords: &["oneshot", "one-shot", "one shot"], }, StarterClass { label: "bass", tag: "instrument.bass", keywords: &["bass", "sub bass", "808"], }, StarterClass { label: "vocal", tag: "instrument.vocal", keywords: &["vocal", "vox", "acapella", "a capella"], }, StarterClass { label: "synth", tag: "instrument.synth", keywords: &["synth", "arp"], }, StarterClass { label: "pad", tag: "instrument.pad", keywords: &["pad"], }, StarterClass { label: "guitar", tag: "instrument.guitar", keywords: &["guitar", "gtr"], }, StarterClass { label: "piano", tag: "instrument.piano", keywords: &["piano", "rhodes"], }, StarterClass { label: "strings", tag: "instrument.strings", keywords: &["strings", "violin", "cello", "viola"], }, StarterClass { label: "brass", tag: "instrument.brass", keywords: &["brass", "trumpet", "trombone", " sax", "saxophone"], }, StarterClass { label: "fx", tag: "character.fx", keywords: &["riser", "downlifter", "uplifter", "whoosh", "sweep"], }, ]; /// `instrument.bass` needs the same suppression the drum classes give each other, /// for one specific reason: "bassdrum" and "bass drum" contain "bass". Without this /// a kick named `BassDrum.wav` would carry `instrument.bass`, which is not a /// layered hit, just a substring accident. const BASS_GUARD_KEYWORDS: &[&str] = &["bassdrum", "bass drum", "kick"]; /// Name a seeded rule. Prefixed so the pack is identifiable in the rules list, and /// so [`seed`] can tell an already-seeded database from a fresh one. fn rule_name(label: &str) -> String { format!("Filename: {label}") } fn guard_name(label: &str) -> String { format!("Filename: {label} (skip if ambiguous)") } fn contains(keyword: &str) -> RuleCondition { RuleCondition { field: RuleField::Name, op: RuleOp::Contains, value: keyword.to_string(), } } /// The rules this pack would create, in evaluation order: every keyword rule /// first, then the guards that suppress an ambiguous match. /// /// Split out from [`seed`] so the shape is testable without a database. pub fn rules() -> Vec { let mut out = Vec::new(); for class in DRUM_CLASSES.iter().chain(OTHER_CLASSES) { out.push(NewRule { name: rule_name(class.label), enabled: false, priority: None, match_mode: MatchMode::Any, conditions: class.keywords.iter().copied().map(contains).collect(), actions: vec![RuleAction::AddTag(class.tag.to_string())], }); } // Guards run after every add, so they get later priorities by construction // (priority: None appends). A guard fires when a name carries some OTHER drum // class's keyword, and drops its own class's tag. for class in DRUM_CLASSES { let others: Vec = DRUM_CLASSES .iter() .filter(|c| c.tag != class.tag) .flat_map(|c| c.keywords.iter().copied()) .map(contains) .collect(); out.push(NewRule { name: guard_name(class.label), enabled: false, priority: None, match_mode: MatchMode::Any, conditions: others, actions: vec![RuleAction::RemoveTag(class.tag.to_string())], }); } out.push(NewRule { name: guard_name("bass"), enabled: false, priority: None, match_mode: MatchMode::Any, conditions: BASS_GUARD_KEYWORDS.iter().copied().map(contains).collect(), actions: vec![RuleAction::RemoveTag("instrument.bass".to_string())], }); out } /// Insert the starter pack, skipping any rule whose name is already present. /// /// Returns the number of rules created, so a second call reports 0 rather than /// duplicating the pack. Rules arrive disabled; nothing is tagged until the user /// enables them and applies. pub fn seed(db: &Database) -> Result { let existing: Vec = crate::rules::list_rules(db)? .into_iter() .map(|r| r.name) .collect(); let mut created = 0; for rule in rules() { if existing.contains(&rule.name) { continue; } crate::rules::create_rule(db, rule)?; created += 1; } Ok(created) } #[cfg(test)] mod tests { use super::*; use crate::rules::{self}; /// Seed the pack, enable every rule, then run the real engine over a sample /// named `name` and return the tags it ended up with. /// /// Deliberately not a reimplementation of `evaluate()`: the guards depend on /// RemoveTag suppressing an earlier add within one pass, which is the engine's /// behaviour to define, not this test's to restate. fn tags_for(name: &str) -> Vec { let db = Database::open_in_memory().unwrap(); seed(&db).unwrap(); for rule in rules::list_rules(&db).unwrap() { rules::set_rule_enabled(&db, &rule.id, true).unwrap(); } let hash = "a".repeat(64); let now = crate::error::unix_now(); db.conn() .execute( "INSERT INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified) VALUES (?1, ?2, 'wav', 100, ?3, ?3)", rusqlite::params![hash, name, now], ) .unwrap(); rules::apply_rules_to_sample(&db, &hash).unwrap(); let mut tags = crate::tags::get_sample_tags(&db, &hash).unwrap(); tags.sort(); tags } #[test] fn every_tag_in_the_pack_is_valid() { // create_rule validates tags and would error at seed time; catching it here // names the offending tag instead. for class in DRUM_CLASSES.iter().chain(OTHER_CLASSES) { crate::tags::validate_tag(class.tag) .unwrap_or_else(|e| panic!("{} is not a valid tag: {e}", class.tag)); } } #[test] fn unambiguous_names_get_their_tag() { assert_eq!(tags_for("Kick 01.wav"), ["instrument.drum.kick"]); assert_eq!(tags_for("BD_Snare_Hard.aiff"), ["instrument.drum.snare"]); assert_eq!(tags_for("Closed Hat.wav"), ["instrument.drum.hihat"]); assert_eq!(tags_for("Crash Cymbal.wav"), ["instrument.drum.cymbal"]); assert_eq!(tags_for("Cowbell.wav"), ["instrument.percussion"]); } #[test] fn layered_hit_names_get_neither_tag() { // The corpus.py behaviour: two classes in one name means the name cannot be // trusted for either, so both are dropped rather than one picked by order. assert!(tags_for("Kick_Cowbell.wav").is_empty()); assert!(tags_for("Tom-Cymbal.wav").is_empty()); assert!(tags_for("Kick-Hat 3.wav").is_empty()); assert!(tags_for("Snare+Crash.wav").is_empty()); } #[test] fn bass_drum_is_a_kick_and_not_a_bass() { // "bassdrum" contains "bass"; the bass guard is what keeps a kick out of // the bass family. assert_eq!(tags_for("BassDrum 2.wav"), ["instrument.drum.kick"]); assert_eq!(tags_for("Bass Drum Long.wav"), ["instrument.drum.kick"]); // A real bass still lands. assert_eq!(tags_for("Reese Bass F.wav"), ["instrument.bass"]); } #[test] fn format_words_stack_with_instrument_families() { // Format and instrument are orthogonal, so unlike the drum classes these // are meant to co-occur. let tags = tags_for("Guitar Loop 120.wav"); assert!(tags.contains(&"type.loop".to_string())); assert!(tags.contains(&"instrument.guitar".to_string())); let tags = tags_for("Bass Guitar Oneshot.wav"); assert!(tags.contains(&"instrument.bass".to_string())); assert!(tags.contains(&"instrument.guitar".to_string())); assert!(tags.contains(&"type.one-shot".to_string())); } #[test] fn a_name_with_nothing_recognisable_gets_nothing() { assert!(tags_for("Untitled-3.wav").is_empty()); assert!(tags_for("MJ_92_04.wav").is_empty()); } #[test] fn seeding_is_idempotent() { let db = Database::open_in_memory().unwrap(); let first = seed(&db).unwrap(); assert_eq!(first, rules().len()); assert_eq!(seed(&db).unwrap(), 0, "second seed must create nothing"); assert_eq!(rules::list_rules(&db).unwrap().len(), first); } #[test] fn seeded_rules_arrive_disabled() { // The pack is an offer, not a retag. Enabling is the user's call, and until // then applying rules changes nothing. let db = Database::open_in_memory().unwrap(); seed(&db).unwrap(); assert!(rules::list_rules(&db).unwrap().iter().all(|r| !r.enabled)); assert_eq!(rules::apply_all_rules(&db).unwrap(), 0); } #[test] fn guards_evaluate_after_the_adds() { // RemoveTag only suppresses an add that already happened, so the guards // must sort after every keyword rule. Seeding appends in order, which is // what makes that true; assert it rather than trusting it. let db = Database::open_in_memory().unwrap(); seed(&db).unwrap(); let rules = rules::list_rules(&db).unwrap(); let last_add = rules .iter() .rposition(|r| r.actions.iter().any(|a| matches!(a, RuleAction::AddTag(_)))) .unwrap(); let first_guard = rules .iter() .position(|r| { r.actions .iter() .any(|a| matches!(a, RuleAction::RemoveTag(_))) }) .unwrap(); assert!( first_guard > last_add, "a guard at {first_guard} runs before the add at {last_add}" ); } }