stratum-dsp 1.0.0

Professional-grade audio analysis engine for DJ applications: BPM detection, key detection, and beat tracking
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
//! Onset consensus voting
//!
//! Combines multiple onset detection methods with weighted voting.
//!
//! Algorithm:
//! 1. Sort all onsets from 4 methods
//! 2. Cluster onsets within tolerance_ms windows (default 50ms)
//! 3. For each cluster, sum weights of methods that detected it
//! 4. Normalize confidence to [0, 1]
//! 5. Return sorted by confidence
//!
//! # Example
//!
//! ```no_run
//! use stratum_dsp::features::onset::consensus::{vote_onsets, OnsetConsensus};
//!
//! // Note: All onsets must be in sample positions (convert frame indices if needed)
//! let consensus = OnsetConsensus {
//!     energy_flux: vec![1000, 5000],
//!     spectral_flux: vec![1050, 5100], // Converted from frame indices
//!     hfc: vec![980, 5020],            // Converted from frame indices
//!     hpss: vec![1020, 4990],          // Converted from frame indices
//! };
//!
//! let weights = [0.25, 0.25, 0.25, 0.25]; // Equal weights
//! let candidates = vote_onsets(consensus, weights, 50, 44100)?;
//! # Ok::<(), stratum_dsp::AnalysisError>(())
//! ```

use crate::error::AnalysisError;
use super::OnsetCandidate;

/// Onset detection results from all methods
///
/// **Important**: All onsets must be in sample positions (not frame indices).
/// Methods that return frame indices (spectral_flux, hfc, hpss) must be converted
/// to sample positions using: `sample_position = frame_index * hop_size`
#[derive(Debug, Clone)]
pub struct OnsetConsensus {
    /// Energy flux onsets (in samples)
    pub energy_flux: Vec<usize>,
    /// Spectral flux onsets (in samples) - convert from frame indices if needed
    pub spectral_flux: Vec<usize>,
    /// HFC onsets (in samples) - convert from frame indices if needed
    pub hfc: Vec<usize>,
    /// HPSS onsets (in samples) - convert from frame indices if needed
    pub hpss: Vec<usize>,
}

