Skip to main content

resonant_analysis/
chroma.rs

1//! Chroma features — 12-bin pitch class profiles.
2//!
3//! A chroma vector maps spectral energy onto the 12 pitch classes
4//! (C, C#, D, ..., B), collapsing octave information. Chroma features are
5//! useful for chord recognition, key detection, and music similarity.
6//!
7//! The extractor computes an STFT, maps each FFT bin to a pitch class based
8//! on its frequency, and accumulates energy per class.
9
10extern crate alloc;
11
12use alloc::vec;
13use alloc::vec::Vec;
14
15use resonant_core::signal::Signal;
16use resonant_core::window;
17use resonant_fft::stft::Stft;
18use resonant_fft::SignalFreqExt;
19
20use crate::error::AnalysisError;
21
22/// Names of the 12 pitch classes, indexed 0–11.
23pub const PITCH_CLASS_NAMES: [&str; 12] = [
24    "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B",
25];
26
27/// A 12-bin chroma vector representing energy per pitch class.
28///
29/// Bins are ordered: C, C#, D, D#, E, F, F#, G, G#, A, A#, B.
30///
31/// # Examples
32///
33/// ```
34/// use resonant_analysis::chroma::ChromaVector;
35///
36/// let cv = ChromaVector { bins: [0.0; 12] };
37/// assert_eq!(cv.bins.len(), 12);
38/// ```
39#[derive(Debug, Clone, Copy, PartialEq)]
40#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
41pub struct ChromaVector {
42    /// Energy in each of the 12 pitch classes.
43    pub bins: [f32; 12],
44}
45
46impl ChromaVector {
47    /// Returns the index (0–11) of the dominant pitch class.
48    #[must_use]
49    pub fn dominant_class(&self) -> usize {
50        self.bins
51            .iter()
52            .enumerate()
53            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal))
54            .map(|(i, _)| i)
55            .unwrap_or(0)
56    }
57
58    /// Returns the name of the dominant pitch class.
59    #[must_use]
60    pub fn dominant_name(&self) -> &'static str {
61        PITCH_CLASS_NAMES[self.dominant_class()]
62    }
63}
64
65/// Chroma feature extractor with configurable STFT and tuning parameters.
66///
67/// # Examples
68///
69/// ```
70/// use resonant_analysis::chroma::ChromaExtractor;
71///
72/// let extractor = ChromaExtractor::new(44100.0);
73/// let samples = vec![0.0_f32; 8192];
74/// let chroma = extractor.extract(&samples).unwrap();
75/// for cv in &chroma {
76///     assert_eq!(cv.bins.len(), 12);
77/// }
78/// ```
79#[derive(Debug, Clone)]
80pub struct ChromaExtractor {
81    sample_rate: f32,
82    window_size: usize,
83    hop_size: usize,
84    tuning_hz: f32,
85}
86
87impl ChromaExtractor {
88    /// Creates an extractor with default parameters.
89    ///
90    /// Defaults: window 4096, hop 2048, A4 = 440 Hz tuning.
91    #[must_use]
92    pub fn new(sample_rate: f32) -> Self {
93        Self {
94            sample_rate,
95            window_size: 4096,
96            hop_size: 2048,
97            tuning_hz: 440.0,
98        }
99    }
100
101    /// Sets the STFT window size (should be a power of two).
102    #[must_use]
103    pub fn with_window_size(mut self, size: usize) -> Self {
104        self.window_size = size;
105        self
106    }
107
108    /// Sets the STFT hop size.
109    #[must_use]
110    pub fn with_hop_size(mut self, hop: usize) -> Self {
111        self.hop_size = hop;
112        self
113    }
114
115    /// Sets the tuning reference frequency for A4 (default 440 Hz).
116    #[must_use]
117    pub fn with_tuning(mut self, hz: f32) -> Self {
118        self.tuning_hz = hz;
119        self
120    }
121
122    /// Extracts chroma vectors from mono audio samples.
123    ///
124    /// Returns one [`ChromaVector`] per STFT frame.
125    ///
126    /// # Errors
127    ///
128    /// Returns [`AnalysisError::EmptyInput`] if `samples` is empty.
129    pub fn extract(&self, samples: &[f32]) -> Result<Vec<ChromaVector>, AnalysisError> {
130        if samples.is_empty() {
131            return Err(AnalysisError::EmptyInput);
132        }
133
134        // If signal is shorter than one window, return a single zero chroma
135        if samples.len() < self.window_size {
136            return Ok(vec![ChromaVector { bins: [0.0; 12] }]);
137        }
138
139        let signal = Signal::from_samples(samples.to_vec());
140        let stft = Stft::builder(self.window_size, self.hop_size)
141            .window_fn(window::hann)
142            .build();
143
144        let stft_frames = stft.analyze(&signal)?;
145        if stft_frames.is_empty() {
146            return Ok(vec![ChromaVector { bins: [0.0; 12] }]);
147        }
148
149        // Precompute the pitch class map: FFT bin index → pitch class (0–11),
150        // or None for bins below the audible range.
151        let chroma_map = build_chroma_map(self.window_size, self.sample_rate, self.tuning_hz);
152
153        let mut result = Vec::with_capacity(stft_frames.len());
154
155        for frame in &stft_frames {
156            let magnitudes = frame.magnitude();
157            let mut bins = [0.0_f32; 12];
158
159            for (bin_idx, &mag) in magnitudes.iter().enumerate() {
160                if let Some(pitch_class) = chroma_map.get(bin_idx).copied().flatten() {
161                    bins[pitch_class] += mag * mag; // accumulate power
162                }
163            }
164
165            // Normalize: divide by max to get [0, 1] range, or leave zero
166            let max = bins.iter().copied().fold(0.0_f32, f32::max);
167            if max > f32::EPSILON {
168                for b in &mut bins {
169                    *b /= max;
170                }
171            }
172
173            result.push(ChromaVector { bins });
174        }
175
176        Ok(result)
177    }
178}
179
180/// Maps a frequency in Hz to a pitch class index (0 = C, 9 = A, etc.).
181///
182/// Returns `None` for frequencies below C1 (~32.7 Hz) or non-positive.
183fn frequency_to_pitch_class(freq_hz: f32, tuning_hz: f32) -> Option<usize> {
184    if freq_hz <= 0.0 || tuning_hz <= 0.0 {
185        return None;
186    }
187    // A4 = tuning_hz, MIDI note 69. Pitch class = midi_note % 12.
188    // midi_note = 69 + 12 * log2(freq / tuning)
189    // We only need the fractional part mod 12.
190    let semitones_from_a4 = 12.0 * (freq_hz / tuning_hz).log2();
191    // A is pitch class 9. So pitch_class = (9 + round(semitones)) % 12.
192    let midi_approx = 69.0 + semitones_from_a4;
193    if midi_approx < 24.0 {
194        // Below C1 — too low to assign meaningfully
195        return None;
196    }
197    let pitch_class = midi_approx.round() as i32 % 12;
198    // Ensure non-negative modulo
199    Some(((pitch_class + 12) % 12) as usize)
200}
201
202/// Precomputes a lookup table mapping each FFT bin to its pitch class.
203///
204/// Returns `Vec<Option<usize>>` of length `fft_size / 2 + 1`.
205fn build_chroma_map(fft_size: usize, sample_rate: f32, tuning_hz: f32) -> Vec<Option<usize>> {
206    let num_bins = fft_size / 2 + 1;
207    let bin_freq = sample_rate / fft_size as f32;
208
209    (0..num_bins)
210        .map(|bin| {
211            let freq = bin as f32 * bin_freq;
212            frequency_to_pitch_class(freq, tuning_hz)
213        })
214        .collect()
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use core::f32::consts::PI;
221
222    const SR: f32 = 44100.0;
223
224    #[test]
225    fn a4_maps_to_pitch_class_9() {
226        let pc = frequency_to_pitch_class(440.0, 440.0);
227        assert_eq!(pc, Some(9)); // A = 9
228    }
229
230    #[test]
231    fn c4_maps_to_pitch_class_0() {
232        // C4 ≈ 261.63 Hz
233        let pc = frequency_to_pitch_class(261.63, 440.0);
234        assert_eq!(pc, Some(0)); // C = 0
235    }
236
237    #[test]
238    fn e4_maps_to_pitch_class_4() {
239        // E4 ≈ 329.63 Hz
240        let pc = frequency_to_pitch_class(329.63, 440.0);
241        assert_eq!(pc, Some(4)); // E = 4
242    }
243
244    #[test]
245    fn very_low_frequency_returns_none() {
246        let pc = frequency_to_pitch_class(10.0, 440.0);
247        assert_eq!(pc, None);
248    }
249
250    #[test]
251    fn zero_frequency_returns_none() {
252        assert_eq!(frequency_to_pitch_class(0.0, 440.0), None);
253    }
254
255    #[test]
256    fn negative_frequency_returns_none() {
257        assert_eq!(frequency_to_pitch_class(-100.0, 440.0), None);
258    }
259
260    #[test]
261    fn octave_equivalence() {
262        // A3 (220 Hz) and A5 (880 Hz) should both map to pitch class 9
263        assert_eq!(frequency_to_pitch_class(220.0, 440.0), Some(9));
264        assert_eq!(frequency_to_pitch_class(880.0, 440.0), Some(9));
265    }
266
267    #[test]
268    fn chroma_map_length() {
269        let map = build_chroma_map(4096, SR, 440.0);
270        assert_eq!(map.len(), 2049); // 4096/2 + 1
271    }
272
273    #[test]
274    fn chroma_map_dc_is_none() {
275        let map = build_chroma_map(4096, SR, 440.0);
276        assert_eq!(map[0], None); // DC bin = 0 Hz
277    }
278
279    #[test]
280    fn pure_a4_dominates_a_bin() {
281        let samples: Vec<f32> = (0..16384)
282            .map(|i| (2.0 * PI * 440.0 * i as f32 / SR).sin())
283            .collect();
284        let extractor = ChromaExtractor::new(SR);
285        let chroma = extractor.extract(&samples).unwrap();
286        assert!(!chroma.is_empty());
287        // Most frames should have A (bin 9) as dominant
288        let a_dominant = chroma.iter().filter(|cv| cv.dominant_class() == 9).count();
289        assert!(
290            a_dominant > chroma.len() / 2,
291            "A4 should dominate, but only {a_dominant}/{} frames had A dominant",
292            chroma.len()
293        );
294    }
295
296    #[test]
297    fn pure_c4_dominates_c_bin() {
298        // C4 ≈ 261.63 Hz
299        let samples: Vec<f32> = (0..16384)
300            .map(|i| (2.0 * PI * 261.63 * i as f32 / SR).sin())
301            .collect();
302        let extractor = ChromaExtractor::new(SR);
303        let chroma = extractor.extract(&samples).unwrap();
304        assert!(!chroma.is_empty());
305        let c_dominant = chroma.iter().filter(|cv| cv.dominant_class() == 0).count();
306        assert!(
307            c_dominant > chroma.len() / 2,
308            "C4 should dominate, but only {c_dominant}/{} frames had C dominant",
309            chroma.len()
310        );
311    }
312
313    #[test]
314    fn silence_all_near_zero() {
315        let samples = vec![0.0_f32; 8192];
316        let extractor = ChromaExtractor::new(SR);
317        let chroma = extractor.extract(&samples).unwrap();
318        for cv in &chroma {
319            let sum: f32 = cv.bins.iter().sum();
320            assert!(
321                sum < 1e-6,
322                "silence should have near-zero chroma, got {sum}"
323            );
324        }
325    }
326
327    #[test]
328    fn empty_input_error() {
329        let extractor = ChromaExtractor::new(SR);
330        assert_eq!(extractor.extract(&[]), Err(AnalysisError::EmptyInput));
331    }
332
333    #[test]
334    fn short_signal_returns_zero_chroma() {
335        let extractor = ChromaExtractor::new(SR).with_window_size(4096);
336        let chroma = extractor.extract(&[1.0; 2048]).unwrap();
337        assert_eq!(chroma.len(), 1);
338        assert_eq!(chroma[0].bins, [0.0; 12]);
339    }
340
341    #[test]
342    fn normalized_bins() {
343        // Non-silent signal: max bin should be 1.0 after normalization
344        let samples: Vec<f32> = (0..16384)
345            .map(|i| (2.0 * PI * 440.0 * i as f32 / SR).sin())
346            .collect();
347        let extractor = ChromaExtractor::new(SR);
348        let chroma = extractor.extract(&samples).unwrap();
349        for cv in &chroma {
350            let max = cv.bins.iter().copied().fold(0.0_f32, f32::max);
351            if max > 0.0 {
352                assert!(
353                    (max - 1.0).abs() < 1e-6,
354                    "max bin should be 1.0 after normalization, got {max}"
355                );
356            }
357        }
358    }
359
360    #[test]
361    fn dominant_name_matches_class() {
362        let mut cv = ChromaVector { bins: [0.0; 12] };
363        cv.bins[9] = 1.0; // A
364        assert_eq!(cv.dominant_name(), "A");
365        cv.bins[0] = 2.0; // C is now larger
366        assert_eq!(cv.dominant_name(), "C");
367    }
368
369    #[test]
370    fn builder_methods() {
371        let e = ChromaExtractor::new(SR)
372            .with_window_size(8192)
373            .with_hop_size(4096)
374            .with_tuning(442.0);
375        assert_eq!(e.window_size, 8192);
376        assert_eq!(e.hop_size, 4096);
377        assert_eq!(e.tuning_hz, 442.0);
378    }
379
380    #[test]
381    fn custom_tuning() {
382        // A4 at 442 Hz tuning — A should still dominate
383        let samples: Vec<f32> = (0..16384)
384            .map(|i| (2.0 * PI * 442.0 * i as f32 / SR).sin())
385            .collect();
386        let extractor = ChromaExtractor::new(SR).with_tuning(442.0);
387        let chroma = extractor.extract(&samples).unwrap();
388        let a_dominant = chroma.iter().filter(|cv| cv.dominant_class() == 9).count();
389        assert!(
390            a_dominant > chroma.len() / 2,
391            "442 Hz with 442 tuning should map to A"
392        );
393    }
394
395    #[test]
396    fn frame_count_matches_expected() {
397        let n_samples = 16384;
398        let window = 4096;
399        let hop = 2048;
400        let samples = vec![0.0_f32; n_samples];
401        let extractor = ChromaExtractor::new(SR)
402            .with_window_size(window)
403            .with_hop_size(hop);
404        let chroma = extractor.extract(&samples).unwrap();
405        let expected_frames = (n_samples - window) / hop + 1;
406        assert_eq!(
407            chroma.len(),
408            expected_frames,
409            "expected {expected_frames} frames, got {}",
410            chroma.len()
411        );
412    }
413
414    #[test]
415    fn no_nan_or_inf() {
416        let samples: Vec<f32> = (0..8192)
417            .map(|i| (i as f32 * 7.3).sin() * (i as f32 * 13.7).cos())
418            .collect();
419        let extractor = ChromaExtractor::new(SR);
420        let chroma = extractor.extract(&samples).unwrap();
421        for cv in &chroma {
422            for &b in &cv.bins {
423                assert!(b.is_finite(), "non-finite chroma bin: {b}");
424            }
425        }
426    }
427
428    #[cfg(feature = "serde")]
429    #[test]
430    fn chroma_vector_serde_roundtrip() {
431        let mut cv = ChromaVector { bins: [0.0; 12] };
432        cv.bins[9] = 1.0; // A
433        cv.bins[1] = 0.8; // C#
434        let json =
435            serde_json::to_string(&cv).unwrap_or_else(|e| panic!("serialize ChromaVector: {e}"));
436        let back: ChromaVector =
437            serde_json::from_str(&json).unwrap_or_else(|e| panic!("deserialize ChromaVector: {e}"));
438        assert_eq!(cv, back);
439    }
440}