Skip to main content

rlx_ir/ops/
dsp.rs

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