/// Vote on onsets from multiple methods
///
/// This function combines onset detections from 4 different methods using
/// weighted voting. Onsets detected by multiple methods within a tolerance
/// window are clustered together, and confidence is calculated based on the
/// sum of weights from methods that detected each onset.
///
/// # Reference
///
/// Pecan, S., et al. (2017). A Comparison of Onset Detection Methods.
/// *Proceedings of the International Conference on Music Information Retrieval*.
///
/// McFee, B., & Ellis, D. P. W. (2014). Better Beat Tracking Through Robust Onset Aggregation.
/// *Proceedings of the International Society for Music Information Retrieval Conference*.
///
/// # Arguments
///
/// * `consensus` - Onset results from all methods (in samples).
///   Note: Methods that return frame indices (spectral_flux, hfc, hpss) must be
///   converted to sample positions: `sample_position = frame_index * hop_size`
/// * `weights` - Weights for each method [energy_flux, spectral_flux, hfc, hpss]
///   (should sum to 1.0 for normalized confidence)
/// * `tolerance_ms` - Time tolerance for clustering in milliseconds (default: 50ms)
/// * `sample_rate` - Sample rate in Hz
///
/// # Returns
///
/// Vector of onset candidates with confidence scores, sorted by confidence (highest first)
///
/// # Errors
///
/// Returns `AnalysisError` if parameters are invalid
///
/// # Example
///
/// ```no_run
/// use stratum_dsp::features::onset::consensus::{vote_onsets, OnsetConsensus};
///
/// // Note: All onsets must be in sample positions
/// // If using spectral_flux, hfc, or hpss, convert frame indices:
/// // let spectral_onsets_samples: Vec<usize> = spectral_onsets_frames
/// //     .iter()
/// //     .map(|&frame_idx| frame_idx * hop_size)
/// //     .collect();
///
/// let consensus = OnsetConsensus {
///     energy_flux: vec![1000, 5000],
///     spectral_flux: vec![1050, 5100], // Already converted to samples
///     hfc: vec![980, 5020],            // Already converted to samples
///     hpss: vec![1020, 4990],          // Already converted to samples
/// };
///
/// let weights = [0.3, 0.3, 0.2, 0.2]; // Energy and spectral weighted higher
/// let candidates = vote_onsets(consensus, weights, 50, 44100)?;
///
/// for candidate in candidates {
///     println!("Onset at {:.3}s: confidence={:.2}, voted_by={}", 
///              candidate.time_seconds, candidate.confidence, candidate.voted_by);
/// }
/// # Ok::<(), stratum_dsp::AnalysisError>(())
/// ```
pub fn vote_onsets(
    consensus: OnsetConsensus,
    weights: [f32; 4],
    tolerance_ms: u32,
    sample_rate: u32,
) -> Result<Vec<OnsetCandidate>, AnalysisError> {
    // Validate inputs
    if sample_rate == 0 {
        return Err(AnalysisError::InvalidInput("Sample rate must be > 0".to_string()));
    }
    
    if tolerance_ms == 0 {
        return Err(AnalysisError::InvalidInput("Tolerance must be > 0".to_string()));
    }
    
    // Validate weights (should be non-negative, but don't require sum=1.0 for flexibility)
    for &w in &weights {
        if w < 0.0 {
            return Err(AnalysisError::InvalidInput("Weights must be non-negative".to_string()));
        }
    }
    
    log::debug!("Voting on onsets: energy_flux={}, spectral_flux={}, hfc={}, hpss={}, tolerance={}ms",
                consensus.energy_flux.len(), consensus.spectral_flux.len(),
                consensus.hfc.len(), consensus.hpss.len(), tolerance_ms);
    
    // Convert tolerance from milliseconds to samples
    let tolerance_samples = (tolerance_ms as f32 / 1000.0 * sample_rate as f32) as usize;
    
    // Step 1: Collect all onsets with their method indices
    // Method indices: 0=energy_flux, 1=spectral_flux, 2=hfc, 3=hpss
    #[derive(Debug, Clone)]
    struct OnsetWithMethod {
        sample: usize,
        method_idx: usize,
        weight: f32,
    }
    
    let mut all_onsets = Vec::new();
    
    // Add energy flux onsets
    for &sample in &consensus.energy_flux {
        all_onsets.push(OnsetWithMethod {
            sample,
            method_idx: 0,
            weight: weights[0],
        });
    }
    
    // Add spectral flux onsets
    for &sample in &consensus.spectral_flux {
        all_onsets.push(OnsetWithMethod {
            sample,
            method_idx: 1,
            weight: weights[1],
        });
    }
    
    // Add HFC onsets
    for &sample in &consensus.hfc {
        all_onsets.push(OnsetWithMethod {
            sample,
            method_idx: 2,
            weight: weights[2],
        });
    }
    
    // Add HPSS onsets
    for &sample in &consensus.hpss {
        all_onsets.push(OnsetWithMethod {
            sample,
            method_idx: 3,
            weight: weights[3],
        });
    }
    
    if all_onsets.is_empty() {
        return Ok(Vec::new());
    }
    
    // Step 2: Sort all onsets by sample position
    all_onsets.sort_by_key(|o| o.sample);
    
    // Step 3: Cluster onsets within tolerance windows
    // Use a simple greedy clustering algorithm
    // An onset is added to a cluster if it's within tolerance of ANY onset in that cluster
    let mut clusters: Vec<Vec<&OnsetWithMethod>> = Vec::new();
    
    for onset in &all_onsets {
        // Try to add to existing cluster
        let mut added = false;
        for cluster in &mut clusters {
            // Check if this onset is within tolerance of any onset in the cluster
            for existing_onset in cluster.iter() {
                if (onset.sample as i32 - existing_onset.sample as i32).abs() as usize <= tolerance_samples {
                    cluster.push(onset);
                    added = true;
                    break;
                }
            }
            if added {
                break;
            }
        }
        
        // If not added to any cluster, create a new one
        if !added {
            clusters.push(vec![onset]);
        }
    }
    
    // Step 4: For each cluster, compute confidence and create OnsetCandidate
    let mut candidates = Vec::with_capacity(clusters.len());
    
    for cluster in &clusters {
        // Calculate cluster center (average of all onsets in cluster)
        let cluster_center = cluster.iter()
            .map(|o| o.sample)
            .sum::<usize>() / cluster.len();
        
        // Sum weights from methods that detected onsets in this cluster
        let mut total_weight = 0.0;
        let mut voted_by_methods = [false; 4]; // Track which methods voted
        
        for onset in cluster {
            total_weight += onset.weight;
            voted_by_methods[onset.method_idx] = true;
        }
        
        // Count number of unique methods that voted
        let voted_by = voted_by_methods.iter().filter(|&&v| v).count() as u32;
        
        // Normalize confidence to [0, 1]
        // Maximum possible weight is sum of all weights
        let max_weight: f32 = weights.iter().sum();
        let confidence = if max_weight > 0.0 {
            (total_weight / max_weight).min(1.0).max(0.0)
        } else {
            0.0
        };
        
        // Convert sample position to seconds
        let time_seconds = cluster_center as f32 / sample_rate as f32;
        
        candidates.push(OnsetCandidate {
            time_samples: cluster_center,
            time_seconds,
            confidence,
            voted_by,
        });
    }
    
    // Step 5: Sort by confidence (highest first)
    candidates.sort_by(|a, b| {
        b.confidence.partial_cmp(&a.confidence)
            .unwrap_or(std::cmp::Ordering::Equal)
    });
    
    log::debug!("Consensus voting produced {} onset candidates", candidates.len());
    
    Ok(candidates)
}

