Skip to main content

qualia_core_db/audio/
cqt_bake.rs

1//! Constant-Q transform sidecar bake — log-spaced bins for timbral integrity (cold path).
2
3use crate::audio::audio_spectral_sheet::{
4    AudioSpectralSidecarHeader, SIDECAR_KIND_CQT, SPECTRAL_PREVIEW_BINS, SPECTRAL_SIDECAR_MAGIC,
5};
6use crate::audio::stft_bake::StftBakeError;
7
8/// Map linear preview energy into log-spaced CQT bins (MIDI-ish spacing across 64 bins).
9#[inline]
10pub fn preview_to_cqt_frame(
11    preview: &[f32; SPECTRAL_PREVIEW_BINS],
12    frame_index: u32,
13    frame_count: u32,
14) -> [f32; SPECTRAL_PREVIEW_BINS] {
15    let mut out = [0.0_f32; SPECTRAL_PREVIEW_BINS];
16    let phase = (frame_index as f32 / frame_count.max(1) as f32) * core::f32::consts::TAU;
17    for (i, o) in out.iter_mut().enumerate() {
18        let log_i = (i as f32 + 1.0).ln() / (SPECTRAL_PREVIEW_BINS as f32 + 1.0).ln();
19        let src_idx = (log_i * (SPECTRAL_PREVIEW_BINS - 1) as f32).round() as usize;
20        let base = preview[src_idx.min(SPECTRAL_PREVIEW_BINS - 1)];
21        let shimmer = (phase * (i as f32 + 1.0) * 0.05).sin() * 0.08;
22        *o = (base * (1.0 + shimmer)).max(0.0);
23    }
24    out
25}
26
27/// Bake CQT sidecar into caller buffer (`_pad` = `SIDECAR_KIND_CQT`).
28pub fn bake_cqt_sidecar_from_preview(
29    preview: &[f32; SPECTRAL_PREVIEW_BINS],
30    frame_count: u32,
31    sample_rate: u32,
32    out: &mut [u8],
33) -> Result<usize, StftBakeError> {
34    if frame_count == 0 || frame_count > 4096 {
35        return Err(StftBakeError::InvalidFrameCount);
36    }
37    let header = AudioSpectralSidecarHeader {
38        magic: SPECTRAL_SIDECAR_MAGIC,
39        version: AudioSpectralSidecarHeader::VERSION,
40        _pad: SIDECAR_KIND_CQT,
41        bin_count: SPECTRAL_PREVIEW_BINS as u32,
42        frame_count,
43        sample_rate,
44    };
45    let need = std::mem::size_of::<AudioSpectralSidecarHeader>() + header.payload_bytes();
46    if out.len() < need {
47        return Err(StftBakeError::OutputTooSmall);
48    }
49    out[..std::mem::size_of::<AudioSpectralSidecarHeader>()]
50        .copy_from_slice(bytemuck::bytes_of(&header));
51    let payload_off = std::mem::size_of::<AudioSpectralSidecarHeader>();
52    for f in 0..frame_count {
53        let frame = preview_to_cqt_frame(preview, f, frame_count);
54        let off = payload_off + f as usize * SPECTRAL_PREVIEW_BINS * 4;
55        out[off..off + SPECTRAL_PREVIEW_BINS * 4].copy_from_slice(bytemuck::cast_slice(&frame));
56    }
57    Ok(need)
58}
59
60/// Real forward constant-Q transform over actual audio `samples` — direct
61/// constant-Q (one complex inner product per log-spaced bin; CPU is fine, this
62/// is a cold-path ingest transform).
63///
64/// Geometric bin centres `f_k = f_min · 2^(k/bins_per_octave)`, constant quality
65/// factor `Q = 1 / (2^(1/bins_per_octave) − 1)`. Each bin uses a Hann-windowed
66/// kernel of length `n_k = round(Q · sample_rate / f_k)` (clamped to
67/// `[1, samples.len()]`):
68///
69/// `X_k = (1/n_k) · Σ_{j<n_k} samples[j] · hann(j, n_k) · exp(−2πi · f_k · j / sample_rate)`
70///
71/// and the returned vector holds `|X_k|` for `k ∈ [0, n_bins)`.
72pub fn forward_cqt(
73    samples: &[f32],
74    sample_rate: f32,
75    f_min: f32,
76    bins_per_octave: usize,
77    n_bins: usize,
78) -> Vec<f32> {
79    let mut out = Vec::with_capacity(n_bins);
80    if samples.is_empty() || bins_per_octave == 0 || sample_rate <= 0.0 || f_min <= 0.0 {
81        out.resize(n_bins, 0.0);
82        return out;
83    }
84    // Constant quality factor for the chosen resolution.
85    let q = 1.0_f32 / (2.0_f32.powf(1.0 / bins_per_octave as f32) - 1.0);
86
87    for k in 0..n_bins {
88        let f_k = f_min * 2.0_f32.powf(k as f32 / bins_per_octave as f32);
89        // Window length tracks the bin frequency (constant-Q: more cycles at HF
90        // would need fewer samples; here longer windows at LF).
91        let nk = ((q * sample_rate / f_k).round() as usize).clamp(1, samples.len());
92
93        let mut acc_re = 0.0_f32;
94        let mut acc_im = 0.0_f32;
95        for j in 0..nk {
96            let w = hann(j, nk);
97            // exp(-2πi · f_k · j / sample_rate)
98            let theta = -core::f32::consts::TAU * f_k * j as f32 / sample_rate;
99            let s = samples[j] * w;
100            acc_re += s * theta.cos();
101            acc_im += s * theta.sin();
102        }
103        let inv = 1.0 / nk as f32;
104        acc_re *= inv;
105        acc_im *= inv;
106        out.push((acc_re * acc_re + acc_im * acc_im).sqrt());
107    }
108    out
109}
110
111/// Hann window `w[i] = 0.5*(1 - cos(2π·i/(n-1)))` over a length-`n` kernel.
112#[inline]
113fn hann(i: usize, n: usize) -> f32 {
114    if n <= 1 {
115        return 1.0;
116    }
117    0.5 * (1.0 - (core::f32::consts::TAU * i as f32 / (n - 1) as f32).cos())
118}
119
120/// Reduce `n_bins` CQT magnitudes to `SPECTRAL_PREVIEW_BINS` by group-averaging
121/// contiguous bins (or copy directly when `mags.len() == SPECTRAL_PREVIEW_BINS`).
122fn cqt_to_preview(mags: &[f32]) -> [f32; SPECTRAL_PREVIEW_BINS] {
123    let mut out = [0.0_f32; SPECTRAL_PREVIEW_BINS];
124    if mags.is_empty() {
125        return out;
126    }
127    if mags.len() == SPECTRAL_PREVIEW_BINS {
128        out.copy_from_slice(mags);
129        return out;
130    }
131    let n = mags.len();
132    for (b, slot) in out.iter_mut().enumerate() {
133        let lo = b * n / SPECTRAL_PREVIEW_BINS;
134        let hi = ((b + 1) * n / SPECTRAL_PREVIEW_BINS).max(lo + 1).min(n);
135        let mut sum = 0.0_f32;
136        let mut cnt = 0u32;
137        for &m in &mags[lo..hi] {
138            sum += m;
139            cnt += 1;
140        }
141        *slot = if cnt > 0 { sum / cnt as f32 } else { 0.0 };
142    }
143    out
144}
145
146/// Bake a CQT sidecar from REAL audio `samples`: compute the genuine forward CQT,
147/// reduce `n_bins` → `SPECTRAL_PREVIEW_BINS`, and write a single-frame sidecar
148/// through the same header machinery as [`bake_cqt_sidecar_from_preview`]
149/// (`_pad = SIDECAR_KIND_CQT`). When `n_bins == SPECTRAL_PREVIEW_BINS` the
150/// magnitudes are used directly.
151pub fn bake_cqt_sidecar_from_samples(
152    samples: &[f32],
153    sample_rate: f32,
154    f_min: f32,
155    bins_per_octave: usize,
156    n_bins: usize,
157    out: &mut [u8],
158) -> Result<usize, StftBakeError> {
159    let mags = forward_cqt(samples, sample_rate, f_min, bins_per_octave, n_bins);
160    let preview = cqt_to_preview(&mags);
161
162    let header = AudioSpectralSidecarHeader {
163        magic: SPECTRAL_SIDECAR_MAGIC,
164        version: AudioSpectralSidecarHeader::VERSION,
165        _pad: SIDECAR_KIND_CQT,
166        bin_count: SPECTRAL_PREVIEW_BINS as u32,
167        frame_count: 1,
168        sample_rate: sample_rate.round().max(0.0) as u32,
169    };
170    let need = std::mem::size_of::<AudioSpectralSidecarHeader>() + header.payload_bytes();
171    if out.len() < need {
172        return Err(StftBakeError::OutputTooSmall);
173    }
174    out[..std::mem::size_of::<AudioSpectralSidecarHeader>()]
175        .copy_from_slice(bytemuck::bytes_of(&header));
176    let payload_off = std::mem::size_of::<AudioSpectralSidecarHeader>();
177    out[payload_off..payload_off + SPECTRAL_PREVIEW_BINS * 4]
178        .copy_from_slice(bytemuck::cast_slice(&preview));
179    Ok(need)
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use crate::audio::audio_spectral_sheet::parse_sidecar_header;
186
187    #[test]
188    fn cqt_header_kind_flag() {
189        let preview = [0.4_f32; SPECTRAL_PREVIEW_BINS];
190        let mut buf = [0u8; 20 + 64 * 4 * 4];
191        let n = bake_cqt_sidecar_from_preview(&preview, 4, 48_000, &mut buf).unwrap();
192        assert!(n > 20);
193        let h = parse_sidecar_header(&buf).unwrap();
194        assert_eq!(h._pad, SIDECAR_KIND_CQT);
195    }
196
197    #[test]
198    fn cqt_bins_log_spread_nonzero() {
199        let mut preview = [0.0_f32; SPECTRAL_PREVIEW_BINS];
200        preview[0] = 1.0;
201        preview[63] = 0.5;
202        let cqt = preview_to_cqt_frame(&preview, 0, 8);
203        assert!(cqt[0] > 0.0, "low CQT bins sample preview[0]");
204        assert!(
205            cqt[63] > 0.0,
206            "high CQT bins sample preview[63] via log map"
207        );
208    }
209
210    /// A 440 Hz tone must peak at the CQT bin nearest 12·log2(440/55) = 36.
211    #[test]
212    fn forward_cqt_tone_lands_on_expected_bin() {
213        let sample_rate = 44_100.0_f32;
214        let f_min = 55.0_f32;
215        let bins_per_octave = 12usize;
216        let n_bins = 48usize;
217        let freq = 440.0_f32;
218        // ~0.25 s of a pure 440 Hz tone — long enough for the LF kernels.
219        let n = 11_025usize;
220        let samples: Vec<f32> = (0..n)
221            .map(|i| (core::f32::consts::TAU * freq * i as f32 / sample_rate).sin())
222            .collect();
223        let mags = forward_cqt(&samples, sample_rate, f_min, bins_per_octave, n_bins);
224        assert_eq!(mags.len(), n_bins);
225        let (peak, _) = mags
226            .iter()
227            .enumerate()
228            .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
229            .unwrap();
230        let expected = (bins_per_octave as f32 * (freq / f_min).log2()).round() as usize; // 36
231        assert!(
232            peak.abs_diff(expected) <= 1,
233            "expected CQT peak near bin {expected}, got {peak}"
234        );
235    }
236
237    #[test]
238    fn bake_cqt_from_samples_valid_header() {
239        let sample_rate = 44_100.0_f32;
240        let n = 8_192usize;
241        let samples: Vec<f32> = (0..n)
242            .map(|i| (core::f32::consts::TAU * 220.0 * i as f32 / sample_rate).sin())
243            .collect();
244        let mut buf = [0u8; 20 + 64 * 4];
245        let written = bake_cqt_sidecar_from_samples(&samples, sample_rate, 55.0, 12, 48, &mut buf)
246            .expect("bake cqt");
247        let h = parse_sidecar_header(&buf).expect("valid header");
248        assert_eq!(h._pad, SIDECAR_KIND_CQT);
249        assert_eq!(h.bin_count, SPECTRAL_PREVIEW_BINS as u32);
250        assert_eq!(h.frame_count, 1);
251        assert_eq!(h.sample_rate, 44_100);
252        assert_eq!(
253            written,
254            std::mem::size_of::<AudioSpectralSidecarHeader>() + SPECTRAL_PREVIEW_BINS * 4
255        );
256    }
257
258    /// When n_bins == SPECTRAL_PREVIEW_BINS the preview is the magnitudes verbatim.
259    #[test]
260    fn cqt_preview_passthrough_when_64_bins() {
261        let mags: Vec<f32> = (0..SPECTRAL_PREVIEW_BINS)
262            .map(|i| i as f32 * 0.01)
263            .collect();
264        let preview = cqt_to_preview(&mags);
265        for (a, b) in preview.iter().zip(mags.iter()) {
266            assert!((a - b).abs() < 1e-9);
267        }
268    }
269}