Skip to main content

max / audiofiles

Browse by measured axes: brightness, noisiness, attack The three spectral axes join BPM, duration and loudness as numeric range filters. They are what replaced the sample-class filter, and the point is that a measurement cannot be misclassified: there is no label, no threshold and no confusion matrix, only the value analysis already computed. Continuous, not binned. Fixed breakpoints do not transfer across material: kick/tom one-shots sit 48% low / 43% lowmid / 0% high on centroid, reverb loops 71% high, FSL10K loops 16/22/40/22. A bright one-shot and a bright loop are nowhere near each other, so any binning has to be relative to the library. Titled perceptually, valued in the real unit: "Brightness" is how the axis is described, Hz is what the query compares. A bare 0-100 brightness score would be a made-up number, and Hz in the title reads as an instrument spec. The six range sections were six copies of the same 45 lines differing only in constants, which is exactly how the class filter's list and its colour table drifted apart, so the geometry is a RangeAxis table now and every axis goes through one function. M036 indexes the three columns, same reasoning as M028. A sample analysed before the spectral stage existed has NULL on these columns and drops out of a bounded query: unknown is not "within range".
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-30 13:50 UTC
Signed with PGP, not checked
Commit: 261e567f5dc9eab95793ce43db8d65336e462acc
Parent: af92b80
7 files changed, +463 insertions, -144 deletions
@@ -9,6 +9,9 @@
9 9 ### Added
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 + ### Added
13 + - 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 +
12 15 ### Removed
13 16 - The single sample-class label, and everything that consumed it. Measured at 33.4% accuracy on exact class match, with two of its seven drum classes unreachable by any rule; the features it read carry coarse family structure, not instrument identity. Browsing moves to the measured axes, instrument names to filename rules and your own tags, and "more like this" stays on k-NN. Gone with it: the Class column and its sort, the class filter checkboxes, the class badge, the `{class}` rename token, the `classification` field in the export sidecar, the per-class tag suggestions and their dismissal list, and the `classification` / `classification_confidence` database columns. A saved rename pattern containing `{class}` reports an unknown token rather than quietly dropping it, and a saved search that filtered on the class loses that criterion.
14 17
@@ -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 + - **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`.
87 88 - **Tag suggestion**: Generates tag suggestions from analysis results (BPM range, key, duration bracket, loudness) with confidence scores and human-readable reasons.
88 89
89 90 Analysis runs in a background worker thread using rayon for parallel processing. A configurable analysis cap (`max_analysis_seconds`, default 30s) limits expensive operations (STFT, BPM/key) to the first N seconds of audio while cheap operations (peak/RMS, fingerprint) use the full signal. An `AtomicBool` cancel flag allows interrupting in-flight parallel work.
@@ -65,7 +65,8 @@
65 65 | `crest_factor` | REAL | Migration 010, nullable |
66 66 | `attack_time` | REAL | Migration 010, nullable |
67 67
68 - Indexes: `bpm`, `musical_key`, `duration`.
68 + Indexes: `bpm`, `musical_key`, `duration`, `spectral_centroid`, `spectral_flatness`,
69 + `attack_time`. The last three back the measured browse axes (M036).
69 70
70 71 `classification` (M003) and `classification_confidence` (M011) held the single
71 72 sample-class label. Both are gone, along with the classifier that produced them; see
@@ -291,7 +292,7 @@
291 292 ## Key Indexes
292 293
293 294 - **Content lookup** on samples (original_name), tags (sample_hash, tag)
294 - - **Analysis** on audio_analysis (bpm, musical_key, duration)
295 + - **Analysis** on audio_analysis (bpm, musical_key, duration, spectral_centroid, spectral_flatness, attack_time)
295 296 - **VFS tree** on vfs_nodes (parent_id, vfs_id, sample_hash)
296 297 - **History** on edit_history (source_hash, result_hash)
297 298 - **Sync** on sync_changelog (pushed) for pending push detection
@@ -323,3 +324,4 @@
323 324 | 021 | `tag_rules` table (deterministic tag rules, Layer A) + sync triggers |
324 325 | 022 | `tag_provenance` table (manual-sticky tag attribution) + sync triggers |
325 326 | 023 | `tag_policy` table (per-tag k-NN review/auto thresholds) + sync triggers |
327 + | 036 | Indexes on `spectral_centroid`, `spectral_flatness`, `attack_time` for the measured browse axes |
@@ -73,7 +73,7 @@
73 73
74 74 ### Search + Filtering
75 75 - Text search by sample name (folder or global scope, 500 result limit)
76 - - Filter panel: BPM range, duration range, loudness range, key selector (with compatibility mode), tag prefix filter
76 + - Filter panel: BPM range, duration range, loudness range, brightness / noisiness / attack ranges, key selector (with compatibility mode), tag prefix filter
77 77 - Filters combine with AND logic
78 78 - `/` to focus search bar
79 79 - Similarity search: weighted Euclidean distance on analysis vectors ("Find Similar" in context menu)
@@ -1719,6 +1719,16 @@
1719 1719 DROP TABLE legacy_key_tags;
1720 1720 ";
1721 1721
1722 + const MIGRATION_036: &str = r"
1723 + -- Indexes for the measured browse axes, same reasoning as M028: a range filter
1724 + -- with no text query otherwise full-scans audio_analysis, and these three are
1725 + -- now first-class browse dimensions rather than columns nothing queried.
1726 + -- Additive and idempotent.
1727 + CREATE INDEX IF NOT EXISTS idx_analysis_spectral_centroid ON audio_analysis(spectral_centroid);
1728 + CREATE INDEX IF NOT EXISTS idx_analysis_spectral_flatness ON audio_analysis(spectral_flatness);
1729 + CREATE INDEX IF NOT EXISTS idx_analysis_attack_time ON audio_analysis(attack_time);
1730 + ";
1731 +
1722 1732 /// Register `hash_row_id(salt, key) -> TEXT` as a deterministic SQLite
1723 1733 /// function on the given connection. Used by the M018 sync triggers so the
1724 1734 /// `sync_changelog.row_id` field never carries cleartext content (tag strings,
@@ -1908,6 +1918,7 @@
1908 1918 MIGRATION_033,
1909 1919 MIGRATION_034,
1910 1920 MIGRATION_035,
1921 + MIGRATION_036,
1911 1922 ];
1912 1923
1913 1924 for (i, sql) in MIGRATIONS.iter().enumerate() {
@@ -2334,7 +2345,7 @@
2334 2345 .conn()
2335 2346 .query_row("PRAGMA user_version", [], |row| row.get(0))
2336 2347 .unwrap();
2337 - assert_eq!(version, 35);
2348 + assert_eq!(version, 36);
2338 2349 }
2339 2350
2340 2351 #[test]
@@ -2345,7 +2356,7 @@
2345 2356 .conn()
2346 2357 .query_row("PRAGMA user_version", [], |row| row.get(0))
2347 2358 .unwrap();
2348 - assert_eq!(version, 35);
2359 + assert_eq!(version, 36);
2349 2360 }
2350 2361
2351 2362 #[test]
@@ -2529,7 +2540,7 @@
2529 2540 .conn()
2530 2541 .query_row("PRAGMA user_version", [], |row| row.get(0))
2531 2542 .unwrap();
2532 - assert_eq!(version, 35);
2543 + assert_eq!(version, 36);
2533 2544 }
2534 2545
2535 2546 /// Simulates the worst-case recovery path: a prior partial migration left
@@ -2573,7 +2584,7 @@
2573 2584 .conn()
2574 2585 .query_row("PRAGMA user_version", [], |row| row.get(0))
2575 2586 .unwrap();
2576 - assert_eq!(version, 35);
2587 + assert_eq!(version, 36);
2577 2588 }
2578 2589
2579 2590 /// M018 contract: the `sync_changelog.row_id` for sensitive tables must
@@ -2803,7 +2814,7 @@
2803 2814 let initial_version: i32 = conn
2804 2815 .query_row("PRAGMA user_version", [], |row| row.get(0))
2805 2816 .unwrap();
2806 - assert_eq!(initial_version, 35);
2817 + assert_eq!(initial_version, 36);
2807 2818
2808 2819 let batch = format!("BEGIN;\n{bad_sql}\nPRAGMA user_version = 999;\nCOMMIT;");
2809 2820 let first_err = conn.execute_batch(&batch).unwrap_err();
@@ -2868,7 +2879,7 @@
2868 2879 .conn()
2869 2880 .query_row("PRAGMA user_version", [], |row| row.get(0))
2870 2881 .unwrap();
2871 - assert_eq!(version, 35);
2882 + assert_eq!(version, 36);
2872 2883 }
2873 2884
2874 2885 #[test]
@@ -1,4 +1,5 @@
1 - //! Search query builder: text search, filter by BPM/key/duration/loudness/tags.
1 + //! Search query builder: text search, filter by BPM/key/duration/loudness, the
2 + //! measured spectral axes, and tags.
2 3
3 4 use crate::db::Database;
4 5 use crate::error::Result;
@@ -43,6 +44,16 @@
43 44 pub duration_max: Option<f64>,
44 45 pub peak_db_min: Option<f64>,
45 46 pub peak_db_max: Option<f64>,
47 + /// Measured spectral axes, the browse dimensions that replaced the sample-class
48 + /// filter. Ranges over `audio_analysis` columns that analysis already computes:
49 + /// brightness is `spectral_centroid` (Hz), tonal-vs-noisy is `spectral_flatness`
50 + /// (0..1), attack is `attack_time` (seconds). Detail: wiki af-browse-axes.
51 + pub centroid_min: Option<f64>,
52 + pub centroid_max: Option<f64>,
53 + pub flatness_min: Option<f64>,
54 + pub flatness_max: Option<f64>,
55 + pub attack_min: Option<f64>,
56 + pub attack_max: Option<f64>,
46 57 pub required_tags: Vec<String>,
47 58 pub scope: SearchScope,
48 59 }
@@ -59,6 +70,12 @@
59 70 duration_max: None,
60 71 peak_db_min: None,
61 72 peak_db_max: None,
73 + centroid_min: None,
74 + centroid_max: None,
75 + flatness_min: None,
76 + flatness_max: None,
77 + attack_min: None,
78 + attack_max: None,
62 79 required_tags: Vec::new(),
63 80 scope: SearchScope::CurrentFolder,
64 81 }
@@ -76,6 +93,12 @@
76 93 || self.duration_max.is_some()
77 94 || self.peak_db_min.is_some()
78 95 || self.peak_db_max.is_some()
96 + || self.centroid_min.is_some()
97 + || self.centroid_max.is_some()
98 + || self.flatness_min.is_some()
99 + || self.flatness_max.is_some()
100 + || self.attack_min.is_some()
101 + || self.attack_max.is_some()
79 102 || !self.required_tags.is_empty()
80 103 }
81 104
@@ -91,6 +114,15 @@
91 114 if self.peak_db_min.is_some() || self.peak_db_max.is_some() {
92 115 n += 1;
93 116 }
117 + if self.centroid_min.is_some() || self.centroid_max.is_some() {
118 + n += 1;
119 + }
120 + if self.flatness_min.is_some() || self.flatness_max.is_some() {
121 + n += 1;
122 + }
123 + if self.attack_min.is_some() || self.attack_max.is_some() {
124 + n += 1;
125 + }
94 126 if !self.keys.is_empty() {
95 127 n += 1;
96 128 }
@@ -133,6 +165,33 @@
133 165 } else if let Some(max) = self.peak_db_max {
134 166 parts.push(format!("<{max:.0} dB"));
135 167 }
168 + // The measured axes describe as a named range rather than a bare number:
169 + // "1000-4000 Hz" in a collection name says nothing, "bright 1000-4000 Hz"
170 + // says which axis is being cut.
171 + describe_range(
172 + &mut parts,
173 + "bright",
174 + self.centroid_min,
175 + self.centroid_max,
176 + 0,
177 + " Hz",
178 + );
179 + describe_range(
180 + &mut parts,
181 + "noisy",
182 + self.flatness_min,
183 + self.flatness_max,
184 + 2,
185 + "",
186 + );
187 + describe_range(
188 + &mut parts,
189 + "attack",
190 + self.attack_min,
191 + self.attack_max,
192 + 3,
193 + "s",
194 + );
136 195 if !self.keys.is_empty() {
137 196 parts.push(self.keys.join(", "));
138 197 }
@@ -154,6 +213,25 @@
154 213 }
155 214 }
156 215
216 + /// Append a `<name> <min>-<max><unit>` fragment for one measured axis, in whichever
217 + /// of the four shapes the bounds take (both, min only, max only, neither).
218 + fn describe_range(
219 + parts: &mut Vec<String>,
220 + name: &str,
221 + min: Option<f64>,
222 + max: Option<f64>,
223 + decimals: usize,
224 + unit: &str,
225 + ) {
226 + let n = decimals;
227 + match (min, max) {
228 + (Some(lo), Some(hi)) => parts.push(format!("{name} {lo:.n$}-{hi:.n$}{unit}")),
229 + (Some(lo), None) => parts.push(format!("{name} >{lo:.n$}{unit}")),
230 + (None, Some(hi)) => parts.push(format!("{name} <{hi:.n$}{unit}")),
231 + (None, None) => {}
232 + }
233 + }
234 +
157 235 /// Search within a specific VFS folder.
158 236 #[instrument(skip_all)]
159 237 pub fn search_in_folder(
@@ -331,6 +409,25 @@
331 409 params.push(Box::new(peak_max));
332 410 }
333 411
412 + // Measured spectral axes. Plain range comparisons on columns analysis already
413 + // wrote, so there is nothing to be wrong about: no label, no threshold, no
414 + // confusion matrix. A sample with the column NULL (analysed before the feature
415 + // existed, or with the spectral stage off) drops out of a bounded query, which
416 + // is correct: unknown is not "within range".
417 + for (column, bound, op) in [
418 + ("a.spectral_centroid", filter.centroid_min, ">="),
419 + ("a.spectral_centroid", filter.centroid_max, "<="),
420 + ("a.spectral_flatness", filter.flatness_min, ">="),
421 + ("a.spectral_flatness", filter.flatness_max, "<="),
422 + ("a.attack_time", filter.attack_min, ">="),
423 + ("a.attack_time", filter.attack_max, "<="),
424 + ] {
425 + if let Some(value) = bound {
426 + let _ = write!(sql, " AND {column} {op} ?{}", params.len() + 1);
427 + params.push(Box::new(value));
428 + }
429 + }
430 +
334 431 if !filter.keys.is_empty() {
335 432 use std::collections::HashSet;
336 433 let expanded: Vec<String> = match filter.key_mode {
@@ -659,6 +756,173 @@
659 756 assert_eq!(results[0].node.name, "pad_90.wav");
660 757 }
661 758
759 + /// Insert a sample carrying the three measured browse axes. The shared helper
760 + /// only populates BPM/key/duration, and these tests are specifically about the
761 + /// spectral columns, including the NULL case.
762 + fn insert_sample_with_axes(
763 + db: &Database,
764 + hash: &str,
765 + name: &str,
766 + vfs_id: VfsId,
767 + axes: Option<(f64, f64, f64)>,
768 + ) {
769 + crate::test_helpers::insert_fake_sample(db, hash);
770 + let (centroid, flatness, attack) = match axes {
771 + Some((c, f, a)) => (Some(c), Some(f), Some(a)),
772 + None => (None, None, None),
773 + };
774 + let result = crate::analysis::AnalysisResult {
775 + hash: hash.to_string(),
776 + duration: 1.0,
777 + sample_rate: 44100,
778 + channels: 1,
779 + peak_db: None,
780 + rms_db: None,
781 + lufs: None,
782 + bpm: None,
783 + musical_key: None,
784 + is_loop: None,
785 + spectral_centroid: centroid,
786 + spectral_flatness: flatness,
787 + spectral_rolloff: None,
788 + zero_crossing_rate: None,
789 + onset_strength: None,
790 + fingerprint: None,
791 + spectral_bandwidth: None,
792 + centroid_variance: None,
793 + crest_factor: None,
794 + attack_time: attack,
795 + feature_vector: None,
796 + feature_version: None,
797 + };
798 + crate::analysis::save_analysis_batch(db, std::slice::from_ref(&result)).unwrap();
799 + vfs::create_sample_link(
800 + db,
801 + vfs_id,
802 + None,
803 + name,
804 + &crate::SampleHash::from_trusted(hash),
805 + )
806 + .unwrap();
807 + }
808 +
809 + /// A bright noisy fast-attack hit, a dark tonal slow swell, and one sample the
810 + /// spectral stage never ran on.
811 + fn setup_with_axes() -> (Database, VfsId) {
812 + let db = Database::open_in_memory().unwrap();
813 + let vfs_id = vfs::create_vfs(&db, "Test").unwrap();
814 + insert_sample_with_axes(&db, "bright", "hat.wav", vfs_id, Some((6000.0, 0.6, 0.002)));
815 + insert_sample_with_axes(&db, "dark", "pad.wav", vfs_id, Some((400.0, 0.05, 0.4)));
816 + insert_sample_with_axes(&db, "unanalysed", "unknown.wav", vfs_id, None);
817 + (db, vfs_id)
818 + }
819 +
820 + #[test]
821 + fn brightness_filter_cuts_on_centroid() {
822 + let (db, vfs_id) = setup_with_axes();
823 + let filter = SearchFilter {
824 + centroid_min: Some(2500.0),
825 + ..Default::default()
826 + };
827 + let results = search_in_folder(&db, &filter, vfs_id, None).unwrap();
828 + assert_eq!(results.len(), 1);
829 + assert_eq!(results[0].node.name, "hat.wav");
830 +
831 + // The other end of the same axis.
832 + let filter = SearchFilter {
833 + centroid_max: Some(2500.0),
834 + ..Default::default()
835 + };
836 + let results = search_in_folder(&db, &filter, vfs_id, None).unwrap();
837 + assert_eq!(results.len(), 1);
838 + assert_eq!(results[0].node.name, "pad.wav");
839 + }
840 +
841 + #[test]
842 + fn noisiness_and_attack_filters_cut_independently() {
843 + let (db, vfs_id) = setup_with_axes();
844 +
845 + let noisy = SearchFilter {
846 + flatness_min: Some(0.3),
847 + ..Default::default()
848 + };
849 + let results = search_in_folder(&db, &noisy, vfs_id, None).unwrap();
850 + assert_eq!(results.len(), 1);
851 + assert_eq!(results[0].node.name, "hat.wav");
852 +
853 + let slow = SearchFilter {
854 + attack_min: Some(0.1),
855 + ..Default::default()
856 + };
857 + let results = search_in_folder(&db, &slow, vfs_id, None).unwrap();
858 + assert_eq!(results.len(), 1);
859 + assert_eq!(results[0].node.name, "pad.wav");
860 + }
861 +
862 + #[test]
863 + fn axes_compose_and_can_exclude_everything() {
864 + let (db, vfs_id) = setup_with_axes();
865 + // Bright AND noisy AND fast: the hat only.
866 + let filter = SearchFilter {
867 + centroid_min: Some(2500.0),
868 + flatness_min: Some(0.3),
869 + attack_max: Some(0.01),
870 + ..Default::default()
871 + };
872 + let results = search_in_folder(&db, &filter, vfs_id, None).unwrap();
873 + assert_eq!(results.len(), 1);
874 + assert_eq!(results[0].node.name, "hat.wav");
875 +
876 + // Bright AND tonal describes neither sample, and the axes AND together, so
877 + // this must return nothing rather than falling back to either half.
878 + let filter = SearchFilter {
879 + centroid_min: Some(2500.0),
880 + flatness_max: Some(0.1),
881 + ..Default::default()
882 + };
883 + assert!(
884 + search_in_folder(&db, &filter, vfs_id, None)
885 + .unwrap()
886 + .is_empty()
887 + );
888 + }
889 +
890 + #[test]
891 + fn unmeasured_samples_drop_out_of_a_bounded_axis() {
892 + // A sample whose spectral columns are NULL (imported before the feature, or
893 + // analysed with the spectral stage off) must not appear in a bounded query:
894 + // unknown is not "within range". It still appears with no axis filter.
895 + let (db, vfs_id) = setup_with_axes();
896 + let all = search_in_folder(&db, &SearchFilter::default(), vfs_id, None).unwrap();
897 + assert_eq!(all.len(), 3);
898 +
899 + let filter = SearchFilter {
900 + centroid_min: Some(0.5),
901 + ..Default::default()
902 + };
903 + let bounded = search_in_folder(&db, &filter, vfs_id, None).unwrap();
904 + assert!(!bounded.iter().any(|r| r.node.name == "unknown.wav"));
905 + }
906 +
907 + #[test]
908 + fn measured_axes_count_and_describe() {
909 + let filter = SearchFilter {
910 + centroid_min: Some(1000.0),
911 + centroid_max: Some(4000.0),
912 + flatness_min: Some(0.3),
913 + attack_max: Some(0.01),
914 + ..Default::default()
915 + };
916 + assert!(filter.is_active());
917 + // Three axes, three categories.
918 + assert_eq!(filter.active_count(), 3);
919 + let described = filter.describe();
920 + assert_eq!(
921 + described,
922 + "bright 1000-4000 Hz | noisy >0.30 | attack <0.010s"
923 + );
924 + }
925 +
662 926 #[test]
663 927 fn tag_filter() {
664 928 let (db, vfs_id) = setup_with_analysis();
@@ -1,4 +1,5 @@
1 - //! Filter panel: BPM range, key selector, duration range, loudness range, tag filters.
1 + //! Filter panel: numeric range axes (BPM, duration, loudness, and the measured
2 + //! spectral axes), key selector, tag filters.
2 3
3 4 use egui;
4 5
@@ -25,6 +26,90 @@
25 26 (lower, upper)
26 27 }
27 28
29 + /// A numeric range filter's fixed geometry: the sentinel edges, the drag speed,
30 + /// and the unit shown in the field.
31 + struct RangeAxis {
32 + /// Section title. Names the axis the way a producer would say it; the unit in
33 + /// `suffix` keeps the number honest.
34 + title: &'static str,
35 + /// Hover text, for axes whose title is perceptual rather than physical.
36 + hint: Option<&'static str>,
37 + lo: f64,
38 + hi: f64,
39 + speed: f64,
40 + suffix: &'static str,
41 + /// Decimal places in the field. Hz wants 0, a 0..1 ratio wants 2.
42 + decimals: usize,
43 + }
44 +
45 + /// Draw one numeric range filter: a collapsing section, min/max drag fields with
46 + /// sentinel edges, a sibling snap so `min <= max` always holds, and a per-section
47 + /// clear. Returns true when the search needs re-running.
48 + ///
49 + /// Every range axis in the panel goes through here. They were six copies of the
50 + /// same 45 lines differing only in constants, which is how the class filter's
51 + /// list and colour table drifted apart, so the axis geometry is data now.
52 + fn range_filter_section(
53 + ui: &mut egui::Ui,
54 + axis: &RangeAxis,
55 + min_field: &mut Option<f64>,
56 + max_field: &mut Option<f64>,
57 + ) -> bool {
58 + let active = min_field.is_some() || max_field.is_some();
59 + let mut changed = false;
60 +
61 + widgets::filter_section(ui, axis.title, active, |ui| {
62 + if let Some(hint) = axis.hint {
63 + ui.label(
64 + egui::RichText::new(hint)
65 + .small()
66 + .color(theme::content_muted()),
67 + );
68 + }
69 + if active && draw_section_clear(ui) {
70 + *min_field = None;
71 + *max_field = None;
72 + changed = true;
73 + }
74 + ui.horizontal(|ui| {
75 + let mut min = min_field.unwrap_or(axis.lo);
76 + let mut max = max_field.unwrap_or(axis.hi);
77 + // A closure returning the DragValue would have to name the borrow of
78 + // `value` in its return type, which a closure cannot do, so the builder
79 + // is spelled out at both call sites.
80 + fn field<'a>(value: &'a mut f64, axis: &RangeAxis) -> egui::DragValue<'a> {
81 + egui::DragValue::new(value)
82 + .speed(axis.speed)
83 + .range(axis.lo..=axis.hi)
84 + .max_decimals(axis.decimals)
85 + .suffix(axis.suffix)
86 + }
87 +
88 + ui.label("Min");
89 + let r = ui.add(field(&mut min, axis));
90 + if r.changed() {
91 + if min > max {
92 + max = min;
93 + }
94 + (*min_field, *max_field) = range_bounds(min, max, axis.lo, axis.hi);
95 + }
96 + changed |= requery_now(&r);
97 +
98 + ui.label("Max");
99 + let r = ui.add(field(&mut max, axis));
100 + if r.changed() {
101 + if max < min {
102 + min = max;
103 + }
104 + (*min_field, *max_field) = range_bounds(min, max, axis.lo, axis.hi);
105 + }
106 + changed |= requery_now(&r);
107 + });
108 + });
109 +
110 + changed
111 + }
112 +
28 113 /// Render the per-section "[clear]" mini-button used by every active filter
29 114 /// section. Returns true on click. The wrapping `if active` lives at the call
30 115 /// site so each section's clear semantics stay local.
@@ -48,141 +133,94 @@
48 133
49 134 let mut changed = false;
50 135
51 - // BPM range filter. Boundaries 0 and 300 serve as "no filter" sentinels:
52 - // when min is 0 or max is 300, the corresponding filter is cleared to None
53 - // so the SQL query omits that bound. 300 matches the ceiling in bpm.rs's
54 - // plausible-BPM filter. M-1 closes the sentinel-visibility gap via the
55 - // per-section [clear] link; M-2 snaps the sibling when min crosses max so
56 - // contradictory states (returning zero rows by construction) can't be set.
57 - let bpm_active = state.search.search_filter.bpm_min.is_some()
58 - || state.search.search_filter.bpm_max.is_some();
59 - widgets::filter_section(ui, "BPM Range", bpm_active, |ui| {
60 - if bpm_active && draw_section_clear(ui) {
61 - state.search.search_filter.bpm_min = None;
62 - state.search.search_filter.bpm_max = None;
63 - changed = true;
64 - }
65 - ui.horizontal(|ui| {
66 - let mut min = state.search.search_filter.bpm_min.unwrap_or(0.0);
67 - let mut max = state.search.search_filter.bpm_max.unwrap_or(300.0);
68 - ui.label("Min");
69 - let r = ui.add(egui::DragValue::new(&mut min).speed(1.0).range(0.0..=300.0));
70 - if r.changed() {
71 - if min > max {
72 - max = min;
73 - }
74 - let (lo, hi) = range_bounds(min, max, 0.0, 300.0);
75 - state.search.search_filter.bpm_min = lo;
76 - state.search.search_filter.bpm_max = hi;
77 - }
78 - if requery_now(&r) {
79 - changed = true;
80 - }
81 - ui.label("Max");
82 - let r = ui.add(egui::DragValue::new(&mut max).speed(1.0).range(0.0..=300.0));
83 - if r.changed() {
84 - if max < min {
85 - min = max;
86 - }
87 - let (lo, hi) = range_bounds(min, max, 0.0, 300.0);
88 - state.search.search_filter.bpm_min = lo;
89 - state.search.search_filter.bpm_max = hi;
90 - }
91 - if requery_now(&r) {
92 - changed = true;
93 - }
94 - });
95 - });
136 + // Every range axis below is one RangeAxis + one call. The sentinel edges are
137 + // the "no filter" values: a min sitting on `lo` or a max on `hi` stores None,
138 + // so the SQL omits that bound. The per-section [clear] link closes the
139 + // sentinel-visibility gap (M-1) and the sibling snap makes contradictory
140 + // min > max states unrepresentable (M-2).
141 + //
142 + // 300 BPM matches the ceiling in bpm.rs's plausible-BPM filter.
143 + const BPM: RangeAxis = RangeAxis {
144 + title: "BPM Range",
145 + hint: None,
146 + lo: 0.0,
147 + hi: 300.0,
148 + speed: 1.0,
149 + suffix: "",
150 + decimals: 0,
151 + };
152 + const DURATION: RangeAxis = RangeAxis {
153 + title: "Duration",
154 + hint: None,
155 + lo: 0.0,
156 + hi: 600.0,
157 + speed: 0.1,
158 + suffix: " s",
159 + decimals: 1,
160 + };
161 + const LOUDNESS: RangeAxis = RangeAxis {
162 + title: "Loudness",
163 + hint: None,
164 + lo: -96.0,
165 + hi: 0.0,
166 + speed: 0.5,
167 + suffix: " dB",
168 + decimals: 0,
169 + };
96 170
97 - let dur_active = state.search.search_filter.duration_min.is_some()
98 - || state.search.search_filter.duration_max.is_some();
99 - widgets::filter_section(ui, "Duration (s)", dur_active, |ui| {
100 - if dur_active && draw_section_clear(ui) {
101 - state.search.search_filter.duration_min = None;
102 - state.search.search_filter.duration_max = None;
103 - changed = true;
104 - }
105 - ui.horizontal(|ui| {
106 - let mut min = state.search.search_filter.duration_min.unwrap_or(0.0);
107 - let mut max = state.search.search_filter.duration_max.unwrap_or(600.0);
108 - ui.label("Min");
109 - let r = ui.add(egui::DragValue::new(&mut min).speed(0.1).range(0.0..=600.0));
110 - if r.changed() {
111 - if min > max {
112 - max = min;
113 - }
114 - let (lo, hi) = range_bounds(min, max, 0.0, 600.0);
115 - state.search.search_filter.duration_min = lo;
116 - state.search.search_filter.duration_max = hi;
117 - }
118 - if requery_now(&r) {
119 - changed = true;
120 - }
121 - ui.label("Max");
122 - let r = ui.add(egui::DragValue::new(&mut max).speed(0.1).range(0.0..=600.0));
123 - if r.changed() {
124 - if max < min {
125 - min = max;
126 - }
127 - let (lo, hi) = range_bounds(min, max, 0.0, 600.0);
128 - state.search.search_filter.duration_min = lo;
129 - state.search.search_filter.duration_max = hi;
130 - }
131 - if requery_now(&r) {
132 - changed = true;
133 - }
134 - });
135 - });
171 + // The measured axes. These are what replaced the sample-class filter: a class
172 + // was a lossy guess at a question nobody asked, where a region of the feature
173 + // space is the thing a producer actually reaches for (wiki af-browse-axes).
174 + //
175 + // Titled perceptually and valued in the real unit. "Brightness" is how anyone
176 + // describes the axis; 2500 Hz is what the query compares. Neither alone is
177 + // enough: Hz in the title reads as an instrument spec, and a bare 0-100
178 + // "brightness" score would be a made-up number.
179 + //
180 + // Deliberately NOT binned. Fixed breakpoints do not transfer across material:
181 + // kick/tom one-shots sit 48% low / 43% lowmid / 0% high, reverb loops sit 71%
182 + // high, FSL10K loops spread 16/22/40/22. A "bright" one-shot and a "bright"
183 + // loop are nowhere near each other, so any binning has to be relative to the
184 + // library. Exposing the value and letting the range be chosen sidesteps that.
185 + //
186 + // Ceilings are the measurable range, not a guess: centroid tops out near
187 + // Nyquist for 44.1k material, flatness is 0..1 by construction, and attack is
188 + // capped at 1 s because a slower onset than that is a pad, not a transient.
189 + const BRIGHTNESS: RangeAxis = RangeAxis {
190 + title: "Brightness",
191 + hint: Some("Spectral centroid: where the energy sits. Low is dark, high is bright."),
192 + lo: 0.0,
193 + hi: 20_000.0,
194 + speed: 50.0,
195 + suffix: " Hz",
196 + decimals: 0,
197 + };
198 + const NOISINESS: RangeAxis = RangeAxis {
199 + title: "Tonal / Noisy",
200 + hint: Some("Spectral flatness: 0 is a pure tone, 1 is white noise."),
201 + lo: 0.0,
202 + hi: 1.0,
203 + speed: 0.01,
204 + suffix: "",
205 + decimals: 2,
206 + };
207 + const ATTACK: RangeAxis = RangeAxis {
208 + title: "Attack",
209 + hint: Some("Time to reach full level. Short is a transient, long is a swell."),
210 + lo: 0.0,
211 + hi: 1.0,
212 + speed: 0.005,
213 + suffix: " s",
214 + decimals: 3,
215 + };
136 216
137 - let loud_active = state.search.search_filter.peak_db_min.is_some()
138 - || state.search.search_filter.peak_db_max.is_some();
139 - widgets::filter_section(ui, "Loudness (dB)", loud_active, |ui| {
140 - if loud_active && draw_section_clear(ui) {
141 - state.search.search_filter.peak_db_min = None;
142 - state.search.search_filter.peak_db_max = None;
143 - changed = true;
144 - }
145 - ui.horizontal(|ui| {
146 - let mut min = state.search.search_filter.peak_db_min.unwrap_or(-96.0);
147 - let mut max = state.search.search_filter.peak_db_max.unwrap_or(0.0);
148 - ui.label("Min");
149 - let r = ui.add(
150 - egui::DragValue::new(&mut min)
151 - .speed(0.5)
152 - .range(-96.0..=0.0)
153 - .suffix(" dB"),
154 - );
155 - if r.changed() {
156 - if min > max {
157 - max = min;
158 - }
159 - let (lo, hi) = range_bounds(min, max, -96.0, 0.0);
160 - state.search.search_filter.peak_db_min = lo;
161 - state.search.search_filter.peak_db_max = hi;
162 - }
163 - if requery_now(&r) {
164 - changed = true;
165 - }
166 - ui.label("Max");
167 - let r = ui.add(
168 - egui::DragValue::new(&mut max)
169 - .speed(0.5)
170 - .range(-96.0..=0.0)
171 - .suffix(" dB"),
172 - );
173 - if r.changed() {
174 - if max < min {
175 - min = max;
176 - }
177 - let (lo, hi) = range_bounds(min, max, -96.0, 0.0);
178 - state.search.search_filter.peak_db_min = lo;
179 - state.search.search_filter.peak_db_max = hi;
180 - }
181 - if requery_now(&r) {
182 - changed = true;
183 - }
184 - });
185 - });
217 + let f = &mut state.search.search_filter;
218 + changed |= range_filter_section(ui, &BPM, &mut f.bpm_min, &mut f.bpm_max);
219 + changed |= range_filter_section(ui, &DURATION, &mut f.duration_min, &mut f.duration_max);
220 + changed |= range_filter_section(ui, &LOUDNESS, &mut f.peak_db_min, &mut f.peak_db_max);
221 + changed |= range_filter_section(ui, &BRIGHTNESS, &mut f.centroid_min, &mut f.centroid_max);
222 + changed |= range_filter_section(ui, &NOISINESS, &mut f.flatness_min, &mut f.flatness_max);
223 + changed |= range_filter_section(ui, &ATTACK, &mut f.attack_min, &mut f.attack_max);
186 224
187 225 let key_active = !state.search.search_filter.keys.is_empty();
188 226 widgets::filter_section(ui, "Key Filter", key_active, |ui| {