#[cfg(test)]
mod tests {
    use super::*;
    
    #[test]
    fn test_consensus_voting_basic() {
        // Simple test: all methods detect the same onset
        let consensus = OnsetConsensus {
            energy_flux: vec![1000],
            spectral_flux: vec![1000],
            hfc: vec![1000],
            hpss: vec![1000],
        };
        
        let weights = [0.25, 0.25, 0.25, 0.25];
        let candidates = vote_onsets(consensus, weights, 50, 44100).unwrap();
        
        assert_eq!(candidates.len(), 1);
        assert_eq!(candidates[0].time_samples, 1000);
        assert_eq!(candidates[0].voted_by, 4);
        assert!((candidates[0].confidence - 1.0).abs() < 0.01);
    }
    
    #[test]
    fn test_consensus_voting_clustering() {
        // Test clustering: onsets within tolerance should be merged
        let consensus = OnsetConsensus {
            energy_flux: vec![1000],
            spectral_flux: vec![1050], // Within 50ms at 44.1kHz (~2205 samples)
            hfc: vec![980],
            hpss: vec![1020],
        };
        
        let weights = [0.25, 0.25, 0.25, 0.25];
        // 50ms tolerance at 44.1kHz = 2205 samples
        let candidates = vote_onsets(consensus, weights, 50, 44100).unwrap();
        
        // All should cluster into one
        assert_eq!(candidates.len(), 1);
        assert_eq!(candidates[0].voted_by, 4);
        assert!((candidates[0].confidence - 1.0).abs() < 0.01);
    }
    
    #[test]
    fn test_consensus_voting_separate_onsets() {
        // Test: two separate onsets far apart
        let consensus = OnsetConsensus {
            energy_flux: vec![1000, 50000],
            spectral_flux: vec![1050, 50500],
            hfc: vec![980, 50200],
            hpss: vec![1020, 49900],
        };
        
        let weights = [0.25, 0.25, 0.25, 0.25];
        let candidates = vote_onsets(consensus, weights, 50, 44100).unwrap();
        
        // Should have 2 clusters
        assert_eq!(candidates.len(), 2);
        assert_eq!(candidates[0].voted_by, 4);
        assert_eq!(candidates[1].voted_by, 4);
    }
    
