max / audiofiles
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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| { |