Skip to main content

max / audiofiles

Add a starter pack of filename rules Where instrument identity is actually wanted, the filename beats the audio by about 3x for free: the same names corpus.py reads labelled 1,049 of 1,074 one-shots correctly, against 33.4% for the DSP classifier on those exact files. Real packs name their files Kick.wav. So the vocabulary moves into Layer A as ordinary rules, seeded by a button in Tag Rules and disabled on arrival (matching imported .afcl rules), so the pack is an offer for review rather than a library-wide retag. Seeding skips rules already present by name, so pressing it twice does nothing. Tags are specific rather than doubled up with a family tag, because tag search is prefix-based: instrument.drum already matches instrument.drum.kick. A name hitting two drum classes gets neither tag, carrying over corpus.py's refusal to resolve a layered hit by rule order. That takes two passes, because MatchMode is per-rule and "any of these keywords AND none of those" is not expressible as one rule: each class adds its tag, then a guard rule removes it when another class's keyword is also present, which works because RemoveTag suppresses an earlier add in the same pass. Only the drum classes guard each other, plus bass against bassdrum, since a bass guitar really is both. The two-letter abbreviations from corpus.py are left out: safe against the drum-machine packs they were written for, false-positive bait anywhere else.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-30 13:56 UTC
Signed with PGP, not checked
Commit: 830ef9ef8604332486959d01f7119285b0e1cfa2
Parent: 261e567
10 files changed, +490 insertions, -1 deletion
@@ -10,6 +10,7 @@
10 10 - Forge: resample overshoot handling. Conforming to an integer target now detects true-peak overshoot (>1.0); by default the signal is left untouched and a warning is shown that it will clip. A new Settings > Forge toggle, "Auto-trim resample overshoot", opts in to the gentlest reversible fix (a single linear gain to full scale), reported when applied. 32-bit float targets are unaffected (lossless passthrough).
11 11
12 12 ### Added
13 + - Starter filename rules. "Add starter rules" in Tag Rules seeds a pack covering the common drum, instrument-family and format words, so `Kick.wav` becomes `instrument.drum.kick` and `Guitar Loop 120.wav` becomes `instrument.guitar` plus `type.loop`. The filename is a far better source of instrument identity than the audio: the same names our corpus script reads labelled 1,049 of 1,074 one-shots correctly, where the retired DSP classifier managed 33.4% on those exact files. Rules arrive disabled and fully editable, so nothing is tagged until you enable them. A name that hits two drum classes (`Kick_Cowbell.wav`) gets neither tag rather than one picked at random.
13 14 - Browse by measured axes: Brightness (spectral centroid), Tonal / Noisy (spectral flatness) and Attack join BPM, duration and loudness as numeric range filters in the filter panel. Continuous rather than binned, because a fixed "bright" cut does not transfer between one-shots and loops. These are what replaced the sample-class filter, and unlike a label they cannot be wrong: there is no classification step to be right or wrong about, only the measurement. Samples analysed before the spectral stage existed have no value on these axes and drop out of a bounded query.
14 15
15 16 ### Removed
M README.md +1
@@ -92,6 +92,7 @@
92 92 |------|-------|
93 93 | Domain library | `crates/audiofiles-core/src/` |
94 94 | Tag rules (Layer A) | `crates/audiofiles-core/src/rules.rs` |
95 + | Starter filename rule pack | `crates/audiofiles-core/src/starter_rules.rs` |
95 96 | Benchmarks + corpus builder | `crates/audiofiles-bench/`, `scripts/corpus.py` |
96 97 | UI components | `crates/audiofiles-browser/src/` |
97 98 | Desktop app shell | `crates/audiofiles-app/src/` |
@@ -84,6 +84,7 @@
84 84 - **Classification**: Deterministic DSP only, and multi-label. `analysis/features.rs` assembles the 35-feature vector (9 spectral/waveform + 26 MFCC), persists it to `sample_features`, and the layered tag pipeline reads it (rules, exemplar k-NN, optional trained head, `.afcl` layers). The single-label `SampleClass` and the threshold tree behind it were removed: 33.4% strict accuracy with two classes unreachable, and the features carry family structure rather than instrument identity. Nothing model-derived ships in the binary today; the accepted plan is to bundle an official `.afcl` layer built from a CC-BY 4.0 corpus, with attribution carried in the manifest. See `ml_classifier.md`.
85 85 - **Loop detection**: Identifies whether a sample is a seamless loop.
86 86 - **Fingerprinting**: Computes an amplitude envelope fingerprint for near-duplicate detection across the library.
87 + - **Starter rules**: `core/src/starter_rules.rs` seeds Layer A with filename keyword rules (disabled until enabled), including the guard rules that suppress an ambiguous layered-hit name. See `ml_classifier.md`.
87 88 - **Browse axes**: The filter panel's numeric ranges (BPM, duration, loudness, spectral centroid, spectral flatness, attack time) all run through one `RangeAxis` table in `ui/filter_panel.rs` and one `append_filter_clauses` pass in `core/search.rs`. The spectral three are the browse dimensions that replaced the sample-class filter; see `ml_classifier.md`.
88 89 - **Tag suggestion**: Generates tag suggestions from analysis results (BPM range, key, duration bracket, loudness) with confidence scores and human-readable reasons.
89 90
@@ -76,7 +76,8 @@
76 76 - **Browsing** by continuous measured axes (register, length, tonal vs noisy, attack).
77 77 Nothing to misclassify, no thresholds to tune.
78 78 - **Instrument names** from filename rules (Layer A over `RuleField::Name`) plus the
79 - user's own tags. Our own ground truth came from filenames: `corpus.py` labelled 1,049 of
79 + user's own tags. `starter_rules.rs` ships the vocabulary as a seedable pack, off
80 + until enabled; see "The starter filename pack" below. Our own ground truth came from filenames: `corpus.py` labelled 1,049 of
80 81 1,074 one-shots that way, 97.7%, on the same files the tree scored 33.4% on. Real packs
81 82 name their files `Kick.wav`.
82 83 - **"More like this"** by the existing k-NN over the 35-feature vector.
@@ -92,6 +93,35 @@
92 93 saved search that filtered on the class silently loses that criterion (`SearchFilter` is
93 94 `#[serde(default)]`, so the stored key deserializes into nothing).
94 95
96 + ## The starter filename pack
97 +
98 + `crates/audiofiles-core/src/starter_rules.rs` carries `corpus.py`'s keyword
99 + vocabulary into Layer A as ordinary rules. "Add starter rules" in Tag Rules seeds
100 + them; they arrive **disabled**, matching imported `.afcl` rules, and are editable
101 + like any other rule. Seeding is idempotent (it skips rules already present by name),
102 + so the button is safe to press twice.
103 +
104 + Two details worth knowing:
105 +
106 + - **Tags are specific, families come free.** `instrument.drum.kick`, not
107 + `instrument.drum` plus `instrument.drum.kick`, because tag search is prefix-based:
108 + a filter on `instrument.drum` already matches everything below it.
109 + - **A name that hits two drum classes gets neither tag.** `corpus.py` drops
110 + `Kick_Cowbell.wav` rather than resolving by rule order, and that carries over.
111 + It takes two passes because `MatchMode` is per-rule, so "any of these keywords AND
112 + none of those" is not one rule: each drum class has a keyword rule that adds its
113 + tag, then a guard rule that removes that tag when another drum class's keyword is
114 + also present. `RemoveTag` suppresses an earlier add in the same pass. Only the drum
115 + classes guard each other; "Bass Guitar Loop" being both bass and guitar is correct.
116 +
117 + The two-letter abbreviations from `corpus.py` (` bd`, `_sd`, ` hh`) are deliberately
118 + not in the pack. They were safe against one specific set of drum-machine packs, where
119 + surrounding whitespace disambiguated them, and are false-positive bait against a
120 + library this has never seen.
121 +
122 + Unmeasured, and the honest gap: how the pack degrades on a badly named library. That
123 + is exactly where the k-NN layer should carry the load instead.
124 +
95 125 ## What ships in the binary, and under what licence
96 126
97 127 Today: no model and no third-party data. Every number the classifier uses is either
@@ -61,6 +61,7 @@
61 61 pub mod rules;
62 62 pub mod search;
63 63 pub mod similarity;
64 + pub mod starter_rules;
64 65 pub mod store;
65 66 pub mod tags;
66 67 pub mod util;
@@ -543,6 +543,10 @@
543 543 /// Re-apply all rules across the library; returns the number of samples changed.
544 544 fn apply_all_rules(&self) -> BackendResult<usize>;
545 545
546 + /// Insert the starter filename-rule pack, disabled, skipping rules already
547 + /// present by name. Returns how many were created (0 when already seeded).
548 + fn seed_starter_rules(&self) -> BackendResult<usize>;
549 +
546 550 /// Provenance of each machine-applied tag on a sample: `(tag, source, rule_id)`.
547 551 /// Tags absent here (but present on the sample) are manual.
548 552 fn sample_tag_provenance(
@@ -189,6 +189,24 @@
189 189 }
190 190 }
191 191
192 + /// Seed the starter filename-rule pack. Idempotent, and the rules arrive
193 + /// disabled, so this is safe to click twice and changes no tags on its own.
194 + pub fn classifier_seed_starter_rules(&mut self) {
195 + match self.backend.seed_starter_rules() {
196 + Ok(0) => {
197 + self.status = "Starter rules are already in the list".to_string();
198 + }
199 + Ok(n) => {
200 + self.refresh_rules();
201 + self.status = format!(
202 + "Added {n} starter rule{}, disabled. Review, then enable the ones you want.",
203 + if n == 1 { "" } else { "s" }
204 + );
205 + }
206 + Err(e) => self.status = format!("Could not add starter rules: {e}"),
207 + }
208 + }
209 +
192 210 // Layer B: k-NN auto-tagging
193 211
194 212 /// Auto-apply above-threshold suggestions across the whole library, runs on a worker
@@ -225,6 +225,17 @@
225 225 {
226 226 state.classifier_apply_all();
227 227 }
228 + if ui
229 + .button("Add starter rules")
230 + .on_hover_text(
231 + "Add filename rules for the common instrument and format words \
232 + (Kick.wav -> instrument.drum.kick). They arrive disabled: review \
233 + them, then enable the ones you want.",
234 + )
235 + .clicked()
236 + {
237 + state.classifier_seed_starter_rules();
238 + }
228 239 });
229 240
230 241 if let Some(n) = state.classifier.last_apply {
@@ -87,6 +87,11 @@
87 87 Ok(audiofiles_core::rules::apply_all_rules(&db)?)
88 88 }
89 89
90 + fn seed_starter_rules(&self) -> BackendResult<usize> {
91 + let db = self.db.lock();
92 + Ok(audiofiles_core::starter_rules::seed(&db)?)
93 + }
94 +
90 95 fn sample_tag_provenance(
91 96 &self,
92 97 hash: &str,
@@ -1,0 +1,417 @@
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 + }