Skip to main content

qualia_core_db/audio/
stft.rs

1//! Real forward STFT over actual audio samples — Hann-windowed framing through the
2//! forge FFT (`crate::wgsl_forge::dispatch::fft_f32`, GPU best-path with CPU DFT floor).
3//!
4//! This is the genuine forward transform that the cold-path sidecar bake consumes:
5//! [`bake_stft_sidecar_from_samples`] reduces the real magnitude spectrum to
6//! `SPECTRAL_PREVIEW_BINS` and writes it through the same header machinery as the
7//! preview-synthesis bake in [`crate::audio::stft_bake`] — so the sidecar derives
8//! from real audio, not from a parametric preview.
9//!
10//! Heap allocation is fine here: STFT runs at ingest (cold path), never in the
11//! zero-heap U3 hot worklet.
12
13use crate::audio::audio_spectral_sheet::{
14    AudioSpectralSidecarHeader, SIDECAR_KIND_STFT, SPECTRAL_PREVIEW_BINS, SPECTRAL_SIDECAR_MAGIC,
15};
16use crate::audio::stft_bake::StftBakeError;
17
18/// Hann window coefficient `w[i] = 0.5*(1 - cos(2π·i/(N-1)))` for a length-`n` frame.
19#[inline]
20fn hann(i: usize, n: usize) -> f32 {
21    if n <= 1 {
22        return 1.0;
23    }
24    0.5 * (1.0 - (core::f32::consts::TAU * i as f32 / (n - 1) as f32).cos())
25}
26
27/// Real forward STFT over `samples`.
28///
29/// `frame_size` must be a power of two in `[2, 1024]` (the forge FFT's accelerated
30/// window; the CPU DFT floor honours the same size), otherwise
31/// [`StftBakeError::InvalidFrameCount`].
32///
33/// For each frame start `s = 0, hop, 2·hop, …` while `s + frame_size ≤ samples.len()`:
34/// take `samples[s..s+frame_size]`, apply the Hann window, build the interleaved
35/// complex buffer `[re = windowed, im = 0]`, run it through
36/// [`crate::wgsl_forge::dispatch::fft_f32`], and collect the frame's `frame_size`
37/// complex bins as `[re, im]`.
38///
39/// Returns one inner `Vec<[f32;2]>` (length `frame_size`) per frame.
40pub fn forward_stft(
41    samples: &[f32],
42    frame_size: usize,
43    hop: usize,
44) -> Result<Vec<Vec<[f32; 2]>>, StftBakeError> {
45    if !frame_size.is_power_of_two() || !(2..=1024).contains(&frame_size) {
46        return Err(StftBakeError::InvalidFrameCount);
47    }
48    if hop == 0 {
49        return Err(StftBakeError::InvalidFrameCount);
50    }
51
52    let mut frames: Vec<Vec<[f32; 2]>> = Vec::new();
53    if samples.len() < frame_size {
54        return Ok(frames);
55    }
56
57    // Reused per-frame interleaved-complex scratch (re, im, re, im, …).
58    let mut interleaved = vec![0.0_f32; frame_size * 2];
59
60    let mut s = 0usize;
61    while s + frame_size <= samples.len() {
62        let win = &samples[s..s + frame_size];
63        for (i, &x) in win.iter().enumerate() {
64            interleaved[2 * i] = x * hann(i, frame_size);
65            interleaved[2 * i + 1] = 0.0;
66        }
67        // Forge FFT (GPU best-path, CPU DFT floor). On any forge error fft_f32
68        // itself falls through to the CPU floor, so this only errors on a bad
69        // (odd) length — which `interleaved` never has.
70        let spectrum = fft_interleaved(&interleaved)?;
71
72        let mut frame = Vec::with_capacity(frame_size);
73        for k in 0..frame_size {
74            frame.push([spectrum[2 * k], spectrum[2 * k + 1]]);
75        }
76        frames.push(frame);
77
78        s += hop;
79    }
80
81    Ok(frames)
82}
83
84/// FFT of one interleaved-complex frame. Uses the forge (GPU best-path with its
85/// own CPU DFT floor) when the `wgsl_forge` module is compiled in; otherwise — on
86/// `wasm32`, or with the `wgsl-forge` feature off — a self-contained naive CPU
87/// DFT with the *identical* forward sign convention (`exp(-2πi·k·j/N)`), so the
88/// spectrum is the same transform either way. Keeps the audio path self-sufficient
89/// without depending on the native-only forge.
90#[inline]
91fn fft_interleaved(interleaved: &[f32]) -> Result<Vec<f32>, StftBakeError> {
92    #[cfg(all(not(target_arch = "wasm32"), feature = "wgsl-forge"))]
93    {
94        crate::wgsl_forge::dispatch::fft_f32(interleaved)
95            .map_err(|_| StftBakeError::InvalidFrameCount)
96    }
97    #[cfg(not(all(not(target_arch = "wasm32"), feature = "wgsl-forge")))]
98    {
99        if interleaved.len() % 2 != 0 {
100            return Err(StftBakeError::InvalidFrameCount);
101        }
102        Ok(dft_interleaved_cpu(interleaved, interleaved.len() / 2))
103    }
104}
105
106/// Naive O(N²) forward DFT of interleaved-complex input — the CPU floor used when
107/// the forge isn't compiled in. Angles accumulate in f64 for a clean reference;
108/// matches `wgsl_forge::oracle::dft_cpu` exactly.
109#[cfg(not(all(not(target_arch = "wasm32"), feature = "wgsl-forge")))]
110fn dft_interleaved_cpu(input_interleaved: &[f32], n: usize) -> Vec<f32> {
111    use core::f64::consts::PI;
112    let mut out = vec![0.0_f32; 2 * n];
113    for k in 0..n {
114        let mut re = 0.0f64;
115        let mut im = 0.0f64;
116        for j in 0..n {
117            let xr = input_interleaved[2 * j] as f64;
118            let xi = input_interleaved[2 * j + 1] as f64;
119            let ang = -2.0 * PI * (k as f64) * (j as f64) / (n as f64);
120            let (s, c) = ang.sin_cos();
121            // x * (c + i s): real = xr·c − xi·s, imag = xr·s + xi·c.
122            re += xr * c - xi * s;
123            im += xr * s + xi * c;
124        }
125        out[2 * k] = re as f32;
126        out[2 * k + 1] = im as f32;
127    }
128    out
129}
130
131/// Per-frame one-sided magnitude spectrum: the first `frame_size/2 + 1` bins
132/// (DC … Nyquist) of `|X[k]| = sqrt(re² + im²)`.
133pub fn stft_magnitudes(spec: &[Vec<[f32; 2]>]) -> Vec<Vec<f32>> {
134    spec.iter()
135        .map(|frame| {
136            let half = frame.len() / 2 + 1;
137            frame
138                .iter()
139                .take(half)
140                .map(|&[re, im]| (re * re + im * im).sqrt())
141                .collect()
142        })
143        .collect()
144}
145
146/// Reduce a single frame's one-sided magnitude spectrum to `SPECTRAL_PREVIEW_BINS`
147/// by averaging contiguous source bins into each preview bin (group/average).
148fn magnitudes_to_preview(mags: &[f32]) -> [f32; SPECTRAL_PREVIEW_BINS] {
149    let mut out = [0.0_f32; SPECTRAL_PREVIEW_BINS];
150    if mags.is_empty() {
151        return out;
152    }
153    let n = mags.len();
154    for (b, slot) in out.iter_mut().enumerate() {
155        // Even split of [0, n) into SPECTRAL_PREVIEW_BINS contiguous groups.
156        let lo = b * n / SPECTRAL_PREVIEW_BINS;
157        let hi = ((b + 1) * n / SPECTRAL_PREVIEW_BINS).max(lo + 1).min(n);
158        let mut sum = 0.0_f32;
159        let mut cnt = 0u32;
160        for &m in &mags[lo..hi] {
161            sum += m;
162            cnt += 1;
163        }
164        *slot = if cnt > 0 { sum / cnt as f32 } else { 0.0 };
165    }
166    out
167}
168
169/// Bake an STFT sidecar from REAL audio `samples`.
170///
171/// Computes the genuine forward STFT, reduces each frame's one-sided magnitude
172/// spectrum to `SPECTRAL_PREVIEW_BINS` (group/average), and writes the sidecar
173/// through the same [`AudioSpectralSidecarHeader`] machinery as
174/// [`crate::audio::stft_bake::bake_stft_sidecar_from_preview`] — mirroring its
175/// layout exactly (header + `frame_count` × `SPECTRAL_PREVIEW_BINS` f32 raster),
176/// but with `_pad = SIDECAR_KIND_STFT` and frame data derived from real audio.
177///
178/// Returns the number of bytes written into `out`.
179pub fn bake_stft_sidecar_from_samples(
180    samples: &[f32],
181    frame_size: usize,
182    hop: usize,
183    sample_rate: u32,
184    out: &mut [u8],
185) -> Result<usize, StftBakeError> {
186    let spec = forward_stft(samples, frame_size, hop)?;
187    let mags = stft_magnitudes(&spec);
188    let frame_count = mags.len() as u32;
189    if frame_count == 0 || frame_count > 4096 {
190        return Err(StftBakeError::InvalidFrameCount);
191    }
192
193    let header = AudioSpectralSidecarHeader {
194        magic: SPECTRAL_SIDECAR_MAGIC,
195        version: AudioSpectralSidecarHeader::VERSION,
196        _pad: SIDECAR_KIND_STFT,
197        bin_count: SPECTRAL_PREVIEW_BINS as u32,
198        frame_count,
199        sample_rate,
200    };
201    let need = std::mem::size_of::<AudioSpectralSidecarHeader>() + header.payload_bytes();
202    if out.len() < need {
203        return Err(StftBakeError::OutputTooSmall);
204    }
205    out[..std::mem::size_of::<AudioSpectralSidecarHeader>()]
206        .copy_from_slice(bytemuck::bytes_of(&header));
207    let payload_off = std::mem::size_of::<AudioSpectralSidecarHeader>();
208    for (f, frame_mags) in mags.iter().enumerate() {
209        let preview = magnitudes_to_preview(frame_mags);
210        let off = payload_off + f * SPECTRAL_PREVIEW_BINS * 4;
211        out[off..off + SPECTRAL_PREVIEW_BINS * 4].copy_from_slice(bytemuck::cast_slice(&preview));
212    }
213    Ok(need)
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use crate::audio::audio_spectral_sheet::parse_sidecar_header;
220
221    /// A pure cosine at bin BIN must place the STFT magnitude peak at bin BIN.
222    /// This also exercises the forge `fft_f32` end-to-end (GPU best-path / CPU floor).
223    #[test]
224    fn cosine_peaks_at_expected_bin() {
225        const FRAME: usize = 64;
226        const BIN: usize = 8;
227        // Three full frames of a pure cosine at exactly `BIN` cycles/frame.
228        let samples: Vec<f32> = (0..FRAME * 3)
229            .map(|i| {
230                (core::f32::consts::TAU * BIN as f32 * (i % FRAME) as f32 / FRAME as f32).cos()
231            })
232            .collect();
233        let spec = forward_stft(&samples, FRAME, FRAME).expect("stft");
234        let mags = stft_magnitudes(&spec);
235        assert!(!mags.is_empty());
236        // Peak bin of the first frame (one-sided spectrum has FRAME/2+1 = 33 bins).
237        let first = &mags[0];
238        let (peak, _) = first
239            .iter()
240            .enumerate()
241            .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
242            .unwrap();
243        assert!(
244            peak.abs_diff(BIN) <= 1,
245            "expected STFT peak near bin {BIN}, got {peak}"
246        );
247    }
248
249    /// Frame count must equal (len - frame_size)/hop + 1.
250    #[test]
251    fn frame_count_matches_formula() {
252        const FRAME: usize = 64;
253        const HOP: usize = 16;
254        let samples = vec![0.25_f32; 64 * 5 + 7];
255        let spec = forward_stft(&samples, FRAME, HOP).expect("stft");
256        let expected = (samples.len() - FRAME) / HOP + 1;
257        assert_eq!(spec.len(), expected, "frame count mismatch");
258    }
259
260    #[test]
261    fn rejects_non_power_of_two_frame() {
262        let samples = vec![0.0_f32; 256];
263        assert_eq!(
264            forward_stft(&samples, 48, 16),
265            Err(StftBakeError::InvalidFrameCount)
266        );
267        assert_eq!(
268            forward_stft(&samples, 2048, 16),
269            Err(StftBakeError::InvalidFrameCount)
270        );
271    }
272
273    #[test]
274    fn magnitudes_are_one_sided() {
275        const FRAME: usize = 64;
276        let samples = vec![0.1_f32; FRAME * 2];
277        let spec = forward_stft(&samples, FRAME, FRAME).expect("stft");
278        let mags = stft_magnitudes(&spec);
279        assert_eq!(mags[0].len(), FRAME / 2 + 1);
280    }
281
282    #[test]
283    fn bake_from_real_samples_produces_valid_stft_header() {
284        const FRAME: usize = 64;
285        const BIN: usize = 8;
286        let samples: Vec<f32> = (0..FRAME * 4)
287            .map(|i| {
288                (core::f32::consts::TAU * BIN as f32 * (i % FRAME) as f32 / FRAME as f32).cos()
289            })
290            .collect();
291        // Header (20 bytes) + up to 4 frames × 64 bins × 4 bytes.
292        let mut buf = [0u8; 20 + 64 * 4 * 4];
293        let n =
294            bake_stft_sidecar_from_samples(&samples, FRAME, FRAME, 44_100, &mut buf).expect("bake");
295        let h = parse_sidecar_header(&buf).expect("valid header");
296        assert_eq!(h.bin_count, SPECTRAL_PREVIEW_BINS as u32);
297        assert_eq!(h._pad, SIDECAR_KIND_STFT);
298        assert_eq!(h.sample_rate, 44_100);
299        assert!(h.frame_count >= 1);
300        assert_eq!(
301            n,
302            std::mem::size_of::<AudioSpectralSidecarHeader>()
303                + h.frame_count as usize * SPECTRAL_PREVIEW_BINS * 4
304        );
305        // The baked preview carries real energy (cosine is not silence).
306        let payload_off = std::mem::size_of::<AudioSpectralSidecarHeader>();
307        let frame0: &[f32] =
308            bytemuck::cast_slice(&buf[payload_off..payload_off + SPECTRAL_PREVIEW_BINS * 4]);
309        assert!(frame0.iter().any(|&v| v > 0.0), "real STFT energy present");
310    }
311}