    #[test]
    fn test_consensus_voting_partial_agreement() {
        // Test: only some methods detect an onset
        let consensus = OnsetConsensus {
            energy_flux: vec![1000],
            spectral_flux: vec![1050],
            hfc: vec![], // Doesn't detect
            hpss: vec![], // Doesn't detect
        };
        
        let weights = [0.3, 0.3, 0.2, 0.2];
        let candidates = vote_onsets(consensus, weights, 50, 44100).unwrap();
        
        assert_eq!(candidates.len(), 1);
        assert_eq!(candidates[0].voted_by, 2);
        // Confidence should be (0.3 + 0.3) / 1.0 = 0.6
        assert!((candidates[0].confidence - 0.6).abs() < 0.01);
    }
    
    #[test]
    fn test_consensus_voting_weighted() {
        // Test: different weights affect confidence
        let consensus = OnsetConsensus {
            energy_flux: vec![1000],
            spectral_flux: vec![],
            hfc: vec![],
            hpss: vec![],
        };
        
        let weights = [0.5, 0.2, 0.2, 0.1];
        let candidates = vote_onsets(consensus, weights, 50, 44100).unwrap();
        
        assert_eq!(candidates.len(), 1);
        assert_eq!(candidates[0].voted_by, 1);
        // Confidence should be 0.5 / 1.0 = 0.5
        assert!((candidates[0].confidence - 0.5).abs() < 0.01);
    }
    
    #[test]
    fn test_consensus_voting_empty() {
        // Test: no onsets detected
        let consensus = OnsetConsensus {
            energy_flux: vec![],
            spectral_flux: vec![],
            hfc: vec![],
            hpss: vec![],
        };
        
        let weights = [0.25, 0.25, 0.25, 0.25];
        let candidates = vote_onsets(consensus, weights, 50, 44100).unwrap();
        
        assert!(candidates.is_empty());
    }
    
    #[test]
    fn test_consensus_voting_sorted_by_confidence() {
        // Test: results should be sorted by confidence (highest first)
        let consensus = OnsetConsensus {
            energy_flux: vec![1000, 20000], // First: all methods
            spectral_flux: vec![1050, 20050],
            hfc: vec![980, 20100],
            hpss: vec![1020, 19950],
            // Second: only one method
        };
        
        // Add a third onset detected by 2 methods
        let mut consensus2 = consensus.clone();
        consensus2.energy_flux.push(50000);
        consensus2.spectral_flux.push(50500);
        
        let weights = [0.25, 0.25, 0.25, 0.25];
        let candidates = vote_onsets(consensus2, weights, 50, 44100).unwrap();
        
        // Should have 3 candidates, sorted by confidence
        assert!(candidates.len() >= 2);
        
        // First should have highest confidence (all 4 methods)
        assert_eq!(candidates[0].voted_by, 4);
        
        // Confidence should be decreasing
        for i in 1..candidates.len() {
            assert!(candidates[i].confidence <= candidates[i-1].confidence);
        }
    }
    
    #[test]
    fn test_consensus_voting_invalid_parameters() {
        let consensus = OnsetConsensus {
            energy_flux: vec![1000],
            spectral_flux: vec![],
            hfc: vec![],
            hpss: vec![],
        };
        
        let weights = [0.25, 0.25, 0.25, 0.25];
        
        // Test zero sample rate
        let result = vote_onsets(consensus.clone(), weights, 50, 0);
        assert!(result.is_err());
        
        // Test zero tolerance
        let result = vote_onsets(consensus.clone(), weights, 0, 44100);
        assert!(result.is_err());
        
        // Test negative weights
        let negative_weights = [-0.1, 0.25, 0.25, 0.25];
        let result = vote_onsets(consensus, negative_weights, 50, 44100);
        assert!(result.is_err());
    }
    
    #[test]
    fn test_consensus_voting_time_conversion() {
        // Test: verify time_seconds is calculated correctly
        let consensus = OnsetConsensus {
            energy_flux: vec![44100], // 1 second at 44.1kHz
            spectral_flux: vec![],
            hfc: vec![],
            hpss: vec![],
        };
        
        let weights = [1.0, 0.0, 0.0, 0.0];
        let candidates = vote_onsets(consensus, weights, 50, 44100).unwrap();
        
        assert_eq!(candidates.len(), 1);
        assert!((candidates[0].time_seconds - 1.0).abs() < 0.001);
    }
}