Skip to main content

qualia_core_db/audio/
tf_surface.rs

1//! P7.5 — Audio time-frequency SURFACE view over STFT/CQT rasters.
2//!
3//! The existing `audio::stft` and `audio::cqt_bake` modules produce
4//! time × frequency magnitude rasters. This module treats those rasters
5//! as a 2D manifold surface where:
6//! - **u axis** = time (frame index)
7//! - **v axis** = frequency (bin index)
8//! - **height** = magnitude (dB or linear)
9//!
10//! The surface supports:
11//! - Bilinear sampling at arbitrary (time, frequency) coordinates
12//! - Gradient computation (spectral flux, frequency derivative)
13//! - Ridge detection (local maxima along frequency axis per frame)
14//! - Surface-to-mesh conversion for visualisation
15//!
16//! ## Determinism
17//!
18//! All operations are deterministic: the raster is a fixed f32 matrix,
19//! and sampling/gradient/ridge are pure functions.
20
21// ───────────────────────────────────────────────────────────────────────────
22//  Types
23// ───────────────────────────────────────────────────────────────────────────
24
25/// A time-frequency surface: `frames × bins` magnitude raster.
26///
27/// The raster is row-major: `raster[frame * bins + bin]`.
28/// This is a view (borrowed) — no heap allocation.
29#[derive(Debug, Clone, Copy)]
30pub struct TfSurface<'a> {
31    pub raster: &'a [f32],
32    pub frame_count: usize,
33    pub bin_count: usize,
34    pub sample_rate: u32,
35    pub hop_size: usize,
36}
37
38impl<'a> TfSurface<'a> {
39    /// Create a surface view from a raw raster slice.
40    ///
41    /// `raster.len()` must equal `frame_count * bin_count`.
42    pub fn new(
43        raster: &'a [f32],
44        frame_count: usize,
45        bin_count: usize,
46        sample_rate: u32,
47        hop_size: usize,
48    ) -> Self {
49        debug_assert_eq!(raster.len(), frame_count * bin_count);
50        Self {
51            raster,
52            frame_count,
53            bin_count,
54            sample_rate,
55            hop_size,
56        }
57    }
58
59    /// Get the magnitude at `(frame, bin)`.
60    #[inline]
61    pub fn get(&self, frame: usize, bin: usize) -> f32 {
62        if frame >= self.frame_count || bin >= self.bin_count {
63            return 0.0;
64        }
65        self.raster[frame * self.bin_count + bin]
66    }
67
68    /// Time in seconds for a given frame index.
69    #[inline]
70    pub fn frame_to_time(&self, frame: usize) -> f32 {
71        if self.sample_rate == 0 || self.hop_size == 0 {
72            return 0.0;
73        }
74        frame as f32 * self.hop_size as f32 / self.sample_rate as f32
75    }
76
77    /// Frequency in Hz for a linear bin index (STFT).
78    #[inline]
79    pub fn bin_to_freq_linear(&self, bin: usize) -> f32 {
80        if self.sample_rate == 0 || self.bin_count == 0 {
81            return 0.0;
82        }
83        bin as f32 * (self.sample_rate as f32 / (2.0 * self.bin_count as f32))
84    }
85
86    /// Frequency in Hz for a log-spaced bin index (CQT).
87    /// `f_k = f_min * 2^(k / bins_per_octave)`
88    #[inline]
89    pub fn bin_to_freq_log(&self, bin: usize, f_min: f32, bins_per_octave: usize) -> f32 {
90        if bins_per_octave == 0 {
91            return 0.0;
92        }
93        f_min * 2.0_f32.powf(bin as f32 / bins_per_octave as f32)
94    }
95
96    /// Bilinear sample at fractional `(frame_f, bin_f)`.
97    #[inline]
98    pub fn sample_bilinear(&self, frame_f: f32, bin_f: f32) -> f32 {
99        let f0 = frame_f.floor() as isize;
100        let b0 = bin_f.floor() as isize;
101        let df = frame_f - f0 as f32;
102        let db = bin_f - b0 as f32;
103
104        let f0 = f0.max(0).min(self.frame_count as isize - 1) as usize;
105        let f1 = (f0 + 1).min(self.frame_count - 1);
106        let b0 = b0.max(0).min(self.bin_count as isize - 1) as usize;
107        let b1 = (b0 + 1).min(self.bin_count - 1);
108
109        let v00 = self.get(f0, b0);
110        let v01 = self.get(f0, b1);
111        let v10 = self.get(f1, b0);
112        let v11 = self.get(f1, b1);
113
114        let top = v00 * (1.0 - db) + v01 * db;
115        let bot = v10 * (1.0 - db) + v11 * db;
116        top * (1.0 - df) + bot * df
117    }
118
119    /// Spectral flux: sum of positive magnitude differences between
120    /// consecutive frames. Written to `out` (length `frame_count - 1`).
121    pub fn spectral_flux(&self, out: &mut [f32]) -> usize {
122        let n = self.frame_count.saturating_sub(1);
123        if out.len() < n {
124            return 0;
125        }
126        for f in 0..n {
127            let mut flux = 0.0f32;
128            for b in 0..self.bin_count {
129                let diff = self.get(f + 1, b) - self.get(f, b);
130                if diff > 0.0 {
131                    flux += diff * diff;
132                }
133            }
134            out[f] = flux.sqrt();
135        }
136        n
137    }
138
139    /// Frequency gradient at `(frame, bin)` — central difference.
140    #[inline]
141    pub fn freq_gradient(&self, frame: usize, bin: usize) -> f32 {
142        let prev = self.get(frame, bin.saturating_sub(1));
143        let next = self.get(frame, (bin + 1).min(self.bin_count - 1));
144        (next - prev) * 0.5
145    }
146
147    /// Time gradient at `(frame, bin)` — central difference.
148    #[inline]
149    pub fn time_gradient(&self, frame: usize, bin: usize) -> f32 {
150        let prev = self.get(frame.saturating_sub(1), bin);
151        let next = self.get((frame + 1).min(self.frame_count - 1), bin);
152        (next - prev) * 0.5
153    }
154
155    /// Find the ridge bin (peak magnitude) for a given frame.
156    /// Returns the bin index of the maximum.
157    pub fn ridge_bin(&self, frame: usize) -> usize {
158        if frame >= self.frame_count {
159            return 0;
160        }
161        let row = &self.raster[frame * self.bin_count..(frame + 1) * self.bin_count];
162        let (idx, _) = row
163            .iter()
164            .enumerate()
165            .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(core::cmp::Ordering::Equal))
166            .unwrap_or((0, &0.0));
167        idx
168    }
169
170    /// Collect ridge bins for all frames into `out`.
171    /// Returns the number of ridges written (= `frame_count`).
172    pub fn ridges(&self, out: &mut [usize]) -> usize {
173        let n = self.frame_count.min(out.len());
174        for f in 0..n {
175            out[f] = self.ridge_bin(f);
176        }
177        n
178    }
179
180    /// Convert to a height-map mesh: each (frame, bin) → (x, y, z)
181    /// where x = time, y = freq, z = magnitude.
182    /// Writes `frame_count * bin_count` vertices to `out_verts` as
183    /// `[x, y, z]` triples. Returns the number of vertices written.
184    pub fn to_height_mesh(&self, out_verts: &mut [f32]) -> usize {
185        let n = self.frame_count * self.bin_count;
186        if out_verts.len() < n * 3 {
187            return 0;
188        }
189        for f in 0..self.frame_count {
190            let t = self.frame_to_time(f);
191            for b in 0..self.bin_count {
192                let freq = self.bin_to_freq_linear(b);
193                let mag = self.get(f, b);
194                let idx = (f * self.bin_count + b) * 3;
195                out_verts[idx] = t;
196                out_verts[idx + 1] = freq;
197                out_verts[idx + 2] = mag;
198            }
199        }
200        n
201    }
202
203    /// Total energy (sum of all magnitudes).
204    pub fn total_energy(&self) -> f32 {
205        self.raster.iter().copied().sum()
206    }
207
208    /// Frame energy (sum of magnitudes in a single frame).
209    pub fn frame_energy(&self, frame: usize) -> f32 {
210        if frame >= self.frame_count {
211            return 0.0;
212        }
213        self.raster[frame * self.bin_count..(frame + 1) * self.bin_count]
214            .iter()
215            .copied()
216            .sum()
217    }
218}
219
220// ───────────────────────────────────────────────────────────────────────────
221//  Tests
222// ───────────────────────────────────────────────────────────────────────────
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use crate::audio::audio_spectral_sheet::SPECTRAL_PREVIEW_BINS;
228
229    fn make_test_surface() -> (Vec<f32>, usize, usize) {
230        let frames = 4;
231        let bins = SPECTRAL_PREVIEW_BINS;
232        let mut raster = vec![0.0f32; frames * bins];
233        // Put a peak at frame 1, bin 16.
234        raster[1 * bins + 16] = 1.0;
235        // Put a peak at frame 2, bin 32.
236        raster[2 * bins + 32] = 0.8;
237        (raster, frames, bins)
238    }
239
240    #[test]
241    fn surface_get_in_bounds() {
242        let (raster, frames, bins) = make_test_surface();
243        let s = TfSurface::new(&raster, frames, bins, 44100, 512);
244        assert_eq!(s.get(1, 16), 1.0);
245        assert_eq!(s.get(2, 32), 0.8);
246        assert_eq!(s.get(0, 0), 0.0);
247    }
248
249    #[test]
250    fn surface_get_out_of_bounds_returns_zero() {
251        let (raster, frames, bins) = make_test_surface();
252        let s = TfSurface::new(&raster, frames, bins, 44100, 512);
253        assert_eq!(s.get(100, 0), 0.0);
254        assert_eq!(s.get(0, 100), 0.0);
255    }
256
257    #[test]
258    fn bilinear_sample_at_integer_coords() {
259        let (raster, frames, bins) = make_test_surface();
260        let s = TfSurface::new(&raster, frames, bins, 44100, 512);
261        assert!((s.sample_bilinear(1.0, 16.0) - 1.0).abs() < 1e-6);
262        assert!((s.sample_bilinear(2.0, 32.0) - 0.8).abs() < 1e-6);
263    }
264
265    #[test]
266    fn bilinear_sample_interpolates() {
267        let (raster, frames, bins) = make_test_surface();
268        let s = TfSurface::new(&raster, frames, bins, 44100, 512);
269        // Sample midway between frame 1 (peak=1.0 at bin 16) and frame 2 (peak=0.8 at bin 32).
270        let val = s.sample_bilinear(1.5, 24.0);
271        // Should be non-zero (interpolating between the two peaks).
272        assert!(val >= 0.0, "bilinear sample should be non-negative");
273    }
274
275    #[test]
276    fn spectral_flux_detects_onsets() {
277        let (raster, frames, bins) = make_test_surface();
278        let s = TfSurface::new(&raster, frames, bins, 44100, 512);
279        let mut flux = [0.0f32; 3];
280        let n = s.spectral_flux(&mut flux);
281        assert_eq!(n, 3);
282        // Frame 0→1: energy appears (flux > 0).
283        assert!(flux[0] > 0.0, "flux at onset should be positive");
284        // Frame 1→2: peak moves, flux should be positive.
285        assert!(flux[1] > 0.0, "flux during peak move should be positive");
286    }
287
288    #[test]
289    fn ridge_bin_finds_peak() {
290        let (raster, frames, bins) = make_test_surface();
291        let s = TfSurface::new(&raster, frames, bins, 44100, 512);
292        assert_eq!(s.ridge_bin(1), 16, "peak at frame 1 should be bin 16");
293        assert_eq!(s.ridge_bin(2), 32, "peak at frame 2 should be bin 32");
294    }
295
296    #[test]
297    fn ridges_collects_all_frames() {
298        let (raster, frames, bins) = make_test_surface();
299        let s = TfSurface::new(&raster, frames, bins, 44100, 512);
300        let mut ridges = [0usize; 4];
301        let n = s.ridges(&mut ridges);
302        assert_eq!(n, 4);
303        assert_eq!(ridges[1], 16);
304        assert_eq!(ridges[2], 32);
305    }
306
307    #[test]
308    fn frame_to_time_correct() {
309        let (raster, frames, bins) = make_test_surface();
310        let s = TfSurface::new(&raster, frames, bins, 44100, 512);
311        let t0 = s.frame_to_time(0);
312        let t1 = s.frame_to_time(1);
313        assert!((t0 - 0.0).abs() < 1e-6);
314        assert!((t1 - 512.0 / 44100.0).abs() < 1e-6);
315    }
316
317    #[test]
318    fn bin_to_freq_linear_correct() {
319        let (raster, frames, bins) = make_test_surface();
320        let s = TfSurface::new(&raster, frames, bins, 44100, 512);
321        // Nyquist = 22050, bins = 64, so bin_freq = 22050/64 ≈ 344.5
322        let f0 = s.bin_to_freq_linear(0);
323        let f1 = s.bin_to_freq_linear(1);
324        assert!((f0 - 0.0).abs() < 1e-6);
325        assert!(f1 > 0.0);
326    }
327
328    #[test]
329    fn bin_to_freq_log_correct() {
330        let (raster, frames, bins) = make_test_surface();
331        let s = TfSurface::new(&raster, frames, bins, 44100, 512);
332        let f0 = s.bin_to_freq_log(0, 55.0, 12);
333        let f12 = s.bin_to_freq_log(12, 55.0, 12);
334        assert!((f0 - 55.0).abs() < 1e-3, "bin 0 should be f_min");
335        assert!((f12 - 110.0).abs() < 0.1, "bin 12 should be one octave up");
336    }
337
338    #[test]
339    fn freq_gradient_central_difference() {
340        let (raster, frames, bins) = make_test_surface();
341        let s = TfSurface::new(&raster, frames, bins, 44100, 512);
342        // At frame 1, bin 15: gradient should point toward bin 16 (the peak).
343        let g = s.freq_gradient(1, 15);
344        assert!(g > 0.0, "gradient should be positive toward peak");
345    }
346
347    #[test]
348    fn time_gradient_central_difference() {
349        // Use a zero-filled surface with a single peak.
350        let frames = 4;
351        let bins = SPECTRAL_PREVIEW_BINS;
352        let mut raster = vec![0.0f32; frames * bins];
353        raster[1 * bins + 16] = 1.0; // peak at frame 1
354        let s = TfSurface::new(&raster, frames, bins, 44100, 512);
355        // At frame 1, bin 16: prev = get(0, 16) = 0.0, next = get(2, 16) = 0.0.
356        // Gradient = (0.0 - 0.0) * 0.5 = 0.0 — the peak is isolated.
357        // At frame 2, bin 16: prev = get(1, 16) = 1.0, next = get(3, 16) = 0.0.
358        // Gradient = (0.0 - 1.0) * 0.5 = -0.5 — negative after the peak.
359        let g = s.time_gradient(2, 16);
360        assert!(
361            g < 0.0,
362            "time gradient should be negative after peak: {}",
363            g
364        );
365    }
366
367    #[test]
368    fn total_energy_correct() {
369        let (raster, frames, bins) = make_test_surface();
370        let s = TfSurface::new(&raster, frames, bins, 44100, 512);
371        assert!(
372            (s.total_energy() - 1.8).abs() < 1e-6,
373            "total energy should be 1.8"
374        );
375    }
376
377    #[test]
378    fn frame_energy_correct() {
379        let (raster, frames, bins) = make_test_surface();
380        let s = TfSurface::new(&raster, frames, bins, 44100, 512);
381        assert!((s.frame_energy(1) - 1.0).abs() < 1e-6);
382        assert!((s.frame_energy(2) - 0.8).abs() < 1e-6);
383        assert!((s.frame_energy(0) - 0.0).abs() < 1e-6);
384    }
385
386    #[test]
387    fn to_height_mesh_writes_vertices() {
388        let (raster, frames, bins) = make_test_surface();
389        let s = TfSurface::new(&raster, frames, bins, 44100, 512);
390        let mut verts = vec![0.0f32; frames * bins * 3];
391        let n = s.to_height_mesh(&mut verts);
392        assert_eq!(n, frames * bins);
393        // Vertex at (frame=1, bin=16) should have z=1.0.
394        let idx = (1 * bins + 16) * 3;
395        assert!((verts[idx + 2] - 1.0).abs() < 1e-6, "z should be magnitude");
396    }
397
398    #[test]
399    fn surface_determinism() {
400        let (raster, frames, bins) = make_test_surface();
401        let s = TfSurface::new(&raster, frames, bins, 44100, 512);
402        let v1 = s.sample_bilinear(1.5, 24.0);
403        let v2 = s.sample_bilinear(1.5, 24.0);
404        assert_eq!(v1, v2, "sampling must be deterministic");
405    }
406}