Skip to main content

secs_engine/
preview.rs

1//! BA display curves replayed from an immutable design, without fitting.
2
3use crate::{
4    C, Design, LayoutInfo,
5    chain::{BaRow, term},
6    grid::design_freqs,
7};
8use secs_dsp::{
9    interp, pairwise_sum,
10    smooth::{Fraction, PowerSmoother},
11};
12use secs_types::chain::{Allpass, Peq};
13
14/// Reference-position XO display curves, all in dB except the Hz grid.
15#[derive(Debug, Clone, PartialEq)]
16pub struct XoCurves {
17    /// Logarithmic 5–500 Hz grid, 620 points.
18    pub grid: Vec<f64>,
19    /// Mean main power, with electrical HP.
20    pub main: Vec<f64>,
21    /// Mean sub power, with electrical LP and each channel's input correction.
22    pub sub: Vec<f64>,
23    /// Mean power of each channel's coherent main/sub sum.
24    pub sum: Vec<f64>,
25    /// Target including the design's reference level.
26    pub target: Vec<f64>,
27}
28
29#[derive(Debug)]
30struct CachedMain {
31    role: String,
32    raw: Vec<C>,
33    corrected: Vec<C>,
34    correction: std::sync::Arc<[C]>,
35}
36
37/// Immutable per-design responses; building once keeps slider calls below R7's
38/// 0.2 s budget. Rebuild this cache whenever the design changes.
39#[derive(Debug)]
40pub struct XoPreview {
41    sub: Vec<C>,
42    mains: Vec<CachedMain>,
43    grid: Vec<f64>,
44    target: Vec<f64>,
45    smoother: PowerSmoother,
46}
47
48impl XoPreview {
49    /// Precomputes BA/bus and corrected main responses at the reference seat.
50    pub fn new(design: &Design) -> Self {
51        let f = design_freqs();
52        let pos = design.spectra.main_position;
53        let mut sub = vec![C::new(0.0, 0.0); f.len()];
54        if let Some(s) = &design.subs {
55            let terms: Vec<_> = s
56                .rows
57                .iter()
58                .map(|(r, row)| {
59                    (
60                        design
61                            .spectra
62                            .used
63                            .get(pos, r)
64                            .expect("reference sub")
65                            .as_ref(),
66                        row,
67                    )
68                })
69                .collect();
70            sub = crate::chain::sub_sum(&terms, f);
71            if let Some(bus) = &s.bus_response {
72                for (h, b) in sub.iter_mut().zip(bus.iter()) {
73                    *h *= b;
74                }
75            }
76        }
77        let mains = design
78            .mains
79            .iter()
80            .filter_map(|m| {
81                let h = design.spectra.raw.get(pos, &m.role)?;
82                let gain = C::new(10f64.powf(m.gain_db / 20.0), 0.0);
83                let raw: Vec<_> = h
84                    .iter()
85                    .zip(crate::measurement::delay_response(m.align_delay_ms))
86                    .map(|(h, d)| h * d * gain)
87                    .collect();
88                let corrected = raw
89                    .iter()
90                    .enumerate()
91                    .map(|(i, h)| {
92                        h * m.top_response.as_ref().map_or(C::new(1.0, 0.0), |p| p[i])
93                            * m.response[i]
94                    })
95                    .collect();
96                Some(CachedMain {
97                    role: m.role.clone(),
98                    raw,
99                    corrected,
100                    correction: m.response.clone(),
101                })
102            })
103            .collect();
104        let grid = geomspace(5.0, 500.0, 620);
105        let target = design
106            .target
107            .sample(&grid)
108            .iter()
109            .map(|t| t + design.ref_level)
110            .collect();
111        Self {
112            sub,
113            mains,
114            grid,
115            target,
116            smoother: PowerSmoother::new(f, Fraction::Scalar(12.0)).expect("design grid"),
117        }
118    }
119
120    /// Applies only a new electrical HP/LP, then power averaging and smoothing.
121    /// `design` must be the design passed to `new`. An absent/unknown role uses
122    /// the whole group; an unknown group has zero mains and the raw sub curve.
123    pub fn curves(
124        &self,
125        design: &Design,
126        group: &str,
127        fc: f64,
128        corrected: bool,
129        role: Option<&str>,
130    ) -> XoCurves {
131        let group_roles = design
132            .groups
133            .iter()
134            .find(|(g, _)| g == group)
135            .map(|(_, roles)| roles);
136        let mut mains: Vec<_> = self
137            .mains
138            .iter()
139            .filter(|m| group_roles.is_some_and(|rs| rs.contains(&m.role)))
140            .collect();
141        // Preserve the group's accumulation order, rather than chain order.
142        if let Some(roles) = group_roles {
143            mains.sort_by_key(|m| roles.iter().position(|r| r == &m.role).unwrap());
144        }
145        if let Some(role) = role
146            && mains.iter().any(|m| m.role == role)
147        {
148            mains.retain(|m| m.role == role);
149        }
150        let f = design_freqs();
151        let hp = secs_dsp::response::lr4(f, fc, "HP");
152        let lp = secs_dsp::response::lr4(f, fc, "LP");
153        let mut main_power = vec![0.0; f.len()];
154        let mut sub_power = vec![0.0; f.len()];
155        let mut sum_power = vec![0.0; f.len()];
156        let power = |h: C| {
157            let a = h.re.hypot(h.im);
158            a * a
159        };
160        for i in 0..f.len() {
161            let sub = self.sub[i] * lp[i];
162            if mains.is_empty() {
163                sub_power[i] = power(sub);
164                sum_power[i] = power(sub);
165            }
166            for m in &mains {
167                let main = if corrected { m.corrected[i] } else { m.raw[i] } * hp[i];
168                let s = if corrected {
169                    sub * m.correction[i]
170                } else {
171                    sub
172                };
173                main_power[i] += power(main);
174                sub_power[i] += power(s);
175                sum_power[i] += power(main + s);
176            }
177            if !mains.is_empty() {
178                main_power[i] /= mains.len() as f64;
179                sub_power[i] /= mains.len() as f64;
180                sum_power[i] /= mains.len() as f64;
181            }
182        }
183        XoCurves {
184            grid: self.grid.clone(),
185            main: db(&main_power, &self.grid, &self.smoother),
186            sub: db(&sub_power, &self.grid, &self.smoother),
187            sum: db(&sum_power, &self.grid, &self.smoother),
188            target: self.target.clone(),
189        }
190    }
191}
192
193/// The sub's exported alignment, on the output time axis.
194#[derive(Debug, Clone, PartialEq)]
195pub struct SubValue {
196    /// Speaker role.
197    pub role: String,
198    /// Output gain in dB.
199    pub gain_db: f64,
200    /// Output delay in milliseconds, including the common shift.
201    pub delay_ms: f64,
202    /// Output polarity, 1 or -1.
203    pub polarity: i8,
204    /// Ordered all-pass filters.
205    pub allpass: Vec<Allpass>,
206    /// BA mode label.
207    pub mode: String,
208    /// Fixed and generated bundle peaking filters.
209    pub peq: Vec<Peq>,
210}
211
212/// Sub-combination preview; all curves are reference-position dB values.
213#[derive(Debug, Clone)]
214pub struct SubCombine {
215    /// Sub roles in configuration order.
216    pub subs: Vec<String>,
217    /// Logarithmic 5–300 Hz display grid, 540 points.
218    pub grid: Vec<f64>,
219    /// Raw individual sub curves, smoothed to 1/12 octave.
220    pub before: Vec<(String, Vec<f64>)>,
221    /// Aligned individual sub curves, including bundle and bus PEQ.
222    pub after: Vec<(String, Vec<f64>)>,
223    /// Coherent sub sum before alignment, in dB.
224    pub sum_before: Vec<f64>,
225    /// Coherent sub sum after alignment, in dB.
226    pub sum_after: Vec<f64>,
227    /// Metric band, 20 Hz to 1.5 times the maximum main XO (150 Hz without XO).
228    pub band: (f64, f64),
229    /// Before/after standard deviations of 1/6-octave sum levels in the band.
230    pub flat: (f64, f64),
231    /// Before/after mean across-seat deviations; absent for a single seat.
232    pub seat: Option<(f64, f64)>,
233    /// Number of positions with every sub measured.
234    pub positions: usize,
235    /// Exported alignment values, in sub order.
236    pub values: Vec<SubValue>,
237    /// Typed layout data; presentation text belongs to the export layer.
238    pub layout_info: Vec<(String, LayoutInfo)>,
239}
240
241fn geomspace(lo: f64, hi: f64, n: usize) -> Vec<f64> {
242    let start = lo.log10();
243    let step = (hi.log10() - start) / (n - 1) as f64;
244    let mut out: Vec<_> = (0..n)
245        .map(|i| 10f64.powf(start + i as f64 * step))
246        .collect();
247    out[0] = lo;
248    out[n - 1] = hi;
249    out
250}
251fn mean(v: &[f64]) -> f64 {
252    pairwise_sum(v) / v.len() as f64
253}
254fn std(v: &[f64]) -> f64 {
255    let m = mean(v);
256    mean(&v.iter().map(|x| (x - m) * (x - m)).collect::<Vec<_>>()).sqrt()
257}
258fn db(power: &[f64], grid: &[f64], smoother: &PowerSmoother) -> Vec<f64> {
259    let f = design_freqs();
260    let values: Vec<_> = smoother
261        .apply(power)
262        .expect("design grid")
263        .iter()
264        .map(|p| 10.0 * (p + 1e-30).log10())
265        .collect();
266    grid.iter().map(|&g| interp(g, f, &values)).collect()
267}
268fn curve(h: &[C], grid: &[f64], smoother: &PowerSmoother) -> Vec<f64> {
269    let power = &h
270        .iter()
271        .map(|h| {
272            let a = h.re.hypot(h.im);
273            a * a
274        })
275        .collect::<Vec<_>>();
276    db(power, grid, smoother)
277}
278
279/// Replays before/after sub responses, returning `None` for fewer than two subs.
280/// Fixed filters remain in the raw spectra, matching upstream's `H0`; generated
281/// bundle filters and bus PEQ appear only after alignment. Does not rerun BA.
282pub fn sub_combine(design: &Design) -> Option<SubCombine> {
283    let sub = design.subs.as_ref()?;
284    if sub.rows.len() < 2 {
285        return None;
286    }
287    let subs: Vec<_> = sub.rows.iter().map(|(r, _)| r.clone()).collect();
288    let positions: Vec<_> = design
289        .spectra
290        .positions
291        .iter()
292        .copied()
293        .filter(|&p| subs.iter().all(|r| design.spectra.raw.get(p, r).is_some()))
294        .collect();
295    let grid = geomspace(5.0, 300.0, 540);
296    let max_xo = design
297        .xo
298        .iter()
299        .filter(|(group, hz)| group != "sub" && *hz != 0.0)
300        .map(|(_, hz)| *hz)
301        .reduce(f64::max)
302        .unwrap_or(100.0);
303    let band = (20.0, max_xo * 1.5);
304    let metric_grid = geomspace(band.0, band.1, 200);
305    let raw = BaRow {
306        gain_db: 0.0,
307        delay_ms: 0.0,
308        polarity: 1,
309        allpass: vec![],
310    };
311    let display_smoother =
312        PowerSmoother::new(design_freqs(), Fraction::Scalar(12.0)).expect("design grid");
313    let metric_smoother =
314        PowerSmoother::new(design_freqs(), Fraction::Scalar(6.0)).expect("design grid");
315    let mut each = [Vec::new(), Vec::new()];
316    let mut sums = [Vec::new(), Vec::new()];
317    let mut flat = [0.0; 2];
318    let mut seat = [0.0; 2];
319    for state in 0..2 {
320        let mut levels = Vec::new();
321        for &p in &positions {
322            let mut sum = vec![C::new(0.0, 0.0); design_freqs().len()];
323            for (role, row) in &sub.rows {
324                let spectra = if state == 0 {
325                    &design.spectra.raw
326                } else {
327                    &design.spectra.used
328                };
329                let h = spectra.get(p, role).expect("complete sub position");
330                let mut out = term(h, design_freqs(), if state == 0 { &raw } else { row });
331                if state == 1
332                    && let Some(bus) = &sub.bus_response
333                {
334                    for (h, b) in out.iter_mut().zip(bus.iter()) {
335                        *h *= b;
336                    }
337                }
338                for (s, h) in sum.iter_mut().zip(&out) {
339                    *s += h;
340                }
341                if p == design.spectra.main_position {
342                    each[state].push((role.clone(), curve(&out, &grid, &display_smoother)));
343                }
344            }
345            let level = curve(&sum, &metric_grid, &metric_smoother);
346            if p == design.spectra.main_position {
347                flat[state] = std(&level);
348                sums[state] = curve(&sum, &grid, &display_smoother);
349            }
350            levels.push(level);
351        }
352        if positions.len() > 1 {
353            // NumPy reduces axis 0 sequentially for this C-contiguous matrix.
354            let deviations: Vec<_> = (0..metric_grid.len())
355                .map(|i| {
356                    let m = levels.iter().fold(0.0, |s, v| s + v[i]) / levels.len() as f64;
357                    (levels.iter().fold(0.0, |s, v| s + (v[i] - m).powi(2)) / levels.len() as f64)
358                        .sqrt()
359                })
360                .collect();
361            seat[state] = mean(&deviations);
362        }
363    }
364    let values = design
365        .chain
366        .subs
367        .iter()
368        .map(|s| SubValue {
369            role: s.role.clone(),
370            gain_db: s.gain_db,
371            delay_ms: s.delay_ms,
372            polarity: s.polarity,
373            allpass: s.allpass.clone(),
374            mode: s.mode.clone(),
375            peq: s.peq.clone(),
376        })
377        .collect();
378    let [before, after] = each;
379    let [sum_before, sum_after] = sums;
380    Some(SubCombine {
381        subs,
382        grid,
383        before,
384        after,
385        sum_before,
386        sum_after,
387        band,
388        flat: (flat[0], flat[1]),
389        seat: (positions.len() > 1).then_some((seat[0], seat[1])),
390        positions: positions.len(),
391        values,
392        layout_info: sub.layout_info.clone(),
393    })
394}