Skip to main content

aether_timbre/
analysis.rs

1//! Spectral analysis — extract the timbre fingerprint of an instrument.
2
3use rustfft::{FftPlanner, num_complex::Complex};
4
5/// The spectral envelope of an instrument — its timbre fingerprint.
6/// Stored as magnitude values across frequency bins.
7#[derive(Debug, Clone)]
8pub struct SpectralEnvelope {
9    /// Magnitude per frequency bin (linear scale).
10    pub magnitudes: Vec<f32>,
11    /// FFT size used.
12    pub fft_size: usize,
13    /// Sample rate of the source audio.
14    pub sample_rate: f32,
15}
16
17impl SpectralEnvelope {
18    /// Analyze a buffer of audio and extract its spectral envelope.
19    pub fn analyze(audio: &[f32], sample_rate: f32, fft_size: usize) -> Self {
20        let fft_size = fft_size.next_power_of_two();
21        let mut planner = FftPlanner::<f32>::new();
22        let fft = planner.plan_fft_forward(fft_size);
23
24        // Average multiple frames for a stable envelope
25        let hop = fft_size / 4;
26        let num_frames = (audio.len().saturating_sub(fft_size)) / hop + 1;
27        let _ = num_frames; // used for documentation purposes only
28        let mut avg_magnitudes = vec![0.0f32; fft_size / 2 + 1];
29
30        let window: Vec<f32> = (0..fft_size)
31            .map(|i| 0.5 * (1.0 - (2.0 * std::f32::consts::PI * i as f32 / (fft_size - 1) as f32).cos()))
32            .collect();
33
34        let mut frame_count = 0;
35        let mut offset = 0;
36        while offset + fft_size <= audio.len() {
37            let mut buf: Vec<Complex<f32>> = audio[offset..offset + fft_size]
38                .iter()
39                .zip(window.iter())
40                .map(|(&s, &w)| Complex::new(s * w, 0.0))
41                .collect();
42
43            fft.process(&mut buf);
44
45            for (i, mag) in avg_magnitudes.iter_mut().enumerate() {
46                *mag += buf[i].norm();
47            }
48            frame_count += 1;
49            offset += hop;
50        }
51
52        if frame_count > 0 {
53            for m in avg_magnitudes.iter_mut() {
54                *m /= frame_count as f32;
55            }
56        }
57
58        // Smooth the envelope (moving average)
59        let smoothed = smooth_envelope(&avg_magnitudes, 8);
60
61        Self {
62            magnitudes: smoothed,
63            fft_size,
64            sample_rate,
65        }
66    }
67
68    /// Normalize to peak = 1.0.
69    pub fn normalize(&mut self) {
70        let peak = self.magnitudes.iter().cloned().fold(0.0f32, f32::max);
71        if peak > 0.0 {
72            for m in self.magnitudes.iter_mut() {
73                *m /= peak;
74            }
75        }
76    }
77
78    /// Interpolate magnitude at a given frequency (Hz).
79    pub fn magnitude_at_freq(&self, freq: f32) -> f32 {
80        let bin_width = self.sample_rate / self.fft_size as f32;
81        let bin = freq / bin_width;
82        let bin_floor = bin as usize;
83        let frac = bin - bin_floor as f32;
84        let m0 = self.magnitudes.get(bin_floor).copied().unwrap_or(0.0);
85        let m1 = self.magnitudes.get(bin_floor + 1).copied().unwrap_or(0.0);
86        m0 + (m1 - m0) * frac
87    }
88}
89
90fn smooth_envelope(mags: &[f32], window: usize) -> Vec<f32> {
91    let n = mags.len();
92    let mut out = vec![0.0f32; n];
93    for i in 0..n {
94        let start = i.saturating_sub(window / 2);
95        let end = (i + window / 2 + 1).min(n);
96        let sum: f32 = mags[start..end].iter().sum();
97        out[i] = sum / (end - start) as f32;
98    }
99    out
100}
101
102/// A complete timbre profile — multiple envelopes at different pitches/velocities.
103#[derive(Debug, Clone)]
104pub struct TimbreProfile {
105    pub name: String,
106    pub description: String,
107    /// Envelopes at different MIDI notes (for pitch-dependent timbre).
108    pub envelopes: Vec<(u8, SpectralEnvelope)>, // (midi_note, envelope)
109    pub sample_rate: f32,
110    pub fft_size: usize,
111}
112
113impl TimbreProfile {
114    pub fn new(name: &str, sample_rate: f32, fft_size: usize) -> Self {
115        Self {
116            name: name.into(),
117            description: String::new(),
118            envelopes: Vec::new(),
119            sample_rate,
120            fft_size,
121        }
122    }
123
124    /// Add an envelope for a specific MIDI note.
125    pub fn add_envelope(&mut self, note: u8, audio: &[f32]) {
126        let mut env = SpectralEnvelope::analyze(audio, self.sample_rate, self.fft_size);
127        env.normalize();
128        self.envelopes.push((note, env));
129    }
130
131    /// Get the best matching envelope for a given MIDI note.
132    pub fn envelope_for_note(&self, note: u8) -> Option<&SpectralEnvelope> {
133        if self.envelopes.is_empty() { return None; }
134        let mut best_dist = u8::MAX;
135        for (n, _env) in &self.envelopes {
136            let dist = note.abs_diff(*n);
137            if dist < best_dist {
138                best_dist = dist;
139            }
140        }
141        // Return the envelope from the best match
142        self.envelopes.iter()
143            .min_by_key(|(n, _)| note.abs_diff(*n))
144            .map(|(_, e)| e)
145    }
146}