Skip to main content

max / audiofiles

Make impact, ambience, foley and texture filterable classify_full assigns all four, but neither of the two places that write down the class list knew about them. The filter panel offered no button, so samples the classifier had labelled could not be selected at all, and classification_color had no arm, so they rendered in the fallback grey and were indistinguishable from an unrecognised value. detail.rs already handled all four, which is what made the omission visible. The cause is that the filter list and the colour table are two hand-written lists of the same classes with nothing holding them together. The list moves out of the draw closure into CLASS_GROUPS, and a test now asserts every filterable class has a colour of its own, so the next class added to one has to appear in the other.
Author: Max Johnson <me@maxj.phd> · 2026-07-29 18:02 UTC
Signed with PGP, not checked
Commit: a436e6319d00f0fda372ed7f7c754e838fd14144
Parent: 72708ea
2 files changed, +59 insertions, -10 deletions
@@ -6,6 +6,25 @@
6 6 use super::widgets;
7 7 use crate::state::BrowserState;
8 8
9 + /// Classification filter buttons, grouped: drums (percussive), tonal (pitched),
10 + /// other (non-instrument). The old hard-coded flat order buried `music` and
11 + /// `noise` at the tail; grouping gives a scannable left-to-right reading
12 + /// without alphabetising away the drum-first reflex (p-1).
13 + ///
14 + /// This must cover every class the analysis pipeline can assign. It previously
15 + /// omitted `impact`, `ambience`, `foley` and `texture`, so four classes the
16 + /// classifier actively produced could not be filtered for at all. Lifted out of
17 + /// the draw closure so a test can hold it against the colour table, which is
18 + /// the other place the class list is written down and the one it drifted from.
19 + pub(super) const CLASS_GROUPS: &[(&str, &[&str])] = &[
20 + ("Drums", &["kick", "snare", "hihat", "cymbal", "percussion"]),
21 + ("Tonal", &["bass", "vocal", "synth", "pad", "music"]),
22 + (
23 + "Other",
24 + &["impact", "ambience", "foley", "texture", "misc", "noise"],
25 + ),
26 + ];
27 +
9 28 /// Debounce numeric range filters: re-run the search only when a DragValue
10 29 /// drag finishes or the field loses keyboard focus, not on every intermediate
11 30 /// tick. The filter value still updates live on `.changed()` (so the widget
@@ -190,16 +209,7 @@
190 209 state.search.search_filter.classifications.clear();
191 210 changed = true;
192 211 }
193 - // Grouped: drums (percussive) | tonal (pitched) | other. The hard-coded
194 - // flat order made `music` and `noise` invisible at the tail; grouping
195 - // gives a scannable left-to-right reading without alphabetising away
196 - // the drum-first reflex (p-1).
197 - let groups: &[(&str, &[&str])] = &[
198 - ("Drums", &["kick", "snare", "hihat", "cymbal", "percussion"]),
199 - ("Tonal", &["bass", "vocal", "synth", "pad", "music"]),
200 - ("Other", &["misc", "noise"]),
201 - ];
202 - for (label, classes) in groups {
212 + for (label, classes) in CLASS_GROUPS {
203 213 ui.label(
204 214 egui::RichText::new(*label)
205 215 .small()
@@ -430,6 +440,37 @@
430 440 mod tests {
431 441 use super::*;
432 442
443 + #[test]
444 + fn every_filterable_class_has_its_own_colour() {
445 + // The bug this guards: the filter list and the colour table are two
446 + // separate hand-written lists of the same classes, and they drifted.
447 + // impact/ambience/foley/texture were in neither, so the classifier
448 + // produced labels that could not be filtered and rendered in the
449 + // fallback grey, indistinguishable from an unrecognised value.
450 + let fallback = theme::content_secondary();
451 + for (group, classes) in CLASS_GROUPS {
452 + for class in *classes {
453 + assert_ne!(
454 + theme::classification_color(class),
455 + fallback,
456 + "class '{class}' in group '{group}' has no colour of its own"
457 + );
458 + }
459 + }
460 + }
461 +
462 + #[test]
463 + fn class_groups_have_no_duplicates() {
464 + let mut seen: Vec<&str> = CLASS_GROUPS
465 + .iter()
466 + .flat_map(|(_, c)| c.iter().copied())
467 + .collect();
468 + let before = seen.len();
469 + seen.sort_unstable();
470 + seen.dedup();
471 + assert_eq!(before, seen.len(), "a class appears in more than one group");
472 + }
473 +
433 474 #[test]
434 475 fn range_bounds_treats_sentinel_edges_as_unbounded() {
435 476 // BPM: 0 = no lower bound, 300 = no upper bound.
@@ -821,6 +821,14 @@
821 821 "misc" => Color32::from_rgb(0xE0, 0x60, 0x80),
822 822 "noise" => Color32::from_rgb(0x90, 0x90, 0x90),
823 823 "music" => Color32::from_rgb(0x70, 0xC0, 0xA0),
824 + // Non-instrument classes, kept deliberately muted so they read as a
825 + // distinct family from the instrument hues above. Without these arms
826 + // they fell through to the fallback and were indistinguishable from an
827 + // unrecognised value, despite the classifier assigning all four.
828 + "impact" => Color32::from_rgb(0xC0, 0x50, 0x40),
829 + "ambience" => Color32::from_rgb(0x60, 0x90, 0xA8),
830 + "foley" => Color32::from_rgb(0xA8, 0x90, 0x60),
831 + "texture" => Color32::from_rgb(0x88, 0x70, 0xA8),
824 832 _ => content_secondary(),
825 833 }
826 834 }