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_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_db: 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: &'static str,
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: &'static str,
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_db: 0.0,
54        });
55        let shape = Shape::from_name(self.shape).expect("shape came from Shape::name");
56        let cutoff = clamp_cutoff(mid.cutoff, sample_rate).0;
57        let coefficients = design(shape, cutoff, clamp_q(mid.q).0, mid.gain_db, sample_rate);
58        Automation {
59            node: self.node.clone(),
60            site: self.site,
61            channel: self.channel,
62            shape: self.shape,
63            clamped: self.clamped,
64            response_db: response(&coefficients, cutoff, sample_rate),
65            coefficients,
66            frames,
67        }
68    }
69}
70
71/// Component `c` of a parameter `width` slots wide: a one-wide parameter answers every lane.
72#[inline]
73fn part<T: Copy>(v: &[T], c: usize) -> T {
74    v[c.min(v.len() - 1)]
75}
76
77#[inline]
78#[allow(clippy::too_many_arguments)]
79fn step(
80    lane: &mut Lane,
81    shape: Shape,
82    stride: usize,
83    clamped: &mut bool,
84    x: f64,
85    cutoff: f64,
86    q: f64,
87    gain_db: f64,
88    sr: f64,
89    i: usize,
90) -> f64 {
91    if (cutoff, q, gain_db) != lane.designed_for {
92        let (cc, c_clamped) = clamp_cutoff(cutoff, sr);
93        let (qq, q_clamped) = clamp_q(q);
94        lane.coeffs = design(shape, cc, qq, gain_db, sr);
95        lane.designed_for = (cutoff, q, gain_db);
96        *clamped |= c_clamped || q_clamped;
97    }
98    if i.is_multiple_of(stride) {
99        lane.frames.push(AutomationFrame {
100            t_secs: i as f64 / sr,
101            cutoff,
102            q,
103            gain_db,
104        });
105    }
106
107    lane.state.step(&lane.coeffs, x)
108}
109
110/// Distance decides, never array position: a window off the 1 ms trace grid — or narrower
111/// than it — still resolves the equation actually in force at its own midpoint.
112fn nearest(frames: &[AutomationFrame], t_secs: f64) -> Option<AutomationFrame> {
113    frames
114        .iter()
115        .min_by(|a, b| {
116            (a.t_secs - t_secs)
117                .abs()
118                .total_cmp(&(b.t_secs - t_secs).abs())
119        })
120        .copied()
121}
122
123struct Lane {
124    state: crate::biquad::State,
125    coeffs: Coeffs,
126    designed_for: (f64, f64, f64),
127    frames: Vec<AutomationFrame>,
128}
129
130impl Lane {
131    fn new(shape: Shape, cutoff: f64, q: f64, gain_db: f64, sr: f64) -> (Lane, bool) {
132        let (c, c_clamped) = clamp_cutoff(cutoff, sr);
133        let (qq, q_clamped) = clamp_q(q);
134        let lane = Lane {
135            state: crate::biquad::State::default(),
136            coeffs: design(shape, c, qq, gain_db, sr),
137            designed_for: (cutoff, q, gain_db),
138            frames: Vec::new(),
139        };
140        (lane, c_clamped || q_clamped)
141    }
142}
143
144/// A call site, not a channel: one site carries as many lanes as its widest argument has
145/// components, so widening a signal never multiplies the call sites an author writes.
146pub struct FilterSite {
147    shape: Shape,
148    lanes: Vec<Lane>,
149    clamped: bool,
150    stride: usize,
151}
152
153impl FilterSite {
154    pub fn new(
155        shape: Shape,
156        width: usize,
157        cutoff: &[f64],
158        q: &[f64],
159        gain_db: &[f64],
160        sr: f64,
161    ) -> FilterSite {
162        let mut lanes = Vec::with_capacity(width);
163        let mut clamped = false;
164        for c in 0..width.max(1) {
165            let (lane, hit) = Lane::new(shape, part(cutoff, c), part(q, c), part(gain_db, c), sr);
166            lanes.push(lane);
167            clamped |= hit;
168        }
169        FilterSite {
170            shape,
171            lanes,
172            clamped,
173            stride: ((sr / TRACE_HZ).round() as usize).max(1),
174        }
175    }
176
177    /// Coefficients are redesigned only when a parameter actually moved, so a static filter
178    /// costs five multiplies a sample and a modulated one pays the trig it asked for.
179    #[allow(clippy::too_many_arguments)]
180    pub fn process(
181        &mut self,
182        x: &[f64],
183        cutoff: &[f64],
184        q: &[f64],
185        gain_db: &[f64],
186        out: &mut [f64],
187        sr: f64,
188        i: usize,
189    ) {
190        let (shape, stride) = (self.shape, self.stride);
191        let mut clamped = self.clamped;
192        for (c, lane) in self.lanes.iter_mut().enumerate() {
193            out[c] = step(
194                lane,
195                shape,
196                stride,
197                &mut clamped,
198                part(x, c),
199                part(cutoff, c),
200                part(q, c),
201                part(gain_db, c),
202                sr,
203                i,
204            );
205        }
206        self.clamped = clamped;
207    }
208
209    pub fn trace(&self, node: &str, site: usize, sr: f64) -> Vec<FilterTrace> {
210        let wide = self.lanes.len() > 1;
211        self.lanes
212            .iter()
213            .enumerate()
214            .map(|(c, lane)| FilterTrace {
215                node: node.to_string(),
216                site,
217                channel: wide.then_some(c),
218                shape: self.shape.name(),
219                clamped: self.clamped,
220                trace_secs: self.stride as f64 / sr,
221                frames: lane.frames.clone(),
222            })
223            .collect()
224    }
225}