Skip to main content

sva_samples/
filters.rs

1// Concern: runs one filter call site and traces its parameters in time | Non-concern: coefficient math (biquad.rs), argument evaluation | IO: (x, cutoff, q, gain, i) -> a lane each, traces
2
3use sva_formula::filter::Shape;
4
5use crate::biquad::{Coeffs, clamp_cutoff, clamp_gain, clamp_q, design, response};
6
7pub const TRACE_HZ: f64 = 1000.0;
8
9#[derive(Clone, Copy, Debug, PartialEq)]
10pub struct AutomationFrame {
11    pub t_secs: f64,
12    pub cutoff: f64,
13    pub q: f64,
14    pub gain: f64,
15}
16
17#[derive(Clone, Debug, PartialEq)]
18pub struct FilterTrace {
19    pub node: String,
20    pub site: usize,
21    pub channel: Option<usize>,
22    pub shape: Shape,
23    pub clamped: bool,
24    pub trace_secs: f64,
25    pub frames: Vec<AutomationFrame>,
26}
27
28#[derive(Clone, Debug, PartialEq)]
29pub struct Automation {
30    pub node: String,
31    pub site: usize,
32    pub channel: Option<usize>,
33    pub shape: Shape,
34    pub clamped: bool,
35    pub frames: Vec<AutomationFrame>,
36    pub coefficients: Coeffs,
37    pub response_db: Vec<(f64, f64)>,
38}
39
40impl FilterTrace {
41    pub fn over(&self, start_secs: f64, end_secs: f64, sample_rate: f64) -> Automation {
42        let frames: Vec<AutomationFrame> = self
43            .frames
44            .iter()
45            .copied()
46            .filter(|f| f.t_secs >= start_secs && f.t_secs < end_secs)
47            .collect();
48        let midpoint = (start_secs + end_secs) / 2.0;
49        let mid = nearest(&self.frames, midpoint).unwrap_or(AutomationFrame {
50            t_secs: midpoint,
51            cutoff: 0.0,
52            q: 1.0,
53            gain: 1.0,
54        });
55        let shape = self.shape;
56        let cutoff = clamp_cutoff(mid.cutoff, sample_rate).0;
57        let gain = clamp_gain(mid.gain).0;
58        let q = clamp_q(shape, cutoff, mid.q, gain, sample_rate).0;
59        let coefficients = design(shape, cutoff, q, gain, sample_rate);
60        Automation {
61            node: self.node.clone(),
62            site: self.site,
63            channel: self.channel,
64            shape: self.shape,
65            clamped: self.clamped,
66            response_db: response(&coefficients, cutoff, sample_rate),
67            coefficients,
68            frames,
69        }
70    }
71}
72
73/// Component `c` of a parameter `width` slots wide: a one-wide parameter answers every lane.
74#[inline]
75fn part<T: Copy>(v: &[T], c: usize) -> T {
76    v[c.min(v.len() - 1)]
77}
78
79#[inline]
80#[allow(clippy::too_many_arguments)]
81fn step(
82    lane: &mut Lane,
83    shape: Shape,
84    stride: usize,
85    clamped: &mut bool,
86    x: f64,
87    cutoff: f64,
88    q: f64,
89    gain: f64,
90    sr: f64,
91    i: i64,
92) -> f64 {
93    if (cutoff, q, gain) != lane.designed_for {
94        let (coeffs, hit) = coefficients(shape, cutoff, q, gain, sr);
95        lane.coeffs = coeffs;
96        lane.designed_for = (cutoff, q, gain);
97        *clamped |= hit;
98    }
99    if i.rem_euclid(stride as i64) == 0 {
100        lane.frames.push(AutomationFrame {
101            t_secs: i as f64 / sr,
102            cutoff,
103            q,
104            gain,
105        });
106    }
107
108    lane.state.step(&lane.coeffs, x)
109}
110
111/// Distance decides, never array position: a window off the 1 ms trace grid — or narrower
112/// than it — still resolves the equation actually in force at its own midpoint.
113fn nearest(frames: &[AutomationFrame], t_secs: f64) -> Option<AutomationFrame> {
114    frames
115        .iter()
116        .min_by(|a, b| {
117            (a.t_secs - t_secs)
118                .abs()
119                .total_cmp(&(b.t_secs - t_secs).abs())
120        })
121        .copied()
122}
123
124#[derive(Clone)]
125struct Lane {
126    state: crate::biquad::State,
127    coeffs: Coeffs,
128    designed_for: (f64, f64, f64),
129    frames: Vec<AutomationFrame>,
130}
131
132pub fn coefficients(shape: Shape, cutoff: f64, q: f64, gain: f64, sr: f64) -> (Coeffs, bool) {
133    let (c, c_clamped) = clamp_cutoff(cutoff, sr);
134    let (g, g_clamped) = match shape.takes_gain() {
135        true => clamp_gain(gain),
136        false => (1.0, false),
137    };
138    let (qq, q_clamped) = clamp_q(shape, c, q, g, sr);
139    (
140        design(shape, c, qq, g, sr),
141        c_clamped || q_clamped || g_clamped,
142    )
143}
144
145impl Lane {
146    fn new(shape: Shape, cutoff: f64, q: f64, gain: f64, sr: f64) -> (Lane, bool) {
147        let (coeffs, clamped) = coefficients(shape, cutoff, q, gain, sr);
148        let lane = Lane {
149            state: crate::biquad::State::default(),
150            coeffs,
151            designed_for: (cutoff, q, gain),
152            frames: Vec::new(),
153        };
154        (lane, clamped)
155    }
156}
157
158/// A call site, not a channel: one site carries as many lanes as its widest argument has
159/// components, so widening a signal never multiplies the call sites an author writes.
160#[derive(Clone)]
161pub struct FilterSite {
162    shape: Shape,
163    lanes: Vec<Lane>,
164    clamped: bool,
165    stride: usize,
166}
167
168impl FilterSite {
169    pub fn new(
170        shape: Shape,
171        width: usize,
172        cutoff: &[f64],
173        q: &[f64],
174        gain: &[f64],
175        sr: f64,
176    ) -> FilterSite {
177        let mut lanes = Vec::with_capacity(width);
178        let mut clamped = false;
179        for c in 0..width.max(1) {
180            let (lane, hit) = Lane::new(shape, part(cutoff, c), part(q, c), part(gain, c), sr);
181            lanes.push(lane);
182            clamped |= hit;
183        }
184        FilterSite {
185            shape,
186            lanes,
187            clamped,
188            stride: ((sr / TRACE_HZ).round() as usize).max(1),
189        }
190    }
191
192    /// Coefficients are redesigned only when a parameter actually moved, so a static filter
193    /// costs five multiplies a sample and a modulated one pays the trig it asked for.
194    #[allow(clippy::too_many_arguments)]
195    pub fn process(
196        &mut self,
197        x: &[f64],
198        cutoff: &[f64],
199        q: &[f64],
200        gain: &[f64],
201        out: &mut [f64],
202        sr: f64,
203        i: i64,
204    ) {
205        let (shape, stride) = (self.shape, self.stride);
206        let mut clamped = self.clamped;
207        for (c, lane) in self.lanes.iter_mut().enumerate() {
208            out[c] = step(
209                lane,
210                shape,
211                stride,
212                &mut clamped,
213                part(x, c),
214                part(cutoff, c),
215                part(q, c),
216                part(gain, c),
217                sr,
218                i,
219            );
220        }
221        self.clamped = clamped;
222    }
223
224    /// Each lane's coefficients and its past, `(x1, x2, y1, y2)`.
225    pub fn lanes(&self) -> impl Iterator<Item = (&Coeffs, [f64; 4])> {
226        self.lanes
227            .iter()
228            .map(|lane| (&lane.coeffs, lane.state.held()))
229    }
230
231    /// A stream reads no trace, so each block drops the last one's and keeps the room.
232    /// What one copy of this site holds.
233    pub fn bytes(&self) -> usize {
234        let frames: usize = self
235            .lanes
236            .iter()
237            .map(|l| std::mem::size_of_val(l.frames.as_slice()))
238            .sum();
239        size_of::<Self>() + std::mem::size_of_val(self.lanes.as_slice()) + frames
240    }
241
242    pub fn forget_frames(&mut self) {
243        for lane in &mut self.lanes {
244            lane.frames.clear();
245        }
246    }
247
248    pub fn trace(&self, node: &str, site: usize, sr: f64) -> Vec<FilterTrace> {
249        let wide = self.lanes.len() > 1;
250        self.lanes
251            .iter()
252            .enumerate()
253            .map(|(c, lane)| FilterTrace {
254                node: node.to_string(),
255                site,
256                channel: wide.then_some(c),
257                shape: self.shape,
258                clamped: self.clamped,
259                trace_secs: self.stride as f64 / sr,
260                frames: lane.frames.clone(),
261            })
262            .collect()
263    }
264}