max / audiofiles
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
3 files changed,
+191 insertions,
-10 deletions
| @@ -1618,6 +1618,33 @@ | |||
| 1618 | 1618 | END; | |
| 1619 | 1619 | "; | |
| 1620 | 1620 | ||
| 1621 | + | const MIGRATION_034: &str = r" | |
| 1622 | + | -- Normalise musical_key to the '<note> major' / '<note> minor' spelling. | |
| 1623 | + | -- | |
| 1624 | + | -- detect_bpm_key stored stratum_dsp's compact DJ-style name ('Am', 'C#m', 'C') | |
| 1625 | + | -- straight through with no normalisation, while search::compatible_keys matches | |
| 1626 | + | -- on ends_with('minor') against a table of '<note> minor' strings and | |
| 1627 | + | -- analysis::suggest builds its tag by replacing a space that was never there. | |
| 1628 | + | -- Key-compatible search therefore matched nothing and the tag came out as | |
| 1629 | + | -- 'key.am'. The detector now normalises at the boundary; this rewrites rows | |
| 1630 | + | -- written before that. | |
| 1631 | + | -- | |
| 1632 | + | -- substr drops only the trailing minor marker; replace(.., 'm', '') would be | |
| 1633 | + | -- equivalent today only because no note name contains an 'm', which is not a | |
| 1634 | + | -- property worth depending on. Rows already canonical contain a space and are | |
| 1635 | + | -- excluded by the WHERE clause, so re-running is a no-op. | |
| 1636 | + | UPDATE audio_analysis | |
| 1637 | + | SET musical_key = CASE | |
| 1638 | + | WHEN musical_key LIKE '%m' | |
| 1639 | + | THEN substr(musical_key, 1, length(musical_key) - 1) || ' minor' | |
| 1640 | + | ELSE musical_key || ' major' | |
| 1641 | + | END | |
| 1642 | + | WHERE musical_key IS NOT NULL | |
| 1643 | + | AND musical_key NOT LIKE '% major' | |
| 1644 | + | AND musical_key NOT LIKE '% minor' | |
| 1645 | + | AND musical_key != ''; | |
| 1646 | + | "; | |
| 1647 | + | ||
| 1621 | 1648 | /// Register `hash_row_id(salt, key) -> TEXT` as a deterministic SQLite | |
| 1622 | 1649 | /// function on the given connection. Used by the M018 sync triggers so the | |
| 1623 | 1650 | /// `sync_changelog.row_id` field never carries cleartext content (tag strings, | |
| @@ -1805,6 +1832,7 @@ | |||
| 1805 | 1832 | MIGRATION_031, | |
| 1806 | 1833 | MIGRATION_032, | |
| 1807 | 1834 | MIGRATION_033, | |
| 1835 | + | MIGRATION_034, | |
| 1808 | 1836 | ]; | |
| 1809 | 1837 | ||
| 1810 | 1838 | for (i, sql) in MIGRATIONS.iter().enumerate() { | |
| @@ -1998,6 +2026,55 @@ | |||
| 1998 | 2026 | mod tests { | |
| 1999 | 2027 | use super::*; | |
| 2000 | 2028 | ||
| 2029 | + | #[test] | |
| 2030 | + | fn migration_034_normalises_legacy_key_spellings() { | |
| 2031 | + | let db = Database::open_in_memory().unwrap(); | |
| 2032 | + | // Rows written before the detector normalised its output. Inserted | |
| 2033 | + | // post-migration and re-run explicitly, since an in-memory DB starts | |
| 2034 | + | // empty and the migration would otherwise have nothing to rewrite. | |
| 2035 | + | db.conn() | |
| 2036 | + | .execute_batch( | |
| 2037 | + | "INSERT INTO samples | |
| 2038 | + | (hash, original_name, file_extension, file_size, import_date, last_modified) | |
| 2039 | + | VALUES ('a', 'a.wav', 'wav', 1, 0, 0), ('b', 'b.wav', 'wav', 1, 0, 0), | |
| 2040 | + | ('c', 'c.wav', 'wav', 1, 0, 0), ('d', 'd.wav', 'wav', 1, 0, 0); | |
| 2041 | + | INSERT INTO audio_analysis | |
| 2042 | + | (hash, musical_key, duration, sample_rate, channels, analyzed_at) | |
| 2043 | + | VALUES ('a', 'Am', 1.0, 44100, 2, 0), ('b', 'C#m', 1.0, 44100, 2, 0), | |
| 2044 | + | ('c', 'F#', 1.0, 44100, 2, 0), ('d', 'A minor', 1.0, 44100, 2, 0);", | |
| 2045 | + | ) | |
| 2046 | + | .unwrap(); | |
| 2047 | + | db.conn().execute_batch(MIGRATION_034).unwrap(); | |
| 2048 | + | ||
| 2049 | + | let mut stmt = db | |
| 2050 | + | .conn() | |
| 2051 | + | .prepare("SELECT hash, musical_key FROM audio_analysis ORDER BY hash") | |
| 2052 | + | .unwrap(); | |
| 2053 | + | let got: Vec<(String, String)> = stmt | |
| 2054 | + | .query_map([], |r| Ok((r.get(0)?, r.get(1)?))) | |
| 2055 | + | .unwrap() | |
| 2056 | + | .map(Result::unwrap) | |
| 2057 | + | .collect(); | |
| 2058 | + | ||
| 2059 | + | assert_eq!(got[0].1, "A minor"); | |
| 2060 | + | assert_eq!(got[1].1, "C# minor"); | |
| 2061 | + | assert_eq!(got[2].1, "F# major"); | |
| 2062 | + | // Already canonical, must not be rewritten to "A minor major". | |
| 2063 | + | assert_eq!(got[3].1, "A minor"); | |
| 2064 | + | ||
| 2065 | + | // Idempotent: a second application changes nothing. | |
| 2066 | + | db.conn().execute_batch(MIGRATION_034).unwrap(); | |
| 2067 | + | let after: String = db | |
| 2068 | + | .conn() | |
| 2069 | + | .query_row( | |
| 2070 | + | "SELECT musical_key FROM audio_analysis WHERE hash = 'a'", | |
| 2071 | + | [], | |
| 2072 | + | |r| r.get(0), | |
| 2073 | + | ) | |
| 2074 | + | .unwrap(); | |
| 2075 | + | assert_eq!(after, "A minor"); | |
| 2076 | + | } | |
| 2077 | + | ||
| 2001 | 2078 | #[test] | |
| 2002 | 2079 | fn file_db_applies_performance_pragmas() { | |
| 2003 | 2080 | let dir = tempfile::tempdir().unwrap(); | |
| @@ -2073,7 +2150,7 @@ | |||
| 2073 | 2150 | .conn() | |
| 2074 | 2151 | .query_row("PRAGMA user_version", [], |row| row.get(0)) | |
| 2075 | 2152 | .unwrap(); | |
| 2076 | - | assert_eq!(version, 33); | |
| 2153 | + | assert_eq!(version, 34); | |
| 2077 | 2154 | } | |
| 2078 | 2155 | ||
| 2079 | 2156 | #[test] | |
| @@ -2084,7 +2161,7 @@ | |||
| 2084 | 2161 | .conn() | |
| 2085 | 2162 | .query_row("PRAGMA user_version", [], |row| row.get(0)) | |
| 2086 | 2163 | .unwrap(); | |
| 2087 | - | assert_eq!(version, 33); | |
| 2164 | + | assert_eq!(version, 34); | |
| 2088 | 2165 | } | |
| 2089 | 2166 | ||
| 2090 | 2167 | #[test] | |
| @@ -2233,7 +2310,7 @@ | |||
| 2233 | 2310 | .conn() | |
| 2234 | 2311 | .query_row("PRAGMA user_version", [], |row| row.get(0)) | |
| 2235 | 2312 | .unwrap(); | |
| 2236 | - | assert_eq!(version, 33); | |
| 2313 | + | assert_eq!(version, 34); | |
| 2237 | 2314 | } | |
| 2238 | 2315 | ||
| 2239 | 2316 | /// Simulates the worst-case recovery path: a prior partial migration left | |
| @@ -2277,7 +2354,7 @@ | |||
| 2277 | 2354 | .conn() | |
| 2278 | 2355 | .query_row("PRAGMA user_version", [], |row| row.get(0)) | |
| 2279 | 2356 | .unwrap(); | |
| 2280 | - | assert_eq!(version, 33); | |
| 2357 | + | assert_eq!(version, 34); | |
| 2281 | 2358 | } | |
| 2282 | 2359 | ||
| 2283 | 2360 | /// M018 contract: the `sync_changelog.row_id` for sensitive tables must | |
| @@ -2507,7 +2584,7 @@ | |||
| 2507 | 2584 | let initial_version: i32 = conn | |
| 2508 | 2585 | .query_row("PRAGMA user_version", [], |row| row.get(0)) | |
| 2509 | 2586 | .unwrap(); | |
| 2510 | - | assert_eq!(initial_version, 33); | |
| 2587 | + | assert_eq!(initial_version, 34); | |
| 2511 | 2588 | ||
| 2512 | 2589 | let batch = format!("BEGIN;\n{bad_sql}\nPRAGMA user_version = 999;\nCOMMIT;"); | |
| 2513 | 2590 | let first_err = conn.execute_batch(&batch).unwrap_err(); | |
| @@ -2572,7 +2649,7 @@ | |||
| 2572 | 2649 | .conn() | |
| 2573 | 2650 | .query_row("PRAGMA user_version", [], |row| row.get(0)) | |
| 2574 | 2651 | .unwrap(); | |
| 2575 | - | assert_eq!(version, 33); | |
| 2652 | + | assert_eq!(version, 34); | |
| 2576 | 2653 | } | |
| 2577 | 2654 | ||
| 2578 | 2655 | #[test] |
| @@ -4,6 +4,24 @@ | |||
| 4 | 4 | ||
| 5 | 5 | use crate::error::{CoreError, Result}; | |
| 6 | 6 | ||
| 7 | + | /// Render a stored key in the compact form used in filenames. | |
| 8 | + | /// | |
| 9 | + | /// Keys are stored canonically as "A minor" / "C major" so that | |
| 10 | + | /// `search::compatible_keys` and the tag builder have one spelling to parse. | |
| 11 | + | /// A filename wants "Am", not "A minor" -- a space in the middle of a generated | |
| 12 | + | /// name is worse than the abbreviation, and "Am" is what the rename token | |
| 13 | + | /// produced before keys were normalised, so this keeps existing patterns | |
| 14 | + | /// yielding the same names. | |
| 15 | + | fn compact_key(key: &str) -> String { | |
| 16 | + | match key.rsplit_once(' ') { | |
| 17 | + | Some((note, "minor")) => format!("{note}m"), | |
| 18 | + | Some((note, "major")) => note.to_string(), | |
| 19 | + | // Anything else (already compact, or an unexpected spelling) passes | |
| 20 | + | // through rather than being mangled. | |
| 21 | + | _ => key.to_string(), | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | ||
| 7 | 25 | /// Context values used to resolve tokens in a rename pattern. | |
| 8 | 26 | pub struct RenameContext { | |
| 9 | 27 | pub name: String, | |
| @@ -106,7 +124,11 @@ | |||
| 106 | 124 | .bpm | |
| 107 | 125 | .map(|b| format!("{}", b.round() as i64)) | |
| 108 | 126 | .unwrap_or_default(), | |
| 109 | - | Token::Key => ctx.musical_key.clone().unwrap_or_default(), | |
| 127 | + | Token::Key => ctx | |
| 128 | + | .musical_key | |
| 129 | + | .as_deref() | |
| 130 | + | .map(compact_key) | |
| 131 | + | .unwrap_or_default(), | |
| 110 | 132 | Token::Class => ctx | |
| 111 | 133 | .classification | |
| 112 | 134 | .as_ref() | |
| @@ -194,7 +216,8 @@ | |||
| 194 | 216 | name: name.to_string(), | |
| 195 | 217 | extension: "wav".to_string(), | |
| 196 | 218 | bpm: Some(120.5), | |
| 197 | - | musical_key: Some("Cm".to_string()), | |
| 219 | + | // Canonical stored spelling; {key} renders it compactly. | |
| 220 | + | musical_key: Some("C minor".to_string()), | |
| 198 | 221 | classification: Some("Kick".to_string()), | |
| 199 | 222 | duration: Some(1.234), | |
| 200 | 223 | index, | |
| @@ -216,6 +239,16 @@ | |||
| 216 | 239 | assert_eq!(result, "005_hit_121_Cm_kick_1.2s_wav"); | |
| 217 | 240 | } | |
| 218 | 241 | ||
| 242 | + | #[test] | |
| 243 | + | fn key_token_renders_compactly() { | |
| 244 | + | assert_eq!(compact_key("A minor"), "Am"); | |
| 245 | + | assert_eq!(compact_key("C# minor"), "C#m"); | |
| 246 | + | assert_eq!(compact_key("C major"), "C"); | |
| 247 | + | assert_eq!(compact_key("F# major"), "F#"); | |
| 248 | + | // Pre-normalisation values pass through rather than being mangled. | |
| 249 | + | assert_eq!(compact_key("Am"), "Am"); | |
| 250 | + | } | |
| 251 | + | ||
| 219 | 252 | #[test] | |
| 220 | 253 | fn reject_path_separators() { | |
| 221 | 254 | assert!(RenamePattern::parse("foo/{name}").is_err()); | |
| @@ -254,7 +287,7 @@ | |||
| 254 | 287 | name: "sample".to_string(), | |
| 255 | 288 | extension: "wav".to_string(), | |
| 256 | 289 | bpm: None, | |
| 257 | - | musical_key: Some("Am".to_string()), | |
| 290 | + | musical_key: Some("A minor".to_string()), | |
| 258 | 291 | classification: None, | |
| 259 | 292 | duration: None, | |
| 260 | 293 | index: 0, |
| @@ -10,6 +10,32 @@ | |||
| 10 | 10 | pub key_confidence: Option<f32>, | |
| 11 | 11 | } | |
| 12 | 12 | ||
| 13 | + | /// Convert a `stratum_dsp` key name to the canonical stored spelling. | |
| 14 | + | /// | |
| 15 | + | /// stratum emits a compact DJ-style name: "C" and "F#" for major, "Am" and | |
| 16 | + | /// "C#m" for minor. Everything downstream expects `"<note> major"` / | |
| 17 | + | /// `"<note> minor"`: [`crate::search::compatible_keys`] matches on | |
| 18 | + | /// `ends_with("minor")` against a table written that way, and | |
| 19 | + | /// `analysis::suggest` builds its `key.a-minor` tag by lowercasing and | |
| 20 | + | /// replacing the space. Storing the compact form made both silently no-op -- | |
| 21 | + | /// key-compatible search matched nothing and the tag came out as `key.am`. | |
| 22 | + | /// | |
| 23 | + | /// Normalising here rather than at each consumer keeps one spelling in the | |
| 24 | + | /// database and one place to change it. | |
| 25 | + | pub(crate) fn canonical_key(name: &str) -> Option<String> { | |
| 26 | + | let name = name.trim(); | |
| 27 | + | // No natural or sharp note name ends in 'm', so a trailing 'm' is | |
| 28 | + | // unambiguously the minor marker. | |
| 29 | + | let (note, mode) = match name.strip_suffix('m') { | |
| 30 | + | Some(note) => (note, "minor"), | |
| 31 | + | None => (name, "major"), | |
| 32 | + | }; | |
| 33 | + | if note.is_empty() { | |
| 34 | + | return None; | |
| 35 | + | } | |
| 36 | + | Some(format!("{note} {mode}")) | |
| 37 | + | } | |
| 38 | + | ||
| 13 | 39 | /// Detect BPM and musical key using stratum-dsp's `analyze_audio`. | |
| 14 | 40 | /// | |
| 15 | 41 | /// Skips samples shorter than `min_duration` seconds (caller typically passes 2.0) | |
| @@ -61,7 +87,7 @@ | |||
| 61 | 87 | if name.is_empty() || name == "Unknown" { | |
| 62 | 88 | None | |
| 63 | 89 | } else { | |
| 64 | - | Some(name) | |
| 90 | + | canonical_key(&name) | |
| 65 | 91 | } | |
| 66 | 92 | }; | |
| 67 | 93 | ||
| @@ -79,6 +105,51 @@ | |||
| 79 | 105 | mod tests { | |
| 80 | 106 | use super::*; | |
| 81 | 107 | ||
| 108 | + | #[test] | |
| 109 | + | fn canonical_key_converts_stratum_spelling() { | |
| 110 | + | // Exactly the strings stratum_dsp::Key::name() produces. | |
| 111 | + | assert_eq!(canonical_key("C").as_deref(), Some("C major")); | |
| 112 | + | assert_eq!(canonical_key("F#").as_deref(), Some("F# major")); | |
| 113 | + | assert_eq!(canonical_key("Am").as_deref(), Some("A minor")); | |
| 114 | + | assert_eq!(canonical_key("C#m").as_deref(), Some("C# minor")); | |
| 115 | + | assert_eq!(canonical_key(""), None); | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | #[test] | |
| 119 | + | fn detector_emits_canonical_key_format() { | |
| 120 | + | // The gap that let the format mismatch ship: every other test builds a | |
| 121 | + | // musical_key by hand, so nothing asserted against what the detector | |
| 122 | + | // actually returns. This drives the real detector and checks the | |
| 123 | + | // spelling contract rather than a specific key, which would be brittle. | |
| 124 | + | let sr = 44100; | |
| 125 | + | let mut samples = Vec::with_capacity(sr as usize * 4); | |
| 126 | + | // A minor triad (A3/C4/E4) so there is real tonal content to analyse. | |
| 127 | + | for i in 0..sr * 4 { | |
| 128 | + | let t = f64::from(i) / f64::from(sr); | |
| 129 | + | let v = (t * 220.0 * std::f64::consts::TAU).sin() | |
| 130 | + | + (t * 261.63 * std::f64::consts::TAU).sin() | |
| 131 | + | + (t * 329.63 * std::f64::consts::TAU).sin(); | |
| 132 | + | samples.push((v / 3.0) as f32); | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | let result = detect_bpm_key(&samples, sr, 2.0); | |
| 136 | + | // Asserted rather than `if let`: a conditional check would pass | |
| 137 | + | // vacuously the day the detector stops returning keys, which is exactly | |
| 138 | + | // the regression this test exists to catch. | |
| 139 | + | let key = result | |
| 140 | + | .key | |
| 141 | + | .expect("detector returned no key for a sustained triad"); | |
| 142 | + | assert!( | |
| 143 | + | key.ends_with(" major") || key.ends_with(" minor"), | |
| 144 | + | "detector returned {key:?}, which search::compatible_keys and \ | |
| 145 | + | analysis::suggest cannot parse" | |
| 146 | + | ); | |
| 147 | + | assert!( | |
| 148 | + | crate::search::compatible_keys(&key).len() > 1, | |
| 149 | + | "compatible_keys returned nothing usable for {key:?}" | |
| 150 | + | ); | |
| 151 | + | } | |
| 152 | + | ||
| 82 | 153 | #[test] | |
| 83 | 154 | fn short_sample_returns_none() { | |
| 84 | 155 | let short = vec![0.0f32; 44100]; // 1 second |