Skip to main content

rlx_ir/ops/
dsp.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, version 3.
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16//! Analytic-signal and zero-phase-filtering DSP helpers, composed on the
17//! existing FFT primitives.
18//!
19//! * [`Graph::hilbert`] — the analytic signal `x + i·H(x)` (scipy
20//!   `signal.hilbert`), the basis for amplitude envelope and instantaneous
21//!   phase used in EEG band-power / phase-amplitude-coupling features.
22//! * [`Graph::envelope`] / [`Graph::instantaneous_phase`] — magnitude and
23//!   angle of the analytic signal.
24//! * [`Graph::fir_filtfilt`] — zero-phase FIR filtering (forward + reversed
25//!   pass), the parity-preserving band-pass used in the `exg` preprocessor.
26//!
27//! `hilbert` zero-pads the last axis to the next power of two (matching
28//! `rfft`) and truncates back, so for non-pow2 lengths it is the analytic
29//! signal of the zero-padded frame — the same approximation as calling
30//! `scipy.signal.hilbert` on a padded buffer.
31
32use crate::infer::GraphExt as _;
33use crate::op::Activation;
34use crate::{DType, Graph, NodeId, Op, Shape, fft::FftNorm};
35
36impl Graph {
37    /// Analytic signal of the last axis. Returns `(real, imag)`, each the same
38    /// shape as `x`; `real ≈ x` and `imag = H(x)` (the Hilbert transform).
39    pub fn hilbert(&mut self, x: NodeId) -> (NodeId, NodeId) {
40        let shape = self.shape(x).clone();
41        let last = shape.rank() - 1;
42        let l = shape.dim(last).unwrap_static();
43        let n = crate::fft::next_pow2(l);
44
45        // Unnormalized forward DFT of the (real) signal, full n-bin spectrum.
46        let (re, im) = self.fft_real(x, FftNorm::Backward); // each [.., n]
47
48        // Hilbert multiplier h: DC×1, positive freqs ×2, Nyquist×1, negatives ×0.
49        let mut h = vec![0f32; n];
50        h[0] = 1.0;
51        if n >= 2 {
52            h[n / 2] = 1.0;
53            for hk in h.iter_mut().take(n / 2).skip(1) {
54                *hk = 2.0;
55            }
56        }
57        let h_node = self.const_f32_tensor(h, &[n]);
58        let re_h = self.mul(re, h_node);
59        let im_h = self.mul(im, h_node);
60
61        // Inverse DFT with 1/N scaling (numpy `ifft`), keeping both parts.
62        let block = self.concat_(vec![re_h, im_h], last);
63        let full = self.fft_norm(block, true, FftNorm::Forward); // [.., 2n]
64        let a_re = self.narrow_(full, last, 0, n);
65        let a_im = self.narrow_(full, last, n, n);
66        // Truncate the pow2 padding back to the original length.
67        (
68            self.narrow_(a_re, last, 0, l),
69            self.narrow_(a_im, last, 0, l),
70        )
71    }
72
73    /// Amplitude envelope `|x + i·H(x)|` of the last axis (same shape as `x`).
74    pub fn envelope(&mut self, x: NodeId) -> NodeId {
75        let (re, im) = self.hilbert(x);
76        self.complex_abs(re, im)
77    }
78
79    /// Instantaneous phase `atan2(H(x), x)` of the last axis, in radians.
80    ///
81    /// Uses the branch-cut-free identity
82    /// `atan2(y, x) = 2·atan(y / (√(x²+y²) + x))`, exact everywhere except the
83    /// negative real axis (a measure-zero set).
84    pub fn instantaneous_phase(&mut self, x: NodeId) -> NodeId {
85        let (re, im) = self.hilbert(x);
86        let r = self.complex_abs(re, im);
87        let denom = self.add(r, re);
88        let eps = self.constant(1e-12, DType::F32);
89        let denom = self.add(denom, eps);
90        let ratio = self.div(im, denom);
91        let s = crate::shape::unary_shape(self.shape(ratio));
92        let at = self.activation(Activation::Atan, ratio, s);
93        let two = self.constant(2.0, DType::F32);
94        self.mul(at, two)
95    }
96
97    /// Zero-phase FIR filtering: `reverse(fir(reverse(fir(x))))`.
98    ///
99    /// `taps` are the FIR coefficients (host constant). Output has the same
100    /// last-axis length as `x`; the two passes cancel the filter's phase, so
101    /// the effective magnitude response is `|H(f)|²`. This is the zero-phase
102    /// band-pass the `exg` preprocessor uses for MNE parity.
103    pub fn fir_filtfilt(&mut self, x: NodeId, taps: &[f32]) -> NodeId {
104        let k = taps.len();
105        assert!(k > 0, "fir_filtfilt: need ≥1 tap");
106        let last = self.shape(x).rank() - 1;
107        let l = self.shape(x).dim(last).unwrap_static();
108        let h = self.const_f32_tensor(taps.to_vec(), &[k]);
109        let y1 = self.fir_conv_same(x, h, k, l);
110        let y2 = self.reverse(y1, vec![last]);
111        let y3 = self.fir_conv_same(y2, h, k, l);
112        self.reverse(y3, vec![last])
113    }
114
115    /// Single-pass "same"-length FIR convolution along the last axis.
116    ///
117    /// `FftNorm::Forward` normalizes the inverse by `1/N`, so the FFT
118    /// convolution theorem yields the un-scaled linear convolution (an
119    /// unnormalized round-trip would scale by `n_fft`).
120    fn fir_conv_same(&mut self, x: NodeId, taps: NodeId, k: usize, l: usize) -> NodeId {
121        let full = self.fft_conv1d(x, taps, 0, FftNorm::Forward); // [.., l+k-1]
122        let start = (k - 1) / 2;
123        let last = self.shape(full).rank() - 1;
124        self.narrow_(full, last, start, l)
125    }
126
127    /// One biquad (2nd-order IIR) section applied along the last axis,
128    /// Direct-Form II Transposed. `b = [b0, b1, b2]`, `a = [a0, a1, a2]`
129    /// (coefficients are normalized by `a0`). This is a true recurrence —
130    /// `y[n] = b0·x[n] + z1[n−1]`, etc. — evaluated with an `Op::Scan` over
131    /// time, so it currently lowers on the CPU backend (where `Op::Scan`
132    /// runs); GPU support tracks `Op::Scan` GPU lowering.
133    ///
134    /// Any rank ≥ 1 is accepted; leading axes are independent channels.
135    pub fn biquad(&mut self, x: NodeId, b: [f32; 3], a: [f32; 3]) -> NodeId {
136        assert!(a[0] != 0.0, "biquad: a0 must be non-zero");
137        let (b0, b1, b2) = (b[0] / a[0], b[1] / a[0], b[2] / a[0]);
138        let (a1, a2) = (a[1] / a[0], a[2] / a[0]);
139
140        let xs_shape = self.shape(x).clone();
141        let orig_dims: Vec<i64> = xs_shape
142            .dims()
143            .iter()
144            .map(|d| d.unwrap_static() as i64)
145            .collect();
146        let rank = xs_shape.rank();
147        let n = xs_shape.dim(rank - 1).unwrap_static();
148        let p: usize = (0..rank - 1)
149            .map(|i| xs_shape.dim(i).unwrap_static())
150            .product();
151
152        // Reorder to [N, P] so time is the scan axis, channels are per-step.
153        let x_pn = self.reshape_(x, vec![p as i64, n as i64]);
154        let xs = self.transpose_(x_pn, vec![1, 0]); // [N, P]
155
156        // Scan body: carry = [P, 3] holding (y, z1, z2); x_t = [P].
157        let mut body = Graph::new("biquad_body");
158        let carry = body.input("carry", Shape::new(&[p, 3], DType::F32));
159        let x_t = body.input("x_t", Shape::new(&[p], DType::F32));
160        let z1p = body.narrow_(carry, 1, 1, 1);
161        let z1p = body.reshape_(z1p, vec![p as i64]);
162        let z2p = body.narrow_(carry, 1, 2, 1);
163        let z2p = body.reshape_(z2p, vec![p as i64]);
164        let cb0 = body.constant(b0 as f64, DType::F32);
165        let cb1 = body.constant(b1 as f64, DType::F32);
166        let cb2 = body.constant(b2 as f64, DType::F32);
167        let ca1 = body.constant(a1 as f64, DType::F32);
168        let ca2 = body.constant(a2 as f64, DType::F32);
169        let b0x = body.mul(x_t, cb0);
170        let y = body.add(b0x, z1p); // y = b0·x + z1
171        let b1x = body.mul(x_t, cb1);
172        let a1y = body.mul(y, ca1);
173        let z1_tmp = body.sub(b1x, a1y);
174        let z1 = body.add(z1_tmp, z2p); // z1 = b1·x − a1·y + z2
175        let b2x = body.mul(x_t, cb2);
176        let a2y = body.mul(y, ca2);
177        let z2 = body.sub(b2x, a2y); // z2 = b2·x − a2·y
178        let yc = body.reshape_(y, vec![p as i64, 1]);
179        let z1c = body.reshape_(z1, vec![p as i64, 1]);
180        let z2c = body.reshape_(z2, vec![p as i64, 1]);
181        let next = body.concat_(vec![yc, z1c, z2c], 1); // [P, 3]
182        body.set_outputs(vec![next]);
183
184        // Trajectory scan with per-step xs → [N, P, 3].
185        let init = self.const_f32_tensor(vec![0.0; p * 3], &[p, 3]);
186        let traj = self.add_node(
187            Op::Scan {
188                body: Box::new(body),
189                length: n as u32,
190                save_trajectory: true,
191                num_bcast: 0,
192                num_xs: 1,
193                num_checkpoints: 0,
194            },
195            vec![init, xs],
196            Shape::new(&[n, p, 3], DType::F32),
197        );
198        // Extract the y column → [N, P] → [P, N] → original shape.
199        let y_np = self.narrow_(traj, 2, 0, 1); // [N, P, 1]
200        let y_np = self.reshape_(y_np, vec![n as i64, p as i64]);
201        let y_pn = self.transpose_(y_np, vec![1, 0]); // [P, N]
202        self.reshape_(y_pn, orig_dims)
203    }
204
205    /// Cascade of second-order sections (scipy `sosfilt`): apply each
206    /// `(b, a)` biquad in series along the last axis.
207    pub fn sosfilt(&mut self, x: NodeId, sections: &[([f32; 3], [f32; 3])]) -> NodeId {
208        let mut y = x;
209        for &(b, a) in sections {
210            y = self.biquad(y, b, a);
211        }
212        y
213    }
214
215    /// Zero-phase IIR filtering (scipy `sosfiltfilt`): forward pass, reverse,
216    /// forward pass, reverse. Cancels the SOS cascade's phase, leaving the
217    /// squared magnitude response.
218    pub fn sosfiltfilt(&mut self, x: NodeId, sections: &[([f32; 3], [f32; 3])]) -> NodeId {
219        let last = self.shape(x).rank() - 1;
220        let f1 = self.sosfilt(x, sections);
221        let r1 = self.reverse(f1, vec![last]);
222        let f2 = self.sosfilt(r1, sections);
223        self.reverse(f2, vec![last])
224    }
225
226    /// Polyphase rational resampling of the last axis by `up`/`down`.
227    ///
228    /// Zero-stuffs by `up`, applies the FIR anti-alias filter `taps` (scaled
229    /// by `up` to preserve amplitude), then decimates by `down`. Output
230    /// last-axis length is `ceil(N·up / down)`. Composed from
231    /// reshape/concat/FFT-conv/narrow, so it runs on every backend that
232    /// supports those (all Apple-Silicon backends).
233    pub fn resample_poly(&mut self, x: NodeId, up: usize, down: usize, taps: &[f32]) -> NodeId {
234        assert!(up >= 1 && down >= 1, "resample_poly: up/down must be ≥1");
235        assert!(!taps.is_empty(), "resample_poly: need ≥1 tap");
236        let shape = self.shape(x).clone();
237        let rank = shape.rank();
238        let last = rank - 1;
239        let n = shape.dim(last).unwrap_static();
240        let lead: Vec<i64> = (0..last)
241            .map(|i| shape.dim(i).unwrap_static() as i64)
242            .collect();
243
244        // Zero-stuff by `up`: [.., N] → [.., N, 1] ⊕ zeros[.., N, up-1] → [.., N·up].
245        let up_sig = if up == 1 {
246            x
247        } else {
248            let mut d1: Vec<i64> = lead.clone();
249            d1.push(n as i64);
250            d1.push(1);
251            let xr = self.reshape_(x, d1);
252            let mut zdims: Vec<usize> = shape
253                .dims()
254                .iter()
255                .take(last)
256                .map(|d| d.unwrap_static())
257                .collect();
258            zdims.push(n);
259            zdims.push(up - 1);
260            let zeros = self.const_f32_tensor(vec![0.0; zdims.iter().product()], &zdims);
261            let stacked = self.concat_(vec![xr, zeros], last + 1); // [.., N, up]
262            let mut flat: Vec<i64> = lead.clone();
263            flat.push((n * up) as i64);
264            self.reshape_(stacked, flat)
265        };
266
267        // FIR anti-alias filter (gain `up` compensates the zero-stuffing).
268        let l = n * up;
269        let scaled: Vec<f32> = taps.iter().map(|&t| t * up as f32).collect();
270        let h = self.const_f32_tensor(scaled, &[taps.len()]);
271        let filtered = self.fir_conv_same(up_sig, h, taps.len(), l);
272
273        // Decimate by `down`: pad to a multiple of `down`, reshape [.., M, down],
274        // keep phase-0 column.
275        if down == 1 {
276            return filtered;
277        }
278        let out_len = l.div_ceil(down);
279        let padded = out_len * down;
280        let filtered = if padded > l {
281            let mut zdims: Vec<usize> = shape
282                .dims()
283                .iter()
284                .take(last)
285                .map(|d| d.unwrap_static())
286                .collect();
287            zdims.push(padded - l);
288            let zeros = self.const_f32_tensor(vec![0.0; zdims.iter().product()], &zdims);
289            self.concat_(vec![filtered, zeros], last)
290        } else {
291            filtered
292        };
293        let mut rdims: Vec<i64> = lead.clone();
294        rdims.push(out_len as i64);
295        rdims.push(down as i64);
296        let reshaped = self.reshape_(filtered, rdims); // [.., out_len, down]
297        let col0 = self.narrow_(reshaped, last + 1, 0, 1); // [.., out_len, 1]
298        let mut odims: Vec<i64> = lead;
299        odims.push(out_len as i64);
300        self.reshape_(col0, odims)
301    }
302
303    fn complex_abs(&mut self, re: NodeId, im: NodeId) -> NodeId {
304        let re2 = self.mul(re, re);
305        let im2 = self.mul(im, im);
306        let sum = self.add(re2, im2);
307        self.sqrt(sum)
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    fn dims(g: &Graph, id: NodeId) -> Vec<usize> {
316        g.shape(id)
317            .dims()
318            .iter()
319            .map(|d| d.unwrap_static())
320            .collect()
321    }
322    fn input(g: &mut Graph, shape: &[usize]) -> NodeId {
323        g.input("x", Shape::new(shape, DType::F32))
324    }
325
326    #[test]
327    fn hilbert_preserves_shape() {
328        let mut g = Graph::new("hil");
329        let x = input(&mut g, &[3, 128]);
330        let (re, im) = g.hilbert(x);
331        assert_eq!(dims(&g, re), vec![3, 128]);
332        assert_eq!(dims(&g, im), vec![3, 128]);
333    }
334
335    #[test]
336    fn envelope_and_phase_shape() {
337        let mut g = Graph::new("env");
338        let x = input(&mut g, &[2, 100]);
339        let e = g.envelope(x);
340        let p = g.instantaneous_phase(x);
341        assert_eq!(dims(&g, e), vec![2, 100]);
342        assert_eq!(dims(&g, p), vec![2, 100]);
343    }
344
345    #[test]
346    fn filtfilt_same_length() {
347        let mut g = Graph::new("ff");
348        let x = input(&mut g, &[2, 200]);
349        let taps: Vec<f32> = (0..15).map(|i| 1.0 / (i as f32 + 1.0)).collect();
350        let y = g.fir_filtfilt(x, &taps);
351        assert_eq!(dims(&g, y), vec![2, 200]);
352    }
353
354    #[test]
355    fn biquad_and_sosfilt_same_length() {
356        let mut g = Graph::new("iir");
357        let x = input(&mut g, &[3, 128]);
358        let b = [0.2, 0.4, 0.2];
359        let a = [1.0, -0.3, 0.1];
360        let y = g.biquad(x, b, a);
361        assert_eq!(dims(&g, y), vec![3, 128]);
362        let sos = [(b, a), ([0.5, 0.0, 0.5], [1.0, 0.2, 0.05])];
363        let z = g.sosfiltfilt(x, &sos);
364        assert_eq!(dims(&g, z), vec![3, 128]);
365    }
366
367    #[test]
368    fn resample_poly_length() {
369        let mut g = Graph::new("rs");
370        let x = input(&mut g, &[2, 100]);
371        let taps: Vec<f32> = (0..13).map(|i| 1.0 / (i as f32 + 1.0)).collect();
372        // up=3, down=2 → ceil(100*3/2) = 150.
373        let y = g.resample_poly(x, 3, 2, &taps);
374        assert_eq!(dims(&g, y), vec![2, 150]);
375    }
376}