Skip to main content

qualia_core_db/solvers/transforms/
fourier.rs

1//! Discrete Fourier transform and its inverse over complex samples.
2
3/// A complex number as `(real, imaginary)`.
4pub type Cplx = (f64, f64);
5
6#[inline]
7fn cadd(a: Cplx, b: Cplx) -> Cplx {
8    (a.0 + b.0, a.1 + b.1)
9}
10#[inline]
11fn cmul(a: Cplx, b: Cplx) -> Cplx {
12    (a.0 * b.0 - a.1 * b.1, a.0 * b.1 + a.1 * b.0)
13}
14
15/// Forward DFT: `X[k] = Σ_n x[n] · e^{−2πi kn/N}`.
16///
17/// This is the **f64-exact** reference: a naive O(N²) DFT computed entirely on
18/// the CPU in `f64`. It is un-normalized with the forward sign convention
19/// `e^{−2πi kn/N}`. Its bit-for-bit f64 behaviour is a contract — [`idft`] round-
20/// trips against it to ~1e-9 — so it is deliberately **not** routed through the
21/// f32 GPU forge. Spectral callers that accept f32 precision should call
22/// [`dft_accelerated`] instead, which uses the WGSL forge when available.
23pub fn dft(x: &[Cplx]) -> Vec<Cplx> {
24    dft_cpu(x)
25}
26
27/// CPU-only forward DFT — the exact `f64` reference math, factored out so both
28/// [`dft`] and the CPU floor of [`dft_accelerated`] share one definition.
29fn dft_cpu(x: &[Cplx]) -> Vec<Cplx> {
30    let n = x.len();
31    let mut out = vec![(0.0, 0.0); n];
32    if n == 0 {
33        return out;
34    }
35    let w = -2.0 * core::f64::consts::PI / n as f64;
36    for (k, ok) in out.iter_mut().enumerate() {
37        let mut acc = (0.0, 0.0);
38        for (j, &xj) in x.iter().enumerate() {
39            let ang = w * (k * j) as f64;
40            acc = cadd(acc, cmul(xj, (ang.cos(), ang.sin())));
41        }
42        *ok = acc;
43    }
44    out
45}
46
47/// Best-path forward DFT for **spectral callers that accept `f32` precision**:
48/// `X[k] = Σ_n x[n] · e^{−2πi kn/N}`, same un-normalized forward convention as
49/// [`dft`], but accelerated on the GPU when possible.
50///
51/// # Why this is a separate function (and `dft` is not silently swapped)
52///
53/// The forge FFT is `f32` (WGSL has no `f64`), so an accelerated result carries
54/// **f32 precision** (≈ 1e-3 .. 1e-2 of the spectral magnitude for `N ≤ 1024`),
55/// not f64-exact bits. Routing the public [`dft`] through it would break callers
56/// that rely on its f64-exact contract (e.g. the [`idft`] round-trip). So the
57/// fast path is exposed here as an **explicit opt-in**: callers doing
58/// audio / magnitude / feature-extraction style work — where f32 is the
59/// universal norm — choose it knowingly. The inverse [`idft`] stays wholly on
60/// the CPU (the forge FFT is forward-only).
61///
62/// # Convention match (no rescale)
63///
64/// The forge's radix-2 FFT and its CPU DFT oracle
65/// ([`crate::wgsl_forge::dispatch::fft_f32`]) use the **identical** un-normalized
66/// forward sign convention `e^{−2πi kn/N}`. So the forge result is the same
67/// transform as [`dft`] — **no sign flip, no `1/N` / `1/√N` rescale** is applied,
68/// only an `f64 → f32 → f64` width conversion.
69///
70/// # Path selection
71///
72/// The WGSL forge runs only when **all** of:
73/// * `N = x.len()` is a power of two and `2 ≤ N ≤ 1024` (the forge runs ONE
74///   workgroup of `N` threads, so `N` is bounded by the single-workgroup size);
75/// * a wgpu accelerator is present on this machine
76///   ([`caps().wgpu`](crate::wgsl_forge::dispatch::caps)).
77///
78/// Otherwise — and on **any** forge error — it falls through to the f64 CPU DFT
79/// ([`dft`]'s exact math). The result is always a valid forward DFT; only the
80/// precision (f32 vs f64) and the compute device differ between the two paths.
81pub fn dft_accelerated(x: &[Cplx]) -> Vec<Cplx> {
82    // ── Accelerated fast path: WGSL forge forward FFT (f32), same convention. ──
83    // Eligible only for a power-of-two N in [2, 1024] on a machine with a wgpu
84    // adapter; any forge error falls straight through to the exact CPU DFT.
85    #[cfg(all(not(target_arch = "wasm32"), feature = "wgsl-forge"))]
86    if {
87        let n = x.len();
88        n.is_power_of_two() && (2..=1024).contains(&n) && crate::wgsl_forge::dispatch::caps().wgpu
89    } {
90        let n = x.len();
91        // f64 (re, im) -> interleaved f32 [re0, im0, re1, im1, …].
92        let mut interleaved = Vec::with_capacity(2 * n);
93        for &(re, im) in x {
94            interleaved.push(re as f32);
95            interleaved.push(im as f32);
96        }
97        if let Ok(spectrum) = crate::wgsl_forge::dispatch::fft_f32(&interleaved) {
98            // The forge guarantees a 2*n interleaved result; widen back to f64.
99            if spectrum.len() == 2 * n {
100                let mut out = vec![(0.0, 0.0); n];
101                for (k, ok) in out.iter_mut().enumerate() {
102                    *ok = (spectrum[2 * k] as f64, spectrum[2 * k + 1] as f64);
103                }
104                return out;
105            }
106        }
107        // Forge ineligible/failed or returned an unexpected length — fall through
108        // to the exact CPU DFT (never broken).
109    }
110
111    dft_cpu(x)
112}
113
114/// Inverse DFT: `x[n] = (1/N) Σ_k X[k] · e^{+2πi kn/N}`.
115pub fn idft(spectrum: &[Cplx]) -> Vec<Cplx> {
116    let n = spectrum.len();
117    let mut out = vec![(0.0, 0.0); n];
118    if n == 0 {
119        return out;
120    }
121    let w = 2.0 * core::f64::consts::PI / n as f64;
122    let inv = 1.0 / n as f64;
123    for (j, oj) in out.iter_mut().enumerate() {
124        let mut acc = (0.0, 0.0);
125        for (k, &xk) in spectrum.iter().enumerate() {
126            let ang = w * (k * j) as f64;
127            acc = cadd(acc, cmul(xk, (ang.cos(), ang.sin())));
128        }
129        *oj = (acc.0 * inv, acc.1 * inv);
130    }
131    out
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    const EPS: f64 = 1e-9;
138
139    fn re(v: &[f64]) -> Vec<Cplx> {
140        v.iter().map(|&r| (r, 0.0)).collect()
141    }
142
143    #[test]
144    fn dft_of_constant_is_an_impulse() {
145        // DFT([1,1,1,1]) = [4,0,0,0]
146        let x = dft(&re(&[1.0, 1.0, 1.0, 1.0]));
147        assert!((x[0].0 - 4.0).abs() < EPS && x[0].1.abs() < EPS);
148        for k in 1..4 {
149            assert!(x[k].0.abs() < EPS && x[k].1.abs() < EPS);
150        }
151    }
152
153    #[test]
154    fn dft_of_impulse_is_constant() {
155        // DFT([1,0,0,0]) = [1,1,1,1]
156        let x = dft(&re(&[1.0, 0.0, 0.0, 0.0]));
157        for k in 0..4 {
158            assert!((x[k].0 - 1.0).abs() < EPS && x[k].1.abs() < EPS);
159        }
160    }
161
162    /// The wired forward transform [`dft_accelerated`] of a power-of-two signal
163    /// must match the known analytic spectrum whether the WGSL forge fast path or
164    /// the CPU floor runs (so it is validated on a GPU box and a GPU-less box
165    /// alike). Two cases, both at N=8 (a power of two ≤ 1024, so the forge path is
166    /// eligible when an adapter is present):
167    ///
168    /// 1. A real unit impulse `x[0]=1` (rest 0) → flat spectrum, every bin `(1,0)`.
169    /// 2. A real cosine at integer frequency f=1, `x[j]=cos(2π·1·j/8)`, has all its
170    ///    energy in the two conjugate-symmetric bins k=1 and k=N−1=7, each
171    ///    `(N/2, 0) = (4, 0)`, and zero elsewhere.
172    ///
173    /// Tolerance is f32-appropriate (1e-2): the accelerated path carries f32
174    /// precision, so this validates the wired path on a GPU box without being so
175    /// tight that f32 rounding trips it.
176    #[test]
177    fn accelerated_dft_matches_known_spectrum() {
178        const TOL: f64 = 1e-2;
179
180        // Case 1: impulse → all-ones.
181        let imp = re(&[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]);
182        let s = dft_accelerated(&imp);
183        assert_eq!(s.len(), 8);
184        for (k, b) in s.iter().enumerate() {
185            assert!(
186                (b.0 - 1.0).abs() < TOL,
187                "impulse bin {k} re should be 1, got {}",
188                b.0
189            );
190            assert!(
191                b.1.abs() < TOL,
192                "impulse bin {k} im should be 0, got {}",
193                b.1
194            );
195        }
196
197        // Case 2: cos(2π·1·j/8) → energy only in k=1 and k=7, each (4, 0).
198        let n = 8usize;
199        let cosine: Vec<Cplx> = (0..n)
200            .map(|j| {
201                let ang = 2.0 * core::f64::consts::PI * (j as f64) / (n as f64);
202                (ang.cos(), 0.0)
203            })
204            .collect();
205        let cs = dft_accelerated(&cosine);
206        for (k, b) in cs.iter().enumerate() {
207            let expect_re = if k == 1 || k == n - 1 {
208                (n as f64) / 2.0
209            } else {
210                0.0
211            };
212            assert!(
213                (b.0 - expect_re).abs() < TOL,
214                "cos bin {k} re: expected {expect_re}, got {}",
215                b.0
216            );
217            assert!(b.1.abs() < TOL, "cos bin {k} im should be ~0, got {}", b.1);
218        }
219    }
220
221    /// The wired accelerated path must agree with the exact CPU [`dft`] reference
222    /// to f32 tolerance on a non-trivial power-of-two signal — pinning that the
223    /// fast path is the SAME transform (same sign/scale), only in f32. Holds on a
224    /// GPU box (forge ran) and a GPU-less box (both are the CPU DFT, exact).
225    #[test]
226    fn accelerated_dft_agrees_with_cpu_reference() {
227        let x = re(&[3.0, 1.0, 4.0, 1.0, 5.0, 9.0, 2.0, 6.0]); // N=8
228        let exact = dft(&x);
229        let fast = dft_accelerated(&x);
230        assert_eq!(exact.len(), fast.len());
231        for (k, (e, f)) in exact.iter().zip(&fast).enumerate() {
232            // f32 magnitudes here are O(10); a 1e-2 absolute tol comfortably
233            // covers the f32 rounding while still catching any sign/scale error.
234            assert!(
235                (e.0 - f.0).abs() < 1e-2,
236                "bin {k} re: exact {} vs fast {}",
237                e.0,
238                f.0
239            );
240            assert!(
241                (e.1 - f.1).abs() < 1e-2,
242                "bin {k} im: exact {} vs fast {}",
243                e.1,
244                f.1
245            );
246        }
247    }
248
249    #[test]
250    fn inverse_round_trips() {
251        let x = re(&[3.0, 1.0, 4.0, 1.0, 5.0, 9.0, 2.0, 6.0]);
252        let back = idft(&dft(&x));
253        for (a, b) in x.iter().zip(&back) {
254            assert!((a.0 - b.0).abs() < 1e-9 && (a.1 - b.1).abs() < 1e-9);
255        }
256    }
257}