stratum_dsp/analysis/result.rs
1//! Analysis result types
2
3use serde::{Deserialize, Serialize};
4
5/// Musical key
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
7pub enum Key {
8 /// Major key (0 = C, 1 = C#, ..., 11 = B)
9 Major(u32),
10 /// Minor key (0 = C, 1 = C#, ..., 11 = B)
11 Minor(u32),
12}
13
14impl Key {
15 /// Get key name in musical notation (e.g., "C", "Am", "F#", "D#m")
16 ///
17 /// Returns standard musical notation:
18 /// - Major keys: note name only (e.g., "C", "C#", "D", "F#")
19 /// - Minor keys: note name + "m" (e.g., "Am", "C#m", "Dm", "F#m")
20 ///
21 /// # Example
22 ///
23 /// ```
24 /// use stratum_dsp::analysis::result::Key;
25 ///
26 /// assert_eq!(Key::Major(0).name(), "C");
27 /// assert_eq!(Key::Major(6).name(), "F#");
28 /// assert_eq!(Key::Minor(9).name(), "Am");
29 /// assert_eq!(Key::Minor(1).name(), "C#m");
30 /// ```
31 pub fn name(&self) -> String {
32 let note_names = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
33 match self {
34 Key::Major(i) => note_names[*i as usize % 12].to_string(),
35 Key::Minor(i) => format!("{}m", note_names[*i as usize % 12]),
36 }
37 }
38
39 /// Get key in DJ standard numerical notation (e.g., "1A", "2B", "12A")
40 ///
41 /// Uses the circle of fifths mapping popularized in DJ software:
42 /// - Major keys: 1A-12A (1A = C, 2A = G, 3A = D, ..., 12A = F)
43 /// - Minor keys: 1B-12B (1B = Am, 2B = Em, 3B = Bm, ..., 12B = Dm)
44 ///
45 /// The numbering follows the circle of fifths (up a fifth each step).
46 /// Keys are displayed in standard musical notation by default (e.g., "C", "Am", "F#").
47 ///
48 /// # Example
49 ///
50 /// ```
51 /// use stratum_dsp::analysis::result::Key;
52 ///
53 /// assert_eq!(Key::Major(0).numerical(), "1A"); // C
54 /// assert_eq!(Key::Major(7).numerical(), "2A"); // G
55 /// assert_eq!(Key::Minor(9).numerical(), "1B"); // Am
56 /// assert_eq!(Key::Minor(4).numerical(), "2B"); // Em
57 /// ```
58 pub fn numerical(&self) -> String {
59 // Circle of fifths mapping: C=0, G=7, D=2, A=9, E=4, B=11, F#=6, C#=1, G#=8, D#=3, A#=10, F=5
60 // This maps to: 1A, 2A, 3A, 4A, 5A, 6A, 7A, 8A, 9A, 10A, 11A, 12A
61 let circle_of_fifths_major = [0, 7, 2, 9, 4, 11, 6, 1, 8, 3, 10, 5]; // C, G, D, A, E, B, F#, C#, G#, D#, A#, F
62
63 match self {
64 Key::Major(i) => {
65 let key_idx = *i as usize % 12;
66 // Find position in circle of fifths
67 let position = circle_of_fifths_major.iter()
68 .position(|&x| x == key_idx)
69 .unwrap_or(0);
70 format!("{}A", position + 1)
71 }
72 Key::Minor(i) => {
73 let key_idx = *i as usize % 12;
74 // Minor keys follow the same circle of fifths pattern
75 // Relative minor of 1A (C) is 1B (Am), relative minor of 2A (G) is 2B (Em), etc.
76 let circle_of_fifths_minor = [9, 4, 11, 6, 1, 8, 3, 10, 5, 0, 7, 2]; // Am, Em, Bm, F#m, C#m, G#m, D#m, A#m, Fm, Cm, Gm, Dm
77 let position = circle_of_fifths_minor.iter()
78 .position(|&x| x == key_idx)
79 .unwrap_or(0);
80 format!("{}B", position + 1)
81 }
82 }
83 }
84
85 /// Get key from DJ standard numerical notation
86 ///
87 /// Converts numerical notation (e.g., "1A", "2B", "12A") back to a Key.
88 ///
89 /// # Arguments
90 ///
91 /// * `notation` - Numerical key notation (e.g., "1A", "2B", "12A")
92 ///
93 /// # Returns
94 ///
95 /// `Some(Key)` if valid, `None` if invalid format
96 ///
97 /// # Example
98 ///
99 /// ```
100 /// use stratum_dsp::analysis::result::Key;
101 ///
102 /// assert_eq!(Key::from_numerical("1A"), Some(Key::Major(0))); // C
103 /// assert_eq!(Key::from_numerical("2A"), Some(Key::Major(7))); // G
104 /// assert_eq!(Key::from_numerical("1B"), Some(Key::Minor(9))); // Am
105 /// assert_eq!(Key::from_numerical("2B"), Some(Key::Minor(4))); // Em
106 /// assert_eq!(Key::from_numerical("0A"), None); // Invalid
107 /// assert_eq!(Key::from_numerical("13A"), None); // Invalid
108 /// ```
109 pub fn from_numerical(notation: &str) -> Option<Self> {
110 if notation.len() < 2 {
111 return None;
112 }
113
114 let (num_str, suffix) = notation.split_at(notation.len() - 1);
115 let num: u32 = num_str.parse().ok()?;
116
117 if num < 1 || num > 12 {
118 return None;
119 }
120
121 // Circle of fifths mapping
122 let circle_of_fifths_major = [0, 7, 2, 9, 4, 11, 6, 1, 8, 3, 10, 5]; // C, G, D, A, E, B, F#, C#, G#, D#, A#, F
123 let circle_of_fifths_minor = [9, 4, 11, 6, 1, 8, 3, 10, 5, 0, 7, 2]; // Am, Em, Bm, F#m, C#m, G#m, D#m, A#m, Fm, Cm, Gm, Dm
124
125 match suffix {
126 "A" => {
127 let key_idx = circle_of_fifths_major[(num - 1) as usize];
128 Some(Key::Major(key_idx))
129 }
130 "B" => {
131 let key_idx = circle_of_fifths_minor[(num - 1) as usize];
132 Some(Key::Minor(key_idx))
133 }
134 _ => None,
135 }
136 }
137}
138
139/// Beat grid structure
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct BeatGrid {
142 /// Downbeat times (beat 1) in seconds
143 pub downbeats: Vec<f32>,
144
145 /// All beat times in seconds
146 pub beats: Vec<f32>,
147
148 /// Bar boundaries in seconds
149 pub bars: Vec<f32>,
150}
151
152/// Analysis flags
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub enum AnalysisFlag {
155 /// Multiple BPM peaks equally strong
156 MultimodalBpm,
157 /// Low key clarity (atonal/ambiguous)
158 WeakTonality,
159 /// Track has tempo drift
160 TempoVariation,
161 /// Multiple onset interpretations
162 OnsetDetectionAmbiguous,
163}
164
165/// Tempogram candidate diagnostics (for validation/tuning)
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct TempoCandidateDebug {
168 /// Candidate tempo in BPM.
169 pub bpm: f32,
170 /// Combined score used for ranking (blended FFT+autocorr, with mild priors).
171 pub score: f32,
172 /// FFT-method normalized support in [0, 1].
173 pub fft_norm: f32,
174 /// Autocorrelation-method normalized support in [0, 1].
175 pub autocorr_norm: f32,
176 /// True if this candidate was selected as the final BPM.
177 pub selected: bool,
178}
179
180/// Complete analysis result
181#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct AnalysisResult {
183 /// BPM estimate
184 pub bpm: f32,
185
186 /// BPM confidence (0.0-1.0)
187 pub bpm_confidence: f32,
188
189 /// Detected key
190 pub key: Key,
191
192 /// Key confidence (0.0-1.0)
193 pub key_confidence: f32,
194
195 /// Key clarity (0.0-1.0)
196 ///
197 /// Measures how "tonal" vs "atonal" the track is:
198 /// - High (>0.5): Strong tonality, reliable key detection
199 /// - Medium (0.2-0.5): Moderate tonality
200 /// - Low (<0.2): Weak tonality, key detection may be unreliable
201 pub key_clarity: f32,
202
203 /// Beat grid
204 pub beat_grid: BeatGrid,
205
206 /// Grid stability (0.0-1.0)
207 pub grid_stability: f32,
208
209 /// Analysis metadata
210 pub metadata: AnalysisMetadata,
211}
212
213/// Analysis metadata
214#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct AnalysisMetadata {
216 /// Audio duration in seconds
217 pub duration_seconds: f32,
218
219 /// Sample rate in Hz
220 pub sample_rate: u32,
221
222 /// Processing time in milliseconds
223 pub processing_time_ms: f32,
224
225 /// Algorithm version
226 pub algorithm_version: String,
227
228 /// Onset method consensus score
229 pub onset_method_consensus: f32,
230
231 /// Methods used
232 pub methods_used: Vec<String>,
233
234 /// Analysis flags
235 pub flags: Vec<AnalysisFlag>,
236
237 /// Confidence warnings (low confidence, ambiguous results, etc.)
238 pub confidence_warnings: Vec<String>,
239
240 /// Optional: tempogram candidate list (top-N) for diagnostics.
241 #[serde(skip_serializing_if = "Option::is_none")]
242 pub tempogram_candidates: Option<Vec<TempoCandidateDebug>>,
243
244 /// Tempogram multi-resolution escalation was triggered for this track (ambiguous base estimate).
245 #[serde(skip_serializing_if = "Option::is_none")]
246 pub tempogram_multi_res_triggered: Option<bool>,
247
248 /// Tempogram multi-resolution result was selected over the base single-resolution estimate.
249 #[serde(skip_serializing_if = "Option::is_none")]
250 pub tempogram_multi_res_used: Option<bool>,
251
252 /// Tempogram percussive-only fallback was triggered (ambiguous estimate + HPSS enabled).
253 #[serde(skip_serializing_if = "Option::is_none")]
254 pub tempogram_percussive_triggered: Option<bool>,
255
256 /// Tempogram percussive-only fallback was selected over the current estimate.
257 #[serde(skip_serializing_if = "Option::is_none")]
258 pub tempogram_percussive_used: Option<bool>,
259}
260
261// Re-export for convenience
262pub use Key as KeyType;
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 #[test]
269 fn test_key_name_major() {
270 assert_eq!(Key::Major(0).name(), "C");
271 assert_eq!(Key::Major(1).name(), "C#");
272 assert_eq!(Key::Major(2).name(), "D");
273 assert_eq!(Key::Major(6).name(), "F#");
274 assert_eq!(Key::Major(11).name(), "B");
275 }
276
277 #[test]
278 fn test_key_name_minor() {
279 assert_eq!(Key::Minor(0).name(), "Cm");
280 assert_eq!(Key::Minor(1).name(), "C#m");
281 assert_eq!(Key::Minor(2).name(), "Dm");
282 assert_eq!(Key::Minor(9).name(), "Am");
283 assert_eq!(Key::Minor(11).name(), "Bm");
284 }
285
286 #[test]
287 fn test_key_numerical_major() {
288 // Circle of fifths: C=1A, G=2A, D=3A, A=4A, E=5A, B=6A, F#=7A, C#=8A, G#=9A, D#=10A, A#=11A, F=12A
289 assert_eq!(Key::Major(0).numerical(), "1A"); // C
290 assert_eq!(Key::Major(7).numerical(), "2A"); // G
291 assert_eq!(Key::Major(2).numerical(), "3A"); // D
292 assert_eq!(Key::Major(9).numerical(), "4A"); // A
293 assert_eq!(Key::Major(4).numerical(), "5A"); // E
294 assert_eq!(Key::Major(11).numerical(), "6A"); // B
295 assert_eq!(Key::Major(6).numerical(), "7A"); // F#
296 assert_eq!(Key::Major(1).numerical(), "8A"); // C#
297 assert_eq!(Key::Major(8).numerical(), "9A"); // G#
298 assert_eq!(Key::Major(3).numerical(), "10A"); // D#
299 assert_eq!(Key::Major(10).numerical(), "11A"); // A#
300 assert_eq!(Key::Major(5).numerical(), "12A"); // F
301 }
302
303 #[test]
304 fn test_key_numerical_minor() {
305 // Circle of fifths: Am=1B, Em=2B, Bm=3B, F#m=4B, C#m=5B, G#m=6B, D#m=7B, A#m=8B, Fm=9B, Cm=10B, Gm=11B, Dm=12B
306 assert_eq!(Key::Minor(9).numerical(), "1B"); // Am
307 assert_eq!(Key::Minor(4).numerical(), "2B"); // Em
308 assert_eq!(Key::Minor(11).numerical(), "3B"); // Bm
309 assert_eq!(Key::Minor(6).numerical(), "4B"); // F#m
310 assert_eq!(Key::Minor(1).numerical(), "5B"); // C#m
311 assert_eq!(Key::Minor(8).numerical(), "6B"); // G#m
312 assert_eq!(Key::Minor(3).numerical(), "7B"); // D#m
313 assert_eq!(Key::Minor(10).numerical(), "8B"); // A#m
314 assert_eq!(Key::Minor(5).numerical(), "9B"); // Fm
315 assert_eq!(Key::Minor(0).numerical(), "10B"); // Cm
316 assert_eq!(Key::Minor(7).numerical(), "11B"); // Gm
317 assert_eq!(Key::Minor(2).numerical(), "12B"); // Dm
318 }
319
320 #[test]
321 fn test_key_from_numerical() {
322 // Test major keys
323 assert_eq!(Key::from_numerical("1A"), Some(Key::Major(0))); // C
324 assert_eq!(Key::from_numerical("2A"), Some(Key::Major(7))); // G
325 assert_eq!(Key::from_numerical("7A"), Some(Key::Major(6))); // F#
326 assert_eq!(Key::from_numerical("12A"), Some(Key::Major(5))); // F
327
328 // Test minor keys
329 assert_eq!(Key::from_numerical("1B"), Some(Key::Minor(9))); // Am
330 assert_eq!(Key::from_numerical("2B"), Some(Key::Minor(4))); // Em
331 assert_eq!(Key::from_numerical("10B"), Some(Key::Minor(0))); // Cm
332
333 // Test invalid inputs
334 assert_eq!(Key::from_numerical("0A"), None);
335 assert_eq!(Key::from_numerical("13A"), None);
336 assert_eq!(Key::from_numerical("1C"), None);
337 assert_eq!(Key::from_numerical(""), None);
338 assert_eq!(Key::from_numerical("A"), None);
339 }
340
341 #[test]
342 fn test_key_numerical_roundtrip() {
343 // Test that numerical() and from_numerical() are inverses
344 for i in 0..12 {
345 let major = Key::Major(i);
346 let num = major.numerical();
347 assert_eq!(Key::from_numerical(&num), Some(major), "Failed roundtrip for major key {}: {}", i, num);
348
349 let minor = Key::Minor(i);
350 let num = minor.numerical();
351 assert_eq!(Key::from_numerical(&num), Some(minor), "Failed roundtrip for minor key {}: {}", i, num);
352 }
353 }
354}
355