qualia_core_db/audio/
stft.rs1use 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#[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
27pub 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 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 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#[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#[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 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
131pub 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
146fn 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 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
169pub 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 #[test]
224 fn cosine_peaks_at_expected_bin() {
225 const FRAME: usize = 64;
226 const BIN: usize = 8;
227 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 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 #[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 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 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}