1extern 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
22pub const PITCH_CLASS_NAMES: [&str; 12] = [
24 "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B",
25];
26
27#[derive(Debug, Clone, Copy, PartialEq)]
40#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
41pub struct ChromaVector {
42 pub bins: [f32; 12],
44}
45
46impl ChromaVector {
47 #[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 #[must_use]
60 pub fn dominant_name(&self) -> &'static str {
61 PITCH_CLASS_NAMES[self.dominant_class()]
62 }
63}
64
65#[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 #[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 #[must_use]
103 pub fn with_window_size(mut self, size: usize) -> Self {
104 self.window_size = size;
105 self
106 }
107
108 #[must_use]
110 pub fn with_hop_size(mut self, hop: usize) -> Self {
111 self.hop_size = hop;
112 self
113 }
114
115 #[must_use]
117 pub fn with_tuning(mut self, hz: f32) -> Self {
118 self.tuning_hz = hz;
119 self
120 }
121
122 pub fn extract(&self, samples: &[f32]) -> Result<Vec<ChromaVector>, AnalysisError> {
130 if samples.is_empty() {
131 return Err(AnalysisError::EmptyInput);
132 }
133
134 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 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; }
163 }
164
165 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
180fn 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 let semitones_from_a4 = 12.0 * (freq_hz / tuning_hz).log2();
191 let midi_approx = 69.0 + semitones_from_a4;
193 if midi_approx < 24.0 {
194 return None;
196 }
197 let pitch_class = midi_approx.round() as i32 % 12;
198 Some(((pitch_class + 12) % 12) as usize)
200}
201
202fn 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)); }
229
230 #[test]
231 fn c4_maps_to_pitch_class_0() {
232 let pc = frequency_to_pitch_class(261.63, 440.0);
234 assert_eq!(pc, Some(0)); }
236
237 #[test]
238 fn e4_maps_to_pitch_class_4() {
239 let pc = frequency_to_pitch_class(329.63, 440.0);
241 assert_eq!(pc, Some(4)); }
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 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); }
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); }
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 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 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 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; assert_eq!(cv.dominant_name(), "A");
365 cv.bins[0] = 2.0; 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 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; cv.bins[1] = 0.8; 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}