Skip to main content

ling_audio/
fft.rs

1//! Real-time FFT analysis for frequency-driven visuals.
2//!
3//! Usage:
4//! ```
5//! use ling_audio::FftAnalyzer;
6//! let mut fft = FftAnalyzer::new(2048, 44100);
7//! let audio_frame = vec![0.0f32; 2048];
8//! fft.push_samples(&audio_frame);
9//! let bands = fft.freq_bands(32); // 32 log-spaced frequency bands
10//! let beat  = fft.is_beat();
11//! let _ = (bands, beat);
12//! ```
13
14use rustfft::{FftPlanner, num_complex::Complex};
15
16/// Frequency analysis window shapes.
17#[derive(Clone, Copy, Debug)]
18pub enum Window { Hann, Hamming, Blackman, Rectangular }
19
20impl Window {
21    fn apply(self, buf: &mut [f32]) {
22        let n = buf.len();
23        for (i, s) in buf.iter_mut().enumerate() {
24            let w = match self {
25                Self::Hann =>
26                    0.5 * (1.0 - (2.0 * std::f32::consts::PI * i as f32 / (n - 1) as f32).cos()),
27                Self::Hamming =>
28                    0.54 - 0.46 * (2.0 * std::f32::consts::PI * i as f32 / (n - 1) as f32).cos(),
29                Self::Blackman => {
30                    let t = 2.0 * std::f32::consts::PI * i as f32 / (n - 1) as f32;
31                    0.42 - 0.5 * t.cos() + 0.08 * (2.0 * t).cos()
32                }
33                Self::Rectangular => 1.0,
34            };
35            *s *= w;
36        }
37    }
38}
39
40/// Holds the rolling sample buffer and performs FFT analysis.
41pub struct FftAnalyzer {
42    fft_size:    usize,
43    sample_rate: u32,
44    buffer:      Vec<f32>,
45    window:      Window,
46    /// Latest magnitude spectrum (fft_size/2 bins).
47    pub magnitudes: Vec<f32>,
48    /// Smoothed magnitudes for visuals.
49    pub smoothed:   Vec<f32>,
50    /// Beat detection state.
51    beat_energy:    f32,
52    beat_avg:       f32,
53    planner:        FftPlanner<f32>,
54}
55
56impl FftAnalyzer {
57    /// Create an analyser with the given FFT window size and sample rate.
58    /// Typical sizes: 512, 1024, 2048, 4096.
59    pub fn new(fft_size: usize, sample_rate: u32) -> Self {
60        let bins = fft_size / 2;
61        Self {
62            fft_size,
63            sample_rate,
64            buffer:      vec![0.0; fft_size],
65            window:      Window::Hann,
66            magnitudes:  vec![0.0; bins],
67            smoothed:    vec![0.0; bins],
68            beat_energy: 0.0,
69            beat_avg:    0.001,
70            planner:     FftPlanner::new(),
71        }
72    }
73
74    pub fn set_window(&mut self, w: Window) { self.window = w; }
75
76    /// Push new audio samples (mono f32).  Keeps a rolling window.
77    pub fn push_samples(&mut self, samples: &[f32]) {
78        let n = samples.len().min(self.fft_size);
79        let shift = self.fft_size - n;
80        self.buffer.copy_within(n.., 0);
81        self.buffer[shift..].copy_from_slice(&samples[..n]);
82        self.compute();
83    }
84
85    fn compute(&mut self) {
86        let fft = self.planner.plan_fft_forward(self.fft_size);
87        let mut buf: Vec<Complex<f32>> = self.buffer.iter()
88            .cloned()
89            .map(|s| Complex { re: s, im: 0.0 })
90            .collect();
91
92        // Apply window function.
93        {
94            let mut real: Vec<f32> = buf.iter().map(|c| c.re).collect();
95            self.window.apply(&mut real);
96            for (c, &r) in buf.iter_mut().zip(real.iter()) { c.re = r; }
97        }
98
99        fft.process(&mut buf);
100
101        // Compute magnitude spectrum, normalised by FFT size.
102        let scale = 2.0 / self.fft_size as f32;
103        let mut energy = 0.0f32;
104        for (i, mag) in self.magnitudes.iter_mut().enumerate() {
105            let m = buf[i].norm() * scale;
106            *mag = m;
107            energy += m * m;
108        }
109
110        // Smooth: exponential moving average.
111        let smooth = 0.82;
112        for (s, &m) in self.smoothed.iter_mut().zip(self.magnitudes.iter()) {
113            *s = *s * smooth + m * (1.0 - smooth);
114        }
115
116        // Beat detection: compare current energy to rolling average.
117        self.beat_energy = energy / self.magnitudes.len() as f32;
118        self.beat_avg = self.beat_avg * 0.992 + self.beat_energy * 0.008;
119    }
120
121    // ── Frequency analysis ────────────────────────────────────────────────────
122
123    /// Return `bands` logarithmically-spaced frequency magnitudes.
124    /// Index 0 = bass, index bands-1 = treble.
125    pub fn freq_bands(&self, bands: usize) -> Vec<f32> {
126        if bands == 0 { return vec![]; }
127        let nyq   = self.sample_rate as f32 / 2.0;
128        let bins  = self.magnitudes.len();
129        let f_min = 20.0f32;
130        let f_max = nyq;
131        let log_lo = f_min.log2();
132        let log_hi = f_max.log2();
133
134        (0..bands).map(|b| {
135            let lo = 2.0f32.powf(log_lo + (b as f32 / bands as f32) * (log_hi - log_lo));
136            let hi = 2.0f32.powf(log_lo + ((b + 1) as f32 / bands as f32) * (log_hi - log_lo));
137            let bin_lo = ((lo / nyq) * bins as f32) as usize;
138            let bin_hi = ((hi / nyq) * bins as f32) as usize + 1;
139            let bin_lo = bin_lo.min(bins - 1);
140            let bin_hi = bin_hi.min(bins);
141            if bin_hi <= bin_lo {
142                self.smoothed[bin_lo]
143            } else {
144                self.smoothed[bin_lo..bin_hi].iter().cloned().fold(0.0f32, f32::max)
145            }
146        }).collect()
147    }
148
149    /// Dominant frequency in Hz (peak of magnitude spectrum).
150    pub fn dominant_freq(&self) -> f32 {
151        let nyq = self.sample_rate as f32 / 2.0;
152        let bins = self.magnitudes.len();
153        let peak_bin = self.magnitudes.iter()
154            .enumerate()
155            .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
156            .map(|(i, _)| i)
157            .unwrap_or(0);
158        peak_bin as f32 / bins as f32 * nyq
159    }
160
161    /// RMS level of the current window.
162    pub fn rms(&self) -> f32 {
163        (self.buffer.iter().map(|s| s * s).sum::<f32>() / self.fft_size as f32).sqrt()
164    }
165
166    /// Beat detection: true if current frame energy significantly exceeds average.
167    pub fn is_beat(&self) -> bool {
168        self.beat_energy > self.beat_avg * 1.5
169    }
170
171    /// Beat energy ratio (1.0 = at threshold, >1 = strong beat).
172    pub fn beat_ratio(&self) -> f32 {
173        self.beat_energy / self.beat_avg.max(1e-6)
174    }
175}
176
177// ── Color palettes ─────────────────────────────────────────────────────────────
178
179/// IQ cosine palette: color(t) = a + b * cos(2π * (c*t + d))
180#[derive(Clone, Debug)]
181pub struct CosPalette {
182    pub a: [f32; 3],  // offset
183    pub b: [f32; 3],  // amplitude
184    pub c: [f32; 3],  // frequency
185    pub d: [f32; 3],  // phase
186}
187
188impl CosPalette {
189    /// Classic rainbow palette.
190    pub fn rainbow() -> Self {
191        Self {
192            a: [0.5, 0.5, 0.5],
193            b: [0.5, 0.5, 0.5],
194            c: [1.0, 1.0, 1.0],
195            d: [0.0, 0.333, 0.667],
196        }
197    }
198
199    /// Warm fire: red → orange → yellow.
200    pub fn fire() -> Self {
201        Self {
202            a: [0.8, 0.4, 0.1],
203            b: [0.7, 0.3, 0.1],
204            c: [1.0, 0.5, 0.3],
205            d: [0.0, 0.5, 0.8],
206        }
207    }
208
209    /// Deep ocean: blue → cyan → white.
210    pub fn ocean() -> Self {
211        Self {
212            a: [0.1, 0.4, 0.7],
213            b: [0.3, 0.3, 0.4],
214            c: [0.8, 1.0, 0.5],
215            d: [0.3, 0.0, 0.6],
216        }
217    }
218
219    /// Psychedelic high-saturation cycling.
220    pub fn psychedelic() -> Self {
221        Self {
222            a: [0.5, 0.5, 0.5],
223            b: [0.8, 0.8, 0.8],
224            c: [1.0, 1.3, 0.7],
225            d: [0.0, 0.15, 0.3],
226        }
227    }
228
229    /// Bass (red) → mid (green) → high freq (blue/violet).
230    pub fn frequency() -> Self {
231        Self {
232            a: [0.5, 0.4, 0.5],
233            b: [0.5, 0.3, 0.5],
234            c: [0.5, 0.5, 1.0],
235            d: [0.0, 0.33, 0.67],
236        }
237    }
238
239    /// Evaluate the palette at parameter `t` (0..1), returns `[r, g, b]` in 0..1.
240    pub fn eval(&self, t: f32) -> [f32; 3] {
241        [0, 1, 2].map(|i| {
242            let v = self.a[i] + self.b[i] * (2.0 * std::f32::consts::PI * (self.c[i] * t + self.d[i])).cos();
243            v.clamp(0.0, 1.0)
244        })
245    }
246
247    /// Evaluate with time-shifted cycle (for animation).
248    pub fn eval_animated(&self, t: f32, time: f32, speed: f32) -> [f32; 3] {
249        self.eval((t + time * speed) % 1.0)
250    }
251
252    /// Map an array of frequency band magnitudes to colors.
253    /// `magnitudes`: 0..1 per band.  Returns `bands` RGBA pixels.
254    pub fn map_bands(&self, magnitudes: &[f32], time: f32, speed: f32) -> Vec<[u8; 4]> {
255        magnitudes.iter().enumerate().map(|(i, &m)| {
256            let t = i as f32 / magnitudes.len() as f32;
257            let [r, g, b] = self.eval_animated(t, time, speed);
258            let brightness = m.clamp(0.0, 1.0);
259            [(r * brightness * 255.0) as u8, (g * brightness * 255.0) as u8, (b * brightness * 255.0) as u8, (brightness * 255.0) as u8]
260        }).collect()
261    }
262}
263
264/// Generate an RGBA texture (width × height) where each column is a frequency band,
265/// brightness is magnitude, and colour is palette-based.
266pub fn freq_texture(bands: &[f32], palette: &CosPalette, width: u32, height: u32, time: f32) -> Vec<u8> {
267    let w = width as usize;
268    let h = height as usize;
269    let mut pixels = vec![0u8; w * h * 4];
270    for x in 0..w {
271        let band_idx = (x * bands.len() / w).min(bands.len() - 1);
272        let mag      = bands[band_idx].clamp(0.0, 1.0);
273        let fill_h   = (mag * h as f32) as usize;
274        let t        = x as f32 / w as f32;
275        let [r, g, b] = palette.eval_animated(t, time, 0.3);
276        for y in (h - fill_h)..h {
277            let alpha = (mag * (1.0 - (y as f32 / h as f32)) * 1.5).clamp(0.0, 1.0);
278            let idx = (y * w + x) * 4;
279            pixels[idx    ] = (r * 255.0) as u8;
280            pixels[idx + 1] = (g * 255.0) as u8;
281            pixels[idx + 2] = (b * 255.0) as u8;
282            pixels[idx + 3] = (alpha * 255.0) as u8;
283        }
284    }
285    pixels
286}
287
288/// Build a circular waveform / scope texture (Lissajous-style).
289pub fn waveform_texture(samples: &[f32], palette: &CosPalette, width: u32, height: u32, time: f32) -> Vec<u8> {
290    let w = width as usize;
291    let h = height as usize;
292    let mut pixels = vec![0u8; w * h * 4];
293    for (i, &s) in samples.iter().enumerate() {
294        let x = i * w / samples.len();
295        let y = ((s * 0.5 + 0.5) * h as f32).clamp(0.0, h as f32 - 1.0) as usize;
296        let t = i as f32 / samples.len() as f32;
297        let [r, g, b] = palette.eval_animated(t, time, 0.2);
298        let idx = (y * w + x) * 4;
299        pixels[idx    ] = (r * 255.0) as u8;
300        pixels[idx + 1] = (g * 255.0) as u8;
301        pixels[idx + 2] = (b * 255.0) as u8;
302        pixels[idx + 3] = 255;
303    }
304    pixels
305}