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
36/// FIR filters with at most this many taps use the direct time-domain
37/// (shift-and-add) path in [`Graph::fir_conv1d`]; longer filters use the FFT
38/// convolution theorem. Direct form avoids two FFTs and is exact for short
39/// kernels; the crossover is deliberately conservative.
40const FIR_DIRECT_MAX_TAPS: usize = 64;
41
42/// Output-length convention for [`Graph::fir_conv1d`] — mirrors NumPy / SciPy
43/// convolution modes, plus a causal-filter mode.
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45pub enum FirMode {
46    /// Full linear convolution, last-axis length `L + K − 1`.
47    Full,
48    /// Centered same-length output, length `L` (NumPy `mode="same"`).
49    Same,
50    /// Only fully-overlapping positions, length `L − K + 1` (NumPy `mode="valid"`).
51    Valid,
52    /// Causal FIR `y[n] = Σ_k h[k]·x[n−k]`, length `L` (the first `L` samples of
53    /// `Full`) — the convention of `scipy.signal.lfilter` with an FIR kernel.
54    Causal,
55}
56
57/// Truncated impulse response of the IIR filter `(b, a)` (host, `f32`).
58///
59/// Runs a Direct-Form-II-Transposed recurrence on a unit impulse for `n`
60/// samples: `h[t]` for `t ∈ [0, n)`. `a[0]` need not be normalized. Used by
61/// [`Graph::iir_as_fir`] to turn a stable IIR into an FIR that lowers natively
62/// on every backend.
63pub fn iir_impulse_response(b: &[f32], a: &[f32], n: usize) -> Vec<f32> {
64    assert!(
65        !b.is_empty() && !a.is_empty(),
66        "iir_impulse_response: empty coeffs"
67    );
68    assert!(a[0] != 0.0, "iir_impulse_response: a0 must be non-zero");
69    let m = b.len().max(a.len());
70    let a0 = a[0];
71    let bn: Vec<f32> = (0..m)
72        .map(|i| b.get(i).copied().unwrap_or(0.0) / a0)
73        .collect();
74    let an: Vec<f32> = (0..m)
75        .map(|i| a.get(i).copied().unwrap_or(0.0) / a0)
76        .collect();
77    let s = m - 1; // number of state variables
78    let mut w = vec![0f32; s];
79    let mut out = Vec::with_capacity(n);
80    for t in 0..n {
81        let x = if t == 0 { 1.0 } else { 0.0 };
82        let y = bn[0] * x + if s > 0 { w[0] } else { 0.0 };
83        let mut new_w = vec![0f32; s];
84        for i in 0..s {
85            let mut wi = bn[i + 1] * x - an[i + 1] * y;
86            if i + 1 < s {
87                wi += w[i + 1];
88            }
89            new_w[i] = wi;
90        }
91        w = new_w;
92        out.push(y);
93    }
94    out
95}
96
97impl Graph {
98    /// Analytic signal of the last axis. Returns `(real, imag)`, each the same
99    /// shape as `x`; `real ≈ x` and `imag = H(x)` (the Hilbert transform).
100    pub fn hilbert(&mut self, x: NodeId) -> (NodeId, NodeId) {
101        let shape = self.shape(x).clone();
102        let last = shape.rank() - 1;
103        let l = shape.dim(last).unwrap_static();
104        let n = crate::fft::next_pow2(l);
105
106        // Unnormalized forward DFT of the (real) signal, full n-bin spectrum.
107        let (re, im) = self.fft_real(x, FftNorm::Backward); // each [.., n]
108
109        // Hilbert multiplier h: DC×1, positive freqs ×2, Nyquist×1, negatives ×0.
110        let mut h = vec![0f32; n];
111        h[0] = 1.0;
112        if n >= 2 {
113            h[n / 2] = 1.0;
114            for hk in h.iter_mut().take(n / 2).skip(1) {
115                *hk = 2.0;
116            }
117        }
118        let h_node = self.const_f32_tensor(h, &[n]);
119        let re_h = self.mul(re, h_node);
120        let im_h = self.mul(im, h_node);
121
122        // Inverse DFT with 1/N scaling (numpy `ifft`), keeping both parts.
123        let block = self.concat_(vec![re_h, im_h], last);
124        let full = self.fft_norm(block, true, FftNorm::Forward); // [.., 2n]
125        let a_re = self.narrow_(full, last, 0, n);
126        let a_im = self.narrow_(full, last, n, n);
127        // Truncate the pow2 padding back to the original length.
128        (
129            self.narrow_(a_re, last, 0, l),
130            self.narrow_(a_im, last, 0, l),
131        )
132    }
133
134    /// Amplitude envelope `|x + i·H(x)|` of the last axis (same shape as `x`).
135    pub fn envelope(&mut self, x: NodeId) -> NodeId {
136        let (re, im) = self.hilbert(x);
137        self.complex_abs(re, im)
138    }
139
140    /// Instantaneous phase `atan2(H(x), x)` of the last axis, in radians.
141    ///
142    /// Uses the branch-cut-free identity
143    /// `atan2(y, x) = 2·atan(y / (√(x²+y²) + x))`, exact everywhere except the
144    /// negative real axis (a measure-zero set).
145    pub fn instantaneous_phase(&mut self, x: NodeId) -> NodeId {
146        let (re, im) = self.hilbert(x);
147        let r = self.complex_abs(re, im);
148        let denom = self.add(r, re);
149        let eps = self.constant(1e-12, DType::F32);
150        let denom = self.add(denom, eps);
151        let ratio = self.div(im, denom);
152        let s = crate::shape::unary_shape(self.shape(ratio));
153        let at = self.activation(Activation::Atan, ratio, s);
154        let two = self.constant(2.0, DType::F32);
155        self.mul(at, two)
156    }
157
158    /// Zero-phase FIR filtering: `reverse(fir(reverse(fir(x))))`.
159    ///
160    /// `taps` are the FIR coefficients (host constant). Output has the same
161    /// last-axis length as `x`; the two passes cancel the filter's phase, so
162    /// the effective magnitude response is `|H(f)|²`. This is the zero-phase
163    /// band-pass the `exg` preprocessor uses for MNE parity.
164    pub fn fir_filtfilt(&mut self, x: NodeId, taps: &[f32]) -> NodeId {
165        let k = taps.len();
166        assert!(k > 0, "fir_filtfilt: need ≥1 tap");
167        let last = self.shape(x).rank() - 1;
168        let l = self.shape(x).dim(last).unwrap_static();
169        let h = self.const_f32_tensor(taps.to_vec(), &[k]);
170        let y1 = self.fir_conv_same(x, h, k, l);
171        let y2 = self.reverse(y1, vec![last]);
172        let y3 = self.fir_conv_same(y2, h, k, l);
173        self.reverse(y3, vec![last])
174    }
175
176    /// Single-pass "same"-length FIR convolution along the last axis.
177    ///
178    /// `FftNorm::Forward` normalizes the inverse by `1/N`, so the FFT
179    /// convolution theorem yields the un-scaled linear convolution (an
180    /// unnormalized round-trip would scale by `n_fft`).
181    fn fir_conv_same(&mut self, x: NodeId, taps: NodeId, k: usize, l: usize) -> NodeId {
182        let full = self.fft_conv1d(x, taps, 0, FftNorm::Forward); // [.., l+k-1]
183        let start = (k - 1) / 2;
184        let last = self.shape(full).rank() - 1;
185        self.narrow_(full, last, start, l)
186    }
187
188    /// One biquad (2nd-order IIR) section applied along the last axis,
189    /// Direct-Form II Transposed. `b = [b0, b1, b2]`, `a = [a0, a1, a2]`
190    /// (coefficients are normalized by `a0`). This is a true recurrence —
191    /// `y[n] = b0·x[n] + z1[n−1]`, etc. — evaluated with an `Op::Scan` over
192    /// time, so it currently lowers on the CPU backend (where `Op::Scan`
193    /// runs); GPU support tracks `Op::Scan` GPU lowering.
194    ///
195    /// Any rank ≥ 1 is accepted; leading axes are independent channels.
196    pub fn biquad(&mut self, x: NodeId, b: [f32; 3], a: [f32; 3]) -> NodeId {
197        assert!(a[0] != 0.0, "biquad: a0 must be non-zero");
198        let (b0, b1, b2) = (b[0] / a[0], b[1] / a[0], b[2] / a[0]);
199        let (a1, a2) = (a[1] / a[0], a[2] / a[0]);
200
201        let xs_shape = self.shape(x).clone();
202        let orig_dims: Vec<i64> = xs_shape
203            .dims()
204            .iter()
205            .map(|d| d.unwrap_static() as i64)
206            .collect();
207        let rank = xs_shape.rank();
208        let n = xs_shape.dim(rank - 1).unwrap_static();
209        let p: usize = (0..rank - 1)
210            .map(|i| xs_shape.dim(i).unwrap_static())
211            .product();
212
213        // Reorder to [N, P] so time is the scan axis, channels are per-step.
214        let x_pn = self.reshape_(x, vec![p as i64, n as i64]);
215        let xs = self.transpose_(x_pn, vec![1, 0]); // [N, P]
216
217        // Scan body: carry = [P, 3] holding (y, z1, z2); x_t = [P].
218        let mut body = Graph::new("biquad_body");
219        let carry = body.input("carry", Shape::new(&[p, 3], DType::F32));
220        let x_t = body.input("x_t", Shape::new(&[p], DType::F32));
221        let z1p = body.narrow_(carry, 1, 1, 1);
222        let z1p = body.reshape_(z1p, vec![p as i64]);
223        let z2p = body.narrow_(carry, 1, 2, 1);
224        let z2p = body.reshape_(z2p, vec![p as i64]);
225        let cb0 = body.constant(b0 as f64, DType::F32);
226        let cb1 = body.constant(b1 as f64, DType::F32);
227        let cb2 = body.constant(b2 as f64, DType::F32);
228        let ca1 = body.constant(a1 as f64, DType::F32);
229        let ca2 = body.constant(a2 as f64, DType::F32);
230        let b0x = body.mul(x_t, cb0);
231        let y = body.add(b0x, z1p); // y = b0·x + z1
232        let b1x = body.mul(x_t, cb1);
233        let a1y = body.mul(y, ca1);
234        let z1_tmp = body.sub(b1x, a1y);
235        let z1 = body.add(z1_tmp, z2p); // z1 = b1·x − a1·y + z2
236        let b2x = body.mul(x_t, cb2);
237        let a2y = body.mul(y, ca2);
238        let z2 = body.sub(b2x, a2y); // z2 = b2·x − a2·y
239        let yc = body.reshape_(y, vec![p as i64, 1]);
240        let z1c = body.reshape_(z1, vec![p as i64, 1]);
241        let z2c = body.reshape_(z2, vec![p as i64, 1]);
242        let next = body.concat_(vec![yc, z1c, z2c], 1); // [P, 3]
243        body.set_outputs(vec![next]);
244
245        // Trajectory scan with per-step xs → [N, P, 3].
246        let init = self.const_f32_tensor(vec![0.0; p * 3], &[p, 3]);
247        let traj = self.add_node(
248            Op::Scan {
249                body: Box::new(body),
250                length: n as u32,
251                save_trajectory: true,
252                num_bcast: 0,
253                num_xs: 1,
254                num_checkpoints: 0,
255            },
256            vec![init, xs],
257            Shape::new(&[n, p, 3], DType::F32),
258        );
259        // Extract the y column → [N, P] → [P, N] → original shape.
260        let y_np = self.narrow_(traj, 2, 0, 1); // [N, P, 1]
261        let y_np = self.reshape_(y_np, vec![n as i64, p as i64]);
262        let y_pn = self.transpose_(y_np, vec![1, 0]); // [P, N]
263        self.reshape_(y_pn, orig_dims)
264    }
265
266    /// Cascade of second-order sections (scipy `sosfilt`): apply each
267    /// `(b, a)` biquad in series along the last axis.
268    pub fn sosfilt(&mut self, x: NodeId, sections: &[([f32; 3], [f32; 3])]) -> NodeId {
269        let mut y = x;
270        for &(b, a) in sections {
271            y = self.biquad(y, b, a);
272        }
273        y
274    }
275
276    /// Zero-phase IIR filtering (scipy `sosfiltfilt`): forward pass, reverse,
277    /// forward pass, reverse. Cancels the SOS cascade's phase, leaving the
278    /// squared magnitude response.
279    pub fn sosfiltfilt(&mut self, x: NodeId, sections: &[([f32; 3], [f32; 3])]) -> NodeId {
280        let last = self.shape(x).rank() - 1;
281        let f1 = self.sosfilt(x, sections);
282        let r1 = self.reverse(f1, vec![last]);
283        let f2 = self.sosfilt(r1, sections);
284        self.reverse(f2, vec![last])
285    }
286
287    /// Polyphase rational resampling of the last axis by `up`/`down`.
288    ///
289    /// Zero-stuffs by `up`, applies the FIR anti-alias filter `taps` (scaled
290    /// by `up` to preserve amplitude), then decimates by `down`. Output
291    /// last-axis length is `ceil(N·up / down)`. Composed from
292    /// reshape/concat/FFT-conv/narrow, so it runs on every backend that
293    /// supports those (all Apple-Silicon backends).
294    pub fn resample_poly(&mut self, x: NodeId, up: usize, down: usize, taps: &[f32]) -> NodeId {
295        assert!(up >= 1 && down >= 1, "resample_poly: up/down must be ≥1");
296        assert!(!taps.is_empty(), "resample_poly: need ≥1 tap");
297        let shape = self.shape(x).clone();
298        let rank = shape.rank();
299        let last = rank - 1;
300        let n = shape.dim(last).unwrap_static();
301        let lead: Vec<i64> = (0..last)
302            .map(|i| shape.dim(i).unwrap_static() as i64)
303            .collect();
304
305        // Zero-stuff by `up`: [.., N] → [.., N, 1] ⊕ zeros[.., N, up-1] → [.., N·up].
306        let up_sig = if up == 1 {
307            x
308        } else {
309            let mut d1: Vec<i64> = lead.clone();
310            d1.push(n as i64);
311            d1.push(1);
312            let xr = self.reshape_(x, d1);
313            let mut zdims: Vec<usize> = shape
314                .dims()
315                .iter()
316                .take(last)
317                .map(|d| d.unwrap_static())
318                .collect();
319            zdims.push(n);
320            zdims.push(up - 1);
321            let zeros = self.const_f32_tensor(vec![0.0; zdims.iter().product()], &zdims);
322            let stacked = self.concat_(vec![xr, zeros], last + 1); // [.., N, up]
323            let mut flat: Vec<i64> = lead.clone();
324            flat.push((n * up) as i64);
325            self.reshape_(stacked, flat)
326        };
327
328        // FIR anti-alias filter (gain `up` compensates the zero-stuffing).
329        let l = n * up;
330        let scaled: Vec<f32> = taps.iter().map(|&t| t * up as f32).collect();
331        let h = self.const_f32_tensor(scaled, &[taps.len()]);
332        let filtered = self.fir_conv_same(up_sig, h, taps.len(), l);
333
334        // Decimate by `down`: pad to a multiple of `down`, reshape [.., M, down],
335        // keep phase-0 column.
336        if down == 1 {
337            return filtered;
338        }
339        let out_len = l.div_ceil(down);
340        let padded = out_len * down;
341        let filtered = if padded > l {
342            let mut zdims: Vec<usize> = shape
343                .dims()
344                .iter()
345                .take(last)
346                .map(|d| d.unwrap_static())
347                .collect();
348            zdims.push(padded - l);
349            let zeros = self.const_f32_tensor(vec![0.0; zdims.iter().product()], &zdims);
350            self.concat_(vec![filtered, zeros], last)
351        } else {
352            filtered
353        };
354        let mut rdims: Vec<i64> = lead.clone();
355        rdims.push(out_len as i64);
356        rdims.push(down as i64);
357        let reshaped = self.reshape_(filtered, rdims); // [.., out_len, down]
358        let col0 = self.narrow_(reshaped, last + 1, 0, 1); // [.., out_len, 1]
359        let mut odims: Vec<i64> = lead;
360        odims.push(out_len as i64);
361        self.reshape_(col0, odims)
362    }
363
364    /// Single-pass 1-D FIR convolution of the last axis with host `taps`,
365    /// with a NumPy/SciPy-style output [`FirMode`].
366    ///
367    /// Short filters (`≤ FIR_DIRECT_MAX_TAPS`) use a direct time-domain
368    /// shift-and-add (exact, no FFT); longer filters use the FFT convolution
369    /// theorem. Both are pure compositions of `mul`/`add`/`concat`/`Op::Fft`,
370    /// so this lowers on **every** backend and stays differentiable. Leading
371    /// axes are independent channels/batches.
372    pub fn fir_conv1d(&mut self, x: NodeId, taps: &[f32], mode: FirMode) -> NodeId {
373        let k = taps.len();
374        assert!(k > 0, "fir_conv1d: need ≥1 tap");
375        let last = self.shape(x).rank() - 1;
376        let l = self.shape(x).dim(last).unwrap_static();
377        // `full` has last-axis length l + k - 1.
378        let full = if k <= FIR_DIRECT_MAX_TAPS {
379            self.fir_full_direct(x, taps)
380        } else {
381            let h = self.const_f32_tensor(taps.to_vec(), &[k]);
382            self.fft_conv1d(x, h, 0, FftNorm::Forward)
383        };
384        match mode {
385            FirMode::Full => full,
386            FirMode::Causal => self.narrow_(full, last, 0, l),
387            FirMode::Same => {
388                let start = (k - 1) / 2;
389                self.narrow_(full, last, start, l)
390            }
391            FirMode::Valid => {
392                assert!(
393                    l + 1 > k,
394                    "fir_conv1d(Valid): signal ({l}) shorter than taps ({k})"
395                );
396                self.narrow_(full, last, k - 1, l - k + 1)
397            }
398        }
399    }
400
401    /// Direct-form (time-domain) full FIR convolution — exact shift-and-add,
402    /// used by [`Self::fir_conv1d`] for short filters. Output length `L + K − 1`.
403    fn fir_full_direct(&mut self, x: NodeId, taps: &[f32]) -> NodeId {
404        let k = taps.len();
405        let last = self.shape(x).rank() - 1;
406        let l = self.shape(x).dim(last).unwrap_static();
407        let mut acc: Option<NodeId> = None;
408        for (j, &h) in taps.iter().enumerate() {
409            if h == 0.0 {
410                continue;
411            }
412            let c = self.constant(h as f64, DType::F32);
413            let scaled = self.mul(x, c);
414            let term = self.fir_pad_last(scaled, j, k - 1 - j);
415            acc = Some(match acc {
416                None => term,
417                Some(a) => self.add(a, term),
418            });
419        }
420        match acc {
421            Some(a) => a,
422            None => {
423                let mut dims: Vec<usize> = self
424                    .shape(x)
425                    .dims()
426                    .iter()
427                    .map(|d| d.unwrap_static())
428                    .collect();
429                dims[last] = l + k - 1;
430                self.fir_zeros_dims(&dims)
431            }
432        }
433    }
434
435    /// Long 1-D convolution via **uniform-partitioned overlap-save (UPOLS)** —
436    /// the GPU-friendly algorithm for room-impulse-response (RIR) / convolution
437    /// reverb, where the impulse response is thousands to millions of taps.
438    ///
439    /// The impulse response `ir` (rank-1, length `M`) is split into
440    /// `P = ⌈M/B⌉` partitions of `B = next_pow2(block)`; each is zero-padded to
441    /// `2B` and pre-transformed to `H_p`. The signal `x` (`[.., L]`) is streamed
442    /// as overlap-save frames of `2B`; the per-partition spectra are summed in
443    /// the frequency domain (a frequency-domain delay line,
444    /// `Y[m] = Σ_p H_p · X[m−p]`) and inverse-transformed, keeping the valid `B`
445    /// samples per frame.
446    ///
447    /// Every FFT is a fixed `2B` points — small enough to stay on the native
448    /// Metal/WGPU FFT kernels (unlike a single `L+M−1` transform) — and the
449    /// whole thing is a pure composition of `Op::Fft` + elementwise + shape ops,
450    /// so it lowers on **all** backends. Output is the full linear convolution,
451    /// last-axis length `L + M − 1`.
452    pub fn partitioned_conv1d(&mut self, x: NodeId, ir: NodeId, block: usize) -> NodeId {
453        let xs = self.shape(x).clone();
454        let rank = xs.rank();
455        let last = rank - 1;
456        let l = xs.dim(last).unwrap_static();
457        let ir_shape = self.shape(ir).clone();
458        assert_eq!(ir_shape.rank(), 1, "partitioned_conv1d: ir must be rank-1");
459        let m = ir_shape.dim(0).unwrap_static();
460        assert!(m >= 1 && l >= 1, "partitioned_conv1d: empty input/ir");
461
462        let b = crate::fft::next_pow2(block.max(1));
463        let n = 2 * b; // FFT size (power of two)
464        let kbins = n / 2 + 1;
465        let p = m.div_ceil(b);
466        let mf = (l + m - 1).div_ceil(b);
467
468        // IR partition spectra: [M] → [P, B] → [P, 2B] → rfft → [P, K].
469        let ir_pad = self.fir_pad_last(ir, 0, p * b - m); // [P*B]
470        let ir_blocks = self.reshape_(ir_pad, vec![p as i64, b as i64]); // [P, B]
471        let ir_zeros = self.fir_zeros_dims(&[p, b]); // [P, B]
472        let ir_frames = self.concat_(vec![ir_blocks, ir_zeros], 1); // [P, 2B]
473        let (h_re, h_im) = self.rfft(ir_frames, FftNorm::Forward); // [P, K]
474
475        // Frame x with overlap-save: prepend B zeros, pad tail to (Mf+1)·B.
476        let back = mf * b - l; // ≥ 0 since Mf·B ≥ L
477        let x_pad = self.fir_pad_last(x, b, back); // [.., (Mf+1)·B]
478        let mut blk_dims: Vec<i64> = (0..last)
479            .map(|i| xs.dim(i).unwrap_static() as i64)
480            .collect();
481        blk_dims.push((mf + 1) as i64);
482        blk_dims.push(b as i64);
483        let blocks = self.reshape_(x_pad, blk_dims); // [.., Mf+1, B]
484        let brank = self.shape(blocks).rank();
485        let baxis = brank - 2; // block axis
486        let blast = brank - 1; // B axis
487        let left = self.narrow_(blocks, baxis, 0, mf); // [.., Mf, B]
488        let right = self.narrow_(blocks, baxis, 1, mf); // [.., Mf, B]
489        let frames = self.concat_(vec![left, right], blast); // [.., Mf, 2B]
490        let (x_re, x_im) = self.rfft(frames, FftNorm::Forward); // [.., Mf, K]
491
492        // Frequency-domain delay line: Y[m] = Σ_p H[p] · X[m−p].
493        let mf_axis = self.shape(x_re).rank() - 2;
494        let mut acc: Option<(NodeId, NodeId)> = None;
495        for pp in 0..p.min(mf) {
496            let hp_re = self.narrow_(h_re, 0, pp, 1);
497            let hp_re = self.reshape_(hp_re, vec![kbins as i64]); // [K]
498            let hp_im = self.narrow_(h_im, 0, pp, 1);
499            let hp_im = self.reshape_(hp_im, vec![kbins as i64]); // [K]
500            // X shifted down by pp along the frame axis (zero-fill first pp frames).
501            let (xr_s, xi_s) = if pp == 0 {
502                (x_re, x_im)
503            } else {
504                let keep = mf - pp;
505                let mut zdims: Vec<usize> = self
506                    .shape(x_re)
507                    .dims()
508                    .iter()
509                    .map(|d| d.unwrap_static())
510                    .collect();
511                zdims[mf_axis] = pp;
512                let zre = self.fir_zeros_dims(&zdims);
513                let zim = self.fir_zeros_dims(&zdims);
514                let hr = self.narrow_(x_re, mf_axis, 0, keep);
515                let hi = self.narrow_(x_im, mf_axis, 0, keep);
516                (
517                    self.concat_(vec![zre, hr], mf_axis),
518                    self.concat_(vec![zim, hi], mf_axis),
519                )
520            };
521            // Complex multiply (H_re + iH_im)·(X_re + iX_im), broadcasting [K].
522            let rr = self.mul(hp_re, xr_s);
523            let ii = self.mul(hp_im, xi_s);
524            let t_re = self.sub(rr, ii);
525            let ri = self.mul(hp_re, xi_s);
526            let ir2 = self.mul(hp_im, xr_s);
527            let t_im = self.add(ri, ir2);
528            acc = Some(match acc {
529                None => (t_re, t_im),
530                Some((ar, ai)) => (self.add(ar, t_re), self.add(ai, t_im)),
531            });
532        }
533        let (y_re, y_im) = acc.expect("partitioned_conv1d: no partitions");
534
535        // Inverse transform, overlap-save trim (keep last B), reassemble.
536        let y_frames = self.irfft(y_re, y_im, n, FftNorm::Forward); // [.., Mf, 2B]
537        let ylast = self.shape(y_frames).rank() - 1;
538        let valid = self.narrow_(y_frames, ylast, b, b); // [.., Mf, B]
539        let mut out_dims: Vec<i64> = (0..last)
540            .map(|i| xs.dim(i).unwrap_static() as i64)
541            .collect();
542        out_dims.push((mf * b) as i64);
543        let flat = self.reshape_(valid, out_dims); // [.., Mf·B]
544        let flast = self.shape(flat).rank() - 1;
545        self.narrow_(flat, flast, 0, l + m - 1) // [.., L+M-1]
546    }
547
548    /// Convolution reverb: apply a room impulse response `ir` (host samples) to
549    /// `x` via [`Self::partitioned_conv1d`]. Output is the full wet signal,
550    /// last-axis length `L + len(ir) − 1` (the reverb tail extends past `x`).
551    ///
552    /// `block` sets the partition / FFT size: `512` keeps every FFT at `1024`
553    /// (within the WGPU native cap), `1024` keeps it at `2048` (Metal cap).
554    pub fn conv_reverb(&mut self, x: NodeId, ir: &[f32], block: usize) -> NodeId {
555        assert!(!ir.is_empty(), "conv_reverb: empty impulse response");
556        let ir_node = self.const_f32_tensor(ir.to_vec(), &[ir.len()]);
557        self.partitioned_conv1d(x, ir_node, block)
558    }
559
560    /// Fused [`crate::Op::PartitionedConv`] node — the single-op form of
561    /// [`Self::partitioned_conv1d`]. A backend may lower it natively; otherwise
562    /// `unfuse` decomposes it to the batched-GEMM frequency-domain path
563    /// ([`Self::partitioned_conv1d_gemm`]). Output is the full linear
564    /// convolution, last-axis length `L + M − 1`.
565    pub fn partitioned_conv(&mut self, x: NodeId, ir: NodeId, block: usize) -> NodeId {
566        let xs = self.shape(x).clone();
567        let last = xs.rank() - 1;
568        let l = xs.dim(last).unwrap_static();
569        let ir_shape = self.shape(ir).clone();
570        assert_eq!(ir_shape.rank(), 1, "partitioned_conv: ir must be rank-1");
571        let m = ir_shape.dim(0).unwrap_static();
572        let mut dims: Vec<usize> = xs.dims().iter().map(|d| d.unwrap_static()).collect();
573        dims[last] = l + m - 1;
574        let out_shape = Shape::new(&dims, xs.dtype());
575        self.push(Op::PartitionedConv { block }, vec![x, ir], out_shape, None)
576    }
577
578    /// GEMM-path variant of [`Self::partitioned_conv1d`]: the frequency-domain
579    /// delay-line sum `Y[m,k] = Σ_p H[p,k]·X[m−p,k]` is evaluated as a **batched
580    /// complex matmul** contracting the partition axis (batched over FFT bin
581    /// `k`) instead of `P` elementwise multiply-accumulates. On CUDA/ROCm/Metal
582    /// this routes the accumulation through the native batched-GEMM kernels
583    /// (cuBLAS / rocBLAS / MPS — the tensor-core path) while staying a pure op
584    /// composition that lowers on every backend. Numerically identical to
585    /// [`Self::partitioned_conv1d`]; this is the decomposition behind
586    /// [`crate::Op::PartitionedConv`].
587    pub fn partitioned_conv1d_gemm(&mut self, x: NodeId, ir: NodeId, block: usize) -> NodeId {
588        let xs = self.shape(x).clone();
589        let rank = xs.rank();
590        let last = rank - 1;
591        let l = xs.dim(last).unwrap_static();
592        let ir_shape = self.shape(ir).clone();
593        assert_eq!(
594            ir_shape.rank(),
595            1,
596            "partitioned_conv1d_gemm: ir must be rank-1"
597        );
598        let m = ir_shape.dim(0).unwrap_static();
599        assert!(m >= 1 && l >= 1, "partitioned_conv1d_gemm: empty input/ir");
600
601        let b = crate::fft::next_pow2(block.max(1));
602        let n = 2 * b;
603        let kbins = n / 2 + 1;
604        let p = m.div_ceil(b);
605        let mf = (l + m - 1).div_ceil(b);
606
607        // IR partition spectra: [M] → [P, 2B] → rfft → [P, K].
608        let ir_pad = self.fir_pad_last(ir, 0, p * b - m);
609        let ir_blocks = self.reshape_(ir_pad, vec![p as i64, b as i64]);
610        let ir_zeros = self.fir_zeros_dims(&[p, b]);
611        let ir_frames = self.concat_(vec![ir_blocks, ir_zeros], 1);
612        let (h_re, h_im) = self.rfft(ir_frames, FftNorm::Forward); // [P, K]
613
614        // Overlap-save frames of x → rfft → X [.., Mf, K].
615        let back = mf * b - l;
616        let x_pad = self.fir_pad_last(x, b, back);
617        let mut blk_dims: Vec<i64> = (0..last)
618            .map(|i| xs.dim(i).unwrap_static() as i64)
619            .collect();
620        blk_dims.push((mf + 1) as i64);
621        blk_dims.push(b as i64);
622        let blocks = self.reshape_(x_pad, blk_dims);
623        let brank = self.shape(blocks).rank();
624        let baxis = brank - 2;
625        let blast = brank - 1;
626        let left = self.narrow_(blocks, baxis, 0, mf);
627        let right = self.narrow_(blocks, baxis, 1, mf);
628        let frames = self.concat_(vec![left, right], blast);
629        let (x_re, x_im) = self.rfft(frames, FftNorm::Forward); // [.., Mf, K]
630
631        // Build the delay-line tensor U[.., Mf, P, K] = stack_p shift(X, p),
632        // then contract the P axis with H via one batched complex matmul.
633        let xr = self.shape(x_re).rank();
634        let mf_axis = xr - 2;
635        let lead: Vec<usize> = (0..mf_axis)
636            .map(|i| self.shape(x_re).dim(i).unwrap_static())
637            .collect();
638        let rows: usize = lead.iter().product::<usize>() * mf;
639
640        let mut u_re_parts = Vec::with_capacity(p);
641        let mut u_im_parts = Vec::with_capacity(p);
642        for pp in 0..p {
643            let (sre, sim) = if pp == 0 {
644                (x_re, x_im)
645            } else {
646                let keep = mf - pp;
647                let mut zd: Vec<usize> = self
648                    .shape(x_re)
649                    .dims()
650                    .iter()
651                    .map(|d| d.unwrap_static())
652                    .collect();
653                zd[mf_axis] = pp;
654                let zre = self.fir_zeros_dims(&zd);
655                let zim = self.fir_zeros_dims(&zd);
656                let hr = self.narrow_(x_re, mf_axis, 0, keep);
657                let hi = self.narrow_(x_im, mf_axis, 0, keep);
658                (
659                    self.concat_(vec![zre, hr], mf_axis),
660                    self.concat_(vec![zim, hi], mf_axis),
661                )
662            };
663            // insert a size-1 P slot before K: [.., Mf, K] → [.., Mf, 1, K]
664            let mut d: Vec<i64> = self
665                .shape(sre)
666                .dims()
667                .iter()
668                .map(|x| x.unwrap_static() as i64)
669                .collect();
670            d.insert(d.len() - 1, 1);
671            u_re_parts.push(self.reshape_(sre, d.clone()));
672            u_im_parts.push(self.reshape_(sim, d));
673        }
674        let u_re = self.concat_(u_re_parts, mf_axis + 1); // [.., Mf, P, K]
675        let u_im = self.concat_(u_im_parts, mf_axis + 1);
676
677        // Flatten to [R, P, K], move to [K, R, P] for a batch-over-K matmul.
678        let u_re = self.reshape_(u_re, vec![rows as i64, p as i64, kbins as i64]);
679        let u_im = self.reshape_(u_im, vec![rows as i64, p as i64, kbins as i64]);
680        let u_re = self.transpose_(u_re, vec![2, 0, 1]); // [K, R, P]
681        let u_im = self.transpose_(u_im, vec![2, 0, 1]);
682        let h_re_t = self.transpose_(h_re, vec![1, 0]); // [K, P]
683        let h_im_t = self.transpose_(h_im, vec![1, 0]);
684        let h_re_c = self.reshape_(h_re_t, vec![kbins as i64, p as i64, 1]); // [K, P, 1]
685        let h_im_c = self.reshape_(h_im_t, vec![kbins as i64, p as i64, 1]);
686
687        let out_krp = Shape::new(&[kbins, rows, 1], DType::F32);
688        let rr = self.matmul(u_re, h_re_c, out_krp.clone());
689        let ii = self.matmul(u_im, h_im_c, out_krp.clone());
690        let y_re_k = self.sub(rr, ii); // Re = Σ (Xre·Hre − Xim·Him)
691        let ri = self.matmul(u_re, h_im_c, out_krp.clone());
692        let ir2 = self.matmul(u_im, h_re_c, out_krp);
693        let y_im_k = self.add(ri, ir2); // Im = Σ (Xre·Him + Xim·Hre)
694
695        // [K, R, 1] → [K, R] → [R, K] → [.., Mf, K].
696        let y_re = self.reshape_(y_re_k, vec![kbins as i64, rows as i64]);
697        let y_re = self.transpose_(y_re, vec![1, 0]);
698        let y_im = self.reshape_(y_im_k, vec![kbins as i64, rows as i64]);
699        let y_im = self.transpose_(y_im, vec![1, 0]);
700        let mut yd: Vec<i64> = lead.iter().map(|&d| d as i64).collect();
701        yd.push(mf as i64);
702        yd.push(kbins as i64);
703        let y_re = self.reshape_(y_re, yd.clone());
704        let y_im = self.reshape_(y_im, yd);
705
706        // Inverse transform, overlap-save trim, reassemble → [.., L+M−1].
707        let y_frames = self.irfft(y_re, y_im, n, FftNorm::Forward);
708        let ylast = self.shape(y_frames).rank() - 1;
709        let valid = self.narrow_(y_frames, ylast, b, b);
710        let mut out_dims: Vec<i64> = (0..last)
711            .map(|i| xs.dim(i).unwrap_static() as i64)
712            .collect();
713        out_dims.push((mf * b) as i64);
714        let flat = self.reshape_(valid, out_dims);
715        let flast = self.shape(flat).rank() - 1;
716        self.narrow_(flat, flast, 0, l + m - 1)
717    }
718
719    /// General direct-form-II-transposed IIR filter of arbitrary order applied
720    /// along the last axis:
721    /// `a[0]·y[n] = Σ_i b[i]·x[n−i] − Σ_{i≥1} a[i]·y[n−i]`.
722    ///
723    /// A true recurrence evaluated with `Op::Scan` over time — [`Self::biquad`]
724    /// is the order-2 case — so it lowers wherever `Op::Scan` runs (CPU
725    /// natively; Metal/WGPU/oneAPI via host-fallback). For an IIR that runs
726    /// **natively on every** backend, see [`Self::iir_as_fir`]. Leading axes are
727    /// independent channels.
728    pub fn iirfilt(&mut self, x: NodeId, b: &[f32], a: &[f32]) -> NodeId {
729        assert!(
730            !b.is_empty() && !a.is_empty(),
731            "iirfilt: empty coefficients"
732        );
733        assert!(a[0] != 0.0, "iirfilt: a0 must be non-zero");
734        let m = b.len().max(a.len());
735        let a0 = a[0];
736        let bn: Vec<f32> = (0..m)
737            .map(|i| b.get(i).copied().unwrap_or(0.0) / a0)
738            .collect();
739        let an: Vec<f32> = (0..m)
740            .map(|i| a.get(i).copied().unwrap_or(0.0) / a0)
741            .collect();
742        let s = m - 1; // number of state variables
743
744        let xs_shape = self.shape(x).clone();
745        let orig_dims: Vec<i64> = xs_shape
746            .dims()
747            .iter()
748            .map(|d| d.unwrap_static() as i64)
749            .collect();
750        let rank = xs_shape.rank();
751        let nlen = xs_shape.dim(rank - 1).unwrap_static();
752        let pch: usize = (0..rank - 1)
753            .map(|i| xs_shape.dim(i).unwrap_static())
754            .product();
755
756        // [.., N] → [P, N] → [N, P] so time is the scan axis, channels per step.
757        let x_pn = self.reshape_(x, vec![pch as i64, nlen as i64]);
758        let xs = self.transpose_(x_pn, vec![1, 0]); // [N, P]
759
760        // Scan body: carry [P, 1+S] = (y, w[0..S]); x_t [P].
761        let cw = 1 + s;
762        let mut body = Graph::new("iir_body");
763        let carry = body.input("carry", Shape::new(&[pch, cw], DType::F32));
764        let x_t = body.input("x_t", Shape::new(&[pch], DType::F32));
765        let w: Vec<NodeId> = (0..s)
766            .map(|i| {
767                let wi = body.narrow_(carry, 1, 1 + i, 1);
768                body.reshape_(wi, vec![pch as i64])
769            })
770            .collect();
771        let cbn: Vec<NodeId> = (0..m)
772            .map(|i| body.constant(bn[i] as f64, DType::F32))
773            .collect();
774        let can: Vec<NodeId> = (0..m)
775            .map(|i| body.constant(an[i] as f64, DType::F32))
776            .collect();
777        // y = b0·x + w0
778        let b0x = body.mul(x_t, cbn[0]);
779        let y = if s > 0 { body.add(b0x, w[0]) } else { b0x };
780        // new_w[i] = b[i+1]·x − a[i+1]·y + w[i+1]   (last term absent for i = S−1)
781        let mut new_w = Vec::with_capacity(s);
782        for i in 0..s {
783            let bx = body.mul(x_t, cbn[i + 1]);
784            let ay = body.mul(y, can[i + 1]);
785            let mut wi = body.sub(bx, ay);
786            if i + 1 < s {
787                wi = body.add(wi, w[i + 1]);
788            }
789            new_w.push(wi);
790        }
791        // next carry = concat([y], new_w) → [P, 1+S]
792        let mut cols = Vec::with_capacity(cw);
793        cols.push(body.reshape_(y, vec![pch as i64, 1]));
794        for wi in new_w {
795            cols.push(body.reshape_(wi, vec![pch as i64, 1]));
796        }
797        let next = body.concat_(cols, 1);
798        body.set_outputs(vec![next]);
799
800        let init = self.const_f32_tensor(vec![0.0; pch * cw], &[pch, cw]);
801        let traj = self.add_node(
802            Op::Scan {
803                body: Box::new(body),
804                length: nlen as u32,
805                save_trajectory: true,
806                num_bcast: 0,
807                num_xs: 1,
808                num_checkpoints: 0,
809            },
810            vec![init, xs],
811            Shape::new(&[nlen, pch, cw], DType::F32),
812        );
813        // Extract y column [N, P, 1] → [N, P] → [P, N] → original shape.
814        let y_np = self.narrow_(traj, 2, 0, 1);
815        let y_np = self.reshape_(y_np, vec![nlen as i64, pch as i64]);
816        let y_pn = self.transpose_(y_np, vec![1, 0]);
817        self.reshape_(y_pn, orig_dims)
818    }
819
820    /// Stable IIR filtering that runs **natively on every backend**: reduce the
821    /// IIR `(b, a)` to its truncated impulse response (`taps` samples, computed
822    /// on the host via [`iir_impulse_response`]) and apply it as an FIR through
823    /// [`Self::fir_conv1d`] with the given `mode` (`FirMode::Causal` reproduces
824    /// [`Self::iirfilt`] up to truncation error).
825    ///
826    /// Exact for FIR filters (`a = [a0]`); for IIR it is exact up to the
827    /// truncation tail, so choose `taps` past the impulse response's decay.
828    pub fn iir_as_fir(
829        &mut self,
830        x: NodeId,
831        b: &[f32],
832        a: &[f32],
833        taps: usize,
834        mode: FirMode,
835    ) -> NodeId {
836        let h = iir_impulse_response(b, a, taps);
837        self.fir_conv1d(x, &h, mode)
838    }
839
840    /// Zero constant tensor of the given static dims (filter-builder helper).
841    fn fir_zeros_dims(&mut self, dims: &[usize]) -> NodeId {
842        self.const_f32_tensor(vec![0.0; dims.iter().product()], dims)
843    }
844
845    /// Pad the last axis of `x` with `front` leading and `back` trailing zeros.
846    fn fir_pad_last(&mut self, x: NodeId, front: usize, back: usize) -> NodeId {
847        if front == 0 && back == 0 {
848            return x;
849        }
850        let last = self.shape(x).rank() - 1;
851        let mut zdims: Vec<usize> = self
852            .shape(x)
853            .dims()
854            .iter()
855            .map(|d| d.unwrap_static())
856            .collect();
857        let mut parts = Vec::new();
858        if front > 0 {
859            zdims[last] = front;
860            let z = self.fir_zeros_dims(&zdims);
861            parts.push(z);
862        }
863        parts.push(x);
864        if back > 0 {
865            zdims[last] = back;
866            let z = self.fir_zeros_dims(&zdims);
867            parts.push(z);
868        }
869        self.concat_(parts, last)
870    }
871
872    fn complex_abs(&mut self, re: NodeId, im: NodeId) -> NodeId {
873        let re2 = self.mul(re, re);
874        let im2 = self.mul(im, im);
875        let sum = self.add(re2, im2);
876        self.sqrt(sum)
877    }
878}
879
880#[cfg(test)]
881mod tests {
882    use super::*;
883
884    fn dims(g: &Graph, id: NodeId) -> Vec<usize> {
885        g.shape(id)
886            .dims()
887            .iter()
888            .map(|d| d.unwrap_static())
889            .collect()
890    }
891    fn input(g: &mut Graph, shape: &[usize]) -> NodeId {
892        g.input("x", Shape::new(shape, DType::F32))
893    }
894
895    #[test]
896    fn hilbert_preserves_shape() {
897        let mut g = Graph::new("hil");
898        let x = input(&mut g, &[3, 128]);
899        let (re, im) = g.hilbert(x);
900        assert_eq!(dims(&g, re), vec![3, 128]);
901        assert_eq!(dims(&g, im), vec![3, 128]);
902    }
903
904    #[test]
905    fn envelope_and_phase_shape() {
906        let mut g = Graph::new("env");
907        let x = input(&mut g, &[2, 100]);
908        let e = g.envelope(x);
909        let p = g.instantaneous_phase(x);
910        assert_eq!(dims(&g, e), vec![2, 100]);
911        assert_eq!(dims(&g, p), vec![2, 100]);
912    }
913
914    #[test]
915    fn filtfilt_same_length() {
916        let mut g = Graph::new("ff");
917        let x = input(&mut g, &[2, 200]);
918        let taps: Vec<f32> = (0..15).map(|i| 1.0 / (i as f32 + 1.0)).collect();
919        let y = g.fir_filtfilt(x, &taps);
920        assert_eq!(dims(&g, y), vec![2, 200]);
921    }
922
923    #[test]
924    fn biquad_and_sosfilt_same_length() {
925        let mut g = Graph::new("iir");
926        let x = input(&mut g, &[3, 128]);
927        let b = [0.2, 0.4, 0.2];
928        let a = [1.0, -0.3, 0.1];
929        let y = g.biquad(x, b, a);
930        assert_eq!(dims(&g, y), vec![3, 128]);
931        let sos = [(b, a), ([0.5, 0.0, 0.5], [1.0, 0.2, 0.05])];
932        let z = g.sosfiltfilt(x, &sos);
933        assert_eq!(dims(&g, z), vec![3, 128]);
934    }
935
936    #[test]
937    fn resample_poly_length() {
938        let mut g = Graph::new("rs");
939        let x = input(&mut g, &[2, 100]);
940        let taps: Vec<f32> = (0..13).map(|i| 1.0 / (i as f32 + 1.0)).collect();
941        // up=3, down=2 → ceil(100*3/2) = 150.
942        let y = g.resample_poly(x, 3, 2, &taps);
943        assert_eq!(dims(&g, y), vec![2, 150]);
944    }
945
946    #[test]
947    fn fir_conv1d_modes_lengths() {
948        let taps: Vec<f32> = (0..9).map(|i| 1.0 / (i as f32 + 1.0)).collect();
949        for &(mode, want) in &[
950            (FirMode::Full, 64 + 9 - 1),
951            (FirMode::Same, 64),
952            (FirMode::Valid, 64 - 9 + 1),
953            (FirMode::Causal, 64),
954        ] {
955            let mut g = Graph::new("fir");
956            let x = input(&mut g, &[2, 64]);
957            let y = g.fir_conv1d(x, &taps, mode);
958            assert_eq!(dims(&g, y), vec![2, want], "mode {mode:?}");
959        }
960    }
961
962    #[test]
963    fn fir_conv1d_long_uses_fft_path() {
964        // > FIR_DIRECT_MAX_TAPS taps exercises the FFT branch.
965        let taps: Vec<f32> = (0..100).map(|i| ((i as f32) * 0.01).cos()).collect();
966        let mut g = Graph::new("firfft");
967        let x = input(&mut g, &[130]);
968        let y = g.fir_conv1d(x, &taps, FirMode::Full);
969        assert_eq!(dims(&g, y), vec![130 + 100 - 1]);
970    }
971
972    #[test]
973    fn conv_reverb_full_length() {
974        let mut g = Graph::new("rir");
975        let x = input(&mut g, &[2, 100]);
976        let ir: Vec<f32> = (0..300).map(|i| (-(i as f32) * 0.02).exp()).collect();
977        let y = g.conv_reverb(x, &ir, 64);
978        assert_eq!(dims(&g, y), vec![2, 100 + 300 - 1]);
979    }
980
981    #[test]
982    fn partitioned_conv1d_node_ir() {
983        let mut g = Graph::new("pconv");
984        let x = input(&mut g, &[50]);
985        let ir = g.const_f32_tensor((0..70).map(|i| i as f32 * 0.1).collect(), &[70]);
986        let y = g.partitioned_conv1d(x, ir, 32);
987        assert_eq!(dims(&g, y), vec![50 + 70 - 1]);
988    }
989
990    #[test]
991    fn partitioned_conv1d_gemm_shape() {
992        let mut g = Graph::new("pgemm");
993        let x = input(&mut g, &[2, 50]);
994        let ir = g.const_f32_tensor((0..70).map(|i| i as f32 * 0.1).collect(), &[70]);
995        let y = g.partitioned_conv1d_gemm(x, ir, 32);
996        assert_eq!(dims(&g, y), vec![2, 50 + 70 - 1]);
997    }
998
999    #[test]
1000    fn partitioned_conv_op_shape() {
1001        let mut g = Graph::new("pop");
1002        let x = input(&mut g, &[3, 40]);
1003        let ir = g.const_f32_tensor((0..100).map(|i| i as f32 * 0.01).collect(), &[100]);
1004        let y = g.partitioned_conv(x, ir, 32);
1005        // Op::PartitionedConv carries the L+M-1 output shape directly.
1006        assert_eq!(dims(&g, y), vec![3, 40 + 100 - 1]);
1007    }
1008
1009    #[test]
1010    fn iirfilt_high_order_same_length() {
1011        let mut g = Graph::new("iirN");
1012        let x = input(&mut g, &[3, 128]);
1013        // Order-3 IIR (4 b/a coeffs).
1014        let b = [0.1, 0.2, 0.2, 0.1];
1015        let a = [1.0, -0.5, 0.3, -0.1];
1016        let y = g.iirfilt(x, &b, &a);
1017        assert_eq!(dims(&g, y), vec![3, 128]);
1018    }
1019
1020    #[test]
1021    fn iir_as_fir_causal_same_length() {
1022        let mut g = Graph::new("iirfir");
1023        let x = input(&mut g, &[2, 100]);
1024        let b = [0.2, 0.4, 0.2];
1025        let a = [1.0, -0.3, 0.1];
1026        let y = g.iir_as_fir(x, &b, &a, 48, FirMode::Causal);
1027        assert_eq!(dims(&g, y), vec![2, 100]);
1028    }
1029}