Skip to main content

secs_graph/
plan.rs

1//! The upstream `make_plan` topology and its as-written transfer functions.
2//!
3//! Upstream source: `export_filters.py` (`make_plan`, `plan_responses`,
4//! `headroom_db`), moved here from `secs-filters` so every writer reads one
5//! plan (#92 C11).
6
7use crate::{GraphError, LfeChoice, SAMPLE_RATE};
8use secs_dsp::Complex64;
9use secs_dsp::fft;
10use secs_dsp::response::{allpass2, band_filter, peaking_response};
11use secs_types::chain::{BandType, MimoFilter, MimoPaths, SignalChain};
12use std::borrow::Cow;
13use std::f64::consts::PI;
14
15/// One filter-plan operation. `fixed` marks a fixed subwoofer filter.
16#[derive(Debug, Clone, PartialEq)]
17pub enum Op {
18    /// RBJ peaking EQ.
19    Peq {
20        /// Center frequency in hertz.
21        frequency: f64,
22        /// Quality factor.
23        q: f64,
24        /// Gain in dB.
25        gain_db: f64,
26        /// Whether the band is a fixed subwoofer filter.
27        fixed: bool,
28    },
29    /// 48 kHz FIR and its plan-local stem.
30    Fir {
31        /// FIR stem before the writer-specific prefix.
32        name: String,
33        /// IEEE-float samples before WAV serialization.
34        taps: Vec<f64>,
35        /// Fixed-filter marker (normally false for FIRs).
36        fixed: bool,
37    },
38    /// Compact design-rate FIR, expanded only for a consumer that needs 48 kHz taps.
39    FirLowRate {
40        /// FIR stem before the writer-specific prefix.
41        name: String,
42        /// Design-rate coefficients including the isolation low-pass.
43        taps_low: Vec<f64>,
44        /// Design sample rate in hertz.
45        fs_low: f64,
46        /// Fixed-filter marker.
47        fixed: bool,
48    },
49    /// Gain in dB and its fixed marker.
50    Gain {
51        /// Gain in dB.
52        db: f64,
53        /// Fixed-filter marker.
54        fixed: bool,
55    },
56    /// Polarity inversion and its fixed marker.
57    Invert {
58        /// Fixed-filter marker.
59        fixed: bool,
60    },
61    /// Analog-prototype high-pass.
62    Hp {
63        /// Cutoff frequency in hertz.
64        frequency: f64,
65        /// Filter family and order.
66        kind: BandType,
67        /// Fixed-filter marker.
68        fixed: bool,
69    },
70    /// Analog-prototype low-pass.
71    Lp {
72        /// Cutoff frequency in hertz.
73        frequency: f64,
74        /// Filter family and order.
75        kind: BandType,
76        /// Fixed-filter marker.
77        fixed: bool,
78    },
79    /// Second-order analog all-pass.
80    Ap {
81        /// Center frequency in hertz.
82        frequency: f64,
83        /// Quality factor.
84        q: f64,
85        /// Fixed-filter marker.
86        fixed: bool,
87    },
88    /// Delay in milliseconds.
89    Delay {
90        /// Delay in milliseconds.
91        ms: f64,
92        /// Fixed-filter marker.
93        fixed: bool,
94    },
95}
96
97impl Op {
98    /// Whether the operation is a fixed subwoofer filter.
99    pub fn is_fixed(&self) -> bool {
100        match self {
101            Self::Peq { fixed, .. }
102            | Self::Fir { fixed, .. }
103            | Self::FirLowRate { fixed, .. }
104            | Self::Gain { fixed, .. }
105            | Self::Invert { fixed }
106            | Self::Hp { fixed, .. }
107            | Self::Lp { fixed, .. }
108            | Self::Ap { fixed, .. }
109            | Self::Delay { fixed, .. } => *fixed,
110        }
111    }
112}
113
114/// One subwoofer bus output, preserving upstream insertion order.
115#[derive(Debug, Clone, PartialEq)]
116pub struct SubFeed {
117    /// Main sends and optional LFE input summed into this bus.
118    pub sources: Vec<String>,
119    /// Per-sub bus gain.
120    pub gain_db: f64,
121    /// `1` or `-1`.
122    pub polarity: i8,
123    /// Bus, fixed, all-pass, and delay operations.
124    pub ops: Vec<Op>,
125}
126
127/// One MIMO support feed.
128#[derive(Debug, Clone, PartialEq)]
129pub struct MimoFeed {
130    /// Raw input role.
131    pub input: String,
132    /// Support output role.
133    pub support: String,
134    /// Path operations.
135    pub ops: Vec<Op>,
136}
137
138/// The complete export plan, with every map represented as an ordered vector.
139#[derive(Debug, Clone, PartialEq)]
140pub struct FilterPlan {
141    /// Main roles followed by optional LFE.
142    pub inputs: Vec<String>,
143    /// Main roles followed by sub roles.
144    pub outputs: Vec<String>,
145    /// Input-correction operations by role.
146    pub input_ops: Vec<(String, Vec<Op>)>,
147    /// Main-path operations by role.
148    pub main_ops: Vec<(String, Vec<Op>)>,
149    /// Crossover sends by role.
150    pub send_ops: Vec<(String, Vec<Op>)>,
151    /// LFE input operations.
152    pub lfe_ops: Vec<Op>,
153    /// Sub feeds by role.
154    pub subs: Vec<(String, SubFeed)>,
155    /// MIMO paths.
156    pub mimo: Vec<MimoFeed>,
157}
158
159/// The low-rate FIR rule, written once: a design rate of at most 48 kHz
160/// whose 48 kHz expansion has a length `secs_dsp` accepts.
161pub(crate) fn lowrate_fits(taps: usize, fs_low: f64) -> bool {
162    fs_low <= f64::from(SAMPLE_RATE) && secs_dsp::fir::lowrate_len_48k(taps, fs_low).is_some()
163}
164
165/// One FIR of a plan, borrowed: collecting export files never clones stored
166/// FIR arrays.
167#[derive(Debug, Clone, Copy)]
168pub enum FirView<'a> {
169    /// 48 kHz taps.
170    Full(&'a [f64]),
171    /// Design-rate taps and their rate in hertz.
172    LowRate(&'a [f64], f64),
173}
174
175impl<'a> FirView<'a> {
176    /// Check a low-rate recipe against the low-rate FIR rule and its taps for
177    /// finite values. A plan from [`ExportGraph`](crate::ExportGraph) always
178    /// passes; a hand-built or revived plan may not.
179    pub fn validate(self) -> Result<(), GraphError> {
180        if let Self::LowRate(taps, fs_low) = self
181            && (!lowrate_fits(taps.len(), fs_low) || taps.iter().any(|tap| !tap.is_finite()))
182        {
183            return Err(GraphError::Invalid(
184                "invalid or oversized low-rate FIR recipe".into(),
185            ));
186        }
187        Ok(())
188    }
189
190    /// Number of taps at 48 kHz.
191    pub fn len_48k(self) -> usize {
192        match self {
193            Self::Full(taps) => taps.len(),
194            Self::LowRate(taps, fs_low) => {
195                (taps.len() as f64 * (f64::from(SAMPLE_RATE) / fs_low)).ceil() as usize
196            }
197        }
198    }
199
200    /// The 48 kHz taps, expanding a low-rate recipe.
201    pub fn materialize(self) -> Cow<'a, [f64]> {
202        match self {
203            Self::Full(taps) => Cow::Borrowed(taps),
204            Self::LowRate(taps, fs_low) => Cow::Owned(secs_dsp::fir::lowrate_to_48k(taps, fs_low)),
205        }
206    }
207}
208
209impl FilterPlan {
210    /// The plan's FIRs by stem, input corrections first, then MIMO paths; a
211    /// repeated stem keeps its first position and its last taps.
212    pub fn firs(&self) -> Vec<(&str, FirView<'_>)> {
213        let mut out: Vec<(&str, FirView<'_>)> = Vec::new();
214        for ops in self
215            .input_ops
216            .iter()
217            .map(|(_, ops)| ops)
218            .chain(self.mimo.iter().map(|m| &m.ops))
219        {
220            for op in ops {
221                let (name, view) = match op {
222                    Op::Fir { name, taps, .. } => (name.as_str(), FirView::Full(taps)),
223                    Op::FirLowRate {
224                        name,
225                        taps_low,
226                        fs_low,
227                        ..
228                    } => (name.as_str(), FirView::LowRate(taps_low, *fs_low)),
229                    _ => continue,
230                };
231                if let Some(found) = out.iter_mut().find(|(key, _)| *key == name) {
232                    found.1 = view;
233                } else {
234                    out.push((name, view));
235                }
236            }
237        }
238        out
239    }
240}
241
242fn find_ops<'a>(items: &'a [(String, Vec<Op>)], role: &str) -> Option<&'a [Op]> {
243    items
244        .iter()
245        .find(|(key, _)| key == role)
246        .map(|(_, value)| value.as_slice())
247}
248
249fn fixed_ops(chain: &SignalChain, role: &str) -> Vec<Op> {
250    let Some(sub) = chain.subs.iter().find(|sub| sub.role == role) else {
251        return Vec::new();
252    };
253    let mut ops = Vec::new();
254    if sub.hp_hz != 0.0 {
255        ops.push(Op::Hp {
256            frequency: sub.hp_hz,
257            kind: sub.hp_type,
258            fixed: true,
259        });
260    }
261    if sub.lp_hz != 0.0 {
262        ops.push(Op::Lp {
263            frequency: sub.lp_hz,
264            kind: sub.lp_type,
265            fixed: true,
266        });
267    }
268    ops.extend(sub.peq.iter().map(|&(frequency, q, gain_db)| Op::Peq {
269        frequency,
270        q,
271        gain_db,
272        fixed: true,
273    }));
274    ops
275}
276
277/// Convert a validated chain into the upstream filter plan.
278pub(crate) fn make(
279    chain: &SignalChain,
280    mimo: Option<&MimoPaths>,
281    lfe_choice: LfeChoice,
282) -> FilterPlan {
283    let lfe_gain_db = match lfe_choice {
284        LfeChoice::Omit => None,
285        LfeChoice::Include { gain_db, lp_hz } => Some((gain_db, lp_hz)),
286    };
287    let lfe = !chain.subs.is_empty() && lfe_gain_db.is_some();
288    let mut plan = FilterPlan {
289        inputs: chain.mains.iter().map(|main| main.role.clone()).collect(),
290        outputs: chain
291            .mains
292            .iter()
293            .map(|main| main.role.clone())
294            .chain(chain.subs.iter().map(|sub| sub.role.clone()))
295            .collect(),
296        input_ops: Vec::new(),
297        main_ops: Vec::new(),
298        send_ops: Vec::new(),
299        lfe_ops: Vec::new(),
300        subs: Vec::new(),
301        mimo: Vec::new(),
302    };
303    if lfe {
304        plan.inputs.push("LFE".into());
305    }
306    for main in &chain.mains {
307        let mut input = main
308            .input_peq
309            .iter()
310            .map(|&(frequency, q, gain_db)| Op::Peq {
311                frequency,
312                q,
313                gain_db,
314                fixed: false,
315            })
316            .collect::<Vec<_>>();
317        if main.fir.len() > 1 {
318            input.push(Op::Fir {
319                name: format!("{}_fir", main.role),
320                taps: main.fir.clone(),
321                fixed: false,
322            });
323        }
324        plan.input_ops.push((main.role.clone(), input));
325        let mut path = vec![Op::Gain {
326            db: main.gain_db,
327            fixed: false,
328        }];
329        path.extend(main.top_peq.iter().map(|&(frequency, q, gain_db)| Op::Peq {
330            frequency,
331            q,
332            gain_db,
333            fixed: false,
334        }));
335        if let Some(frequency) = main.xo_hz.filter(|value| *value != 0.0) {
336            path.push(Op::Hp {
337                frequency,
338                kind: BandType::Lr24,
339                fixed: false,
340            });
341            plan.send_ops.push((
342                main.role.clone(),
343                vec![Op::Lp {
344                    frequency,
345                    kind: BandType::Lr24,
346                    fixed: false,
347                }],
348            ));
349        }
350        path.push(Op::Delay {
351            ms: main.delay_ms,
352            fixed: false,
353        });
354        plan.main_ops.push((main.role.clone(), path));
355    }
356    if lfe {
357        let (gain_db, lp_hz) = lfe_gain_db.expect("LFE is enabled");
358        plan.lfe_ops = vec![
359            Op::Gain {
360                db: gain_db,
361                fixed: false,
362            },
363            Op::Lp {
364                frequency: lp_hz,
365                kind: BandType::Lr24,
366                fixed: false,
367            },
368        ];
369    }
370    let mut sources = plan
371        .send_ops
372        .iter()
373        .map(|(role, _)| role.clone())
374        .collect::<Vec<_>>();
375    if lfe {
376        sources.push("LFE".into());
377    }
378    for sub in &chain.subs {
379        let mut ops = chain
380            .sub_bus_peq
381            .iter()
382            .map(|&(frequency, q, gain_db)| Op::Peq {
383                frequency,
384                q,
385                gain_db,
386                fixed: false,
387            })
388            .collect::<Vec<_>>();
389        ops.extend(fixed_ops(chain, &sub.role));
390        ops.extend(sub.allpass.iter().map(|&(frequency, q)| Op::Ap {
391            frequency,
392            q,
393            fixed: false,
394        }));
395        ops.push(Op::Delay {
396            ms: sub.delay_ms,
397            fixed: false,
398        });
399        plan.subs.push((
400            sub.role.clone(),
401            SubFeed {
402                sources: sources.clone(),
403                gain_db: sub.gain_db,
404                polarity: if sub.polarity > 0 { 1 } else { -1 },
405                ops,
406            },
407        ));
408    }
409    if let Some(paths) = mimo {
410        for path in &paths.paths {
411            let mut ops = match &path.filter {
412                MimoFilter::Fir { taps_48k } => vec![
413                    Op::Delay {
414                        ms: path.delay_ms,
415                        fixed: false,
416                    },
417                    Op::Hp {
418                        frequency: path.low_hz,
419                        kind: BandType::Lr24,
420                        fixed: false,
421                    },
422                    Op::Fir {
423                        name: format!("mimo_{}_{}", path.input, path.support),
424                        taps: taps_48k.clone(),
425                        fixed: false,
426                    },
427                ],
428                MimoFilter::FirLowRate { taps_low, fs_low } => vec![
429                    Op::Delay {
430                        ms: path.delay_ms,
431                        fixed: false,
432                    },
433                    Op::Hp {
434                        frequency: path.low_hz,
435                        kind: BandType::Lr24,
436                        fixed: false,
437                    },
438                    Op::FirLowRate {
439                        name: format!("mimo_{}_{}", path.input, path.support),
440                        taps_low: taps_low.clone(),
441                        fs_low: *fs_low,
442                        fixed: false,
443                    },
444                ],
445                MimoFilter::Iir {
446                    gain_db,
447                    delay_ms,
448                    polarity,
449                    peq,
450                    allpass,
451                } => {
452                    let mut ops = vec![Op::Gain {
453                        db: *gain_db,
454                        fixed: false,
455                    }];
456                    if *polarity < 0 {
457                        ops.push(Op::Invert { fixed: false });
458                    }
459                    ops.push(Op::Delay {
460                        ms: path.delay_ms + delay_ms,
461                        fixed: false,
462                    });
463                    ops.extend(peq.iter().map(|&(frequency, q, gain_db)| Op::Peq {
464                        frequency,
465                        q,
466                        gain_db,
467                        fixed: false,
468                    }));
469                    ops.extend(allpass.iter().map(|&(frequency, q)| Op::Ap {
470                        frequency,
471                        q,
472                        fixed: false,
473                    }));
474                    ops.push(Op::Hp {
475                        frequency: path.low_hz,
476                        kind: BandType::Lr24,
477                        fixed: false,
478                    });
479                    ops.push(Op::Lp {
480                        frequency: paths.fsiso,
481                        kind: BandType::Lr24,
482                        fixed: false,
483                    });
484                    ops
485                }
486            };
487            ops.extend(fixed_ops(chain, &path.support));
488            plan.mimo.push(MimoFeed {
489                input: path.input.clone(),
490                support: path.support.clone(),
491                ops,
492            });
493        }
494    }
495    plan
496}
497
498fn multiply(out: &mut [Complex64], values: impl IntoIterator<Item = Complex64>) {
499    for (left, right) in out.iter_mut().zip(values) {
500        *left *= right;
501    }
502}
503
504fn op_response(op: &Op, f: &[f64], skip_fixed: bool) -> Result<Vec<Complex64>, GraphError> {
505    if skip_fixed && op.is_fixed() {
506        return Ok(vec![Complex64::new(1.0, 0.0); f.len()]);
507    }
508    let result = match op {
509        Op::Peq {
510            frequency,
511            q,
512            gain_db,
513            ..
514        } => peaking_response(f, *frequency, *q, *gain_db, f64::from(SAMPLE_RATE)),
515        Op::Fir { taps, .. } => {
516            let n = 2 * f.len().saturating_sub(1);
517            if n == 0 {
518                return Ok(Vec::new());
519            }
520            let mut padded = vec![0.0; n];
521            let count = n.min(taps.len());
522            padded[..count].copy_from_slice(&taps[..count]);
523            fft::rfft(&padded)
524        }
525        Op::FirLowRate {
526            taps_low, fs_low, ..
527        } => {
528            FirView::LowRate(taps_low, *fs_low).validate()?;
529            let taps = secs_dsp::fir::lowrate_to_48k(taps_low, *fs_low);
530            let n = 2 * f.len().saturating_sub(1);
531            if n == 0 {
532                return Ok(Vec::new());
533            }
534            let mut padded = vec![0.0; n];
535            let count = n.min(taps.len());
536            padded[..count].copy_from_slice(&taps[..count]);
537            fft::rfft(&padded)
538        }
539        Op::Gain { db, .. } => vec![Complex64::new(10_f64.powf(db / 20.0), 0.0); f.len()],
540        Op::Invert { .. } => vec![Complex64::new(-1.0, 0.0); f.len()],
541        Op::Hp {
542            frequency, kind, ..
543        } => band_filter(f, *frequency, "HP", kind.name())
544            .map_err(|error| GraphError::Invalid(error.to_string()))?,
545        Op::Lp {
546            frequency, kind, ..
547        } => band_filter(f, *frequency, "LP", kind.name())
548            .map_err(|error| GraphError::Invalid(error.to_string()))?,
549        Op::Ap { frequency, q, .. } => allpass2(f, *frequency, *q),
550        Op::Delay { ms, .. } => f
551            .iter()
552            .map(|frequency| Complex64::from_polar(1.0, -2.0 * PI * frequency * ms / 1000.0))
553            .collect(),
554    };
555    Ok(result)
556}
557
558fn response_chain(ops: &[Op], f: &[f64], skip_fixed: bool) -> Result<Vec<Complex64>, GraphError> {
559    let mut out = vec![Complex64::new(1.0, 0.0); f.len()];
560    for op in ops {
561        multiply(&mut out, op_response(op, f, skip_fixed)?);
562    }
563    Ok(out)
564}
565
566fn add_response(out: &mut Vec<(String, Vec<Complex64>)>, role: &str, value: Vec<Complex64>) {
567    if let Some((_, current)) = out.iter_mut().find(|(name, _)| name == role) {
568        for (left, right) in current.iter_mut().zip(value) {
569            *left += right;
570        }
571    } else {
572        out.push((role.to_owned(), value));
573    }
574}
575
576/// Compute electrical responses from one input to every reachable output.
577///
578/// A FIR's response is one real FFT of its taps, which lands on that FFT's
579/// grid, not on `f`. So when the plan holds a FIR, `f` must be the 48 kHz
580/// real-FFT grid `f[k] = k * 48000 / (2 * (f.len() - 1))` with at least two
581/// points, and any other `f` is [`GraphError::Invalid`]. Without a FIR,
582/// every response is evaluated on `f` itself.
583pub fn plan_responses(
584    plan: &FilterPlan,
585    f: &[f64],
586    source: &str,
587    skip_fixed: bool,
588) -> Result<Vec<(String, Vec<Complex64>)>, GraphError> {
589    if !plan.firs().is_empty() {
590        check_rfft_grid(f)?;
591    }
592    plan_responses_prepared(plan, f, source, skip_fixed, None)
593}
594
595fn check_rfft_grid(f: &[f64]) -> Result<(), GraphError> {
596    let n = 2 * f.len().saturating_sub(1);
597    let step = f64::from(SAMPLE_RATE) / n as f64;
598    // A millionth of a bin absorbs how a caller rounds `k * step`.
599    let on_grid = n > 0
600        && f.iter()
601            .enumerate()
602            .all(|(k, &value)| (value - k as f64 * step).abs() <= step * 1e-6);
603    if on_grid {
604        Ok(())
605    } else {
606        Err(GraphError::Invalid(format!(
607            "FIR responses need the 48 kHz real-FFT grid of {} points",
608            f.len()
609        )))
610    }
611}
612
613fn plan_responses_prepared(
614    plan: &FilterPlan,
615    f: &[f64],
616    source: &str,
617    skip_fixed: bool,
618    sub_responses: Option<&[Vec<Complex64>]>,
619) -> Result<Vec<(String, Vec<Complex64>)>, GraphError> {
620    let mut out = Vec::new();
621    let (pre, send) = if source == "LFE" {
622        if !plan.inputs.iter().any(|role| role == "LFE") {
623            return Err(GraphError::Invalid("unknown input LFE".into()));
624        }
625        (
626            response_chain(&plan.lfe_ops, f, false)?,
627            Some(vec![Complex64::new(1.0, 0.0); f.len()]),
628        )
629    } else {
630        let input = find_ops(&plan.input_ops, source)
631            .ok_or_else(|| GraphError::Invalid(format!("unknown input {source}")))?;
632        let pre = response_chain(input, f, false)?;
633        let mut main = pre.clone();
634        multiply(
635            &mut main,
636            response_chain(
637                find_ops(&plan.main_ops, source).expect("plan main"),
638                f,
639                false,
640            )?,
641        );
642        add_response(&mut out, source, main);
643        let send = find_ops(&plan.send_ops, source)
644            .map(|ops| response_chain(ops, f, false))
645            .transpose()?;
646        (pre, send)
647    };
648    for (sub_index, (role, feed)) in plan.subs.iter().enumerate() {
649        if feed.sources.iter().any(|item| item == source)
650            && let Some(send) = &send
651        {
652            let factor = f64::from(feed.polarity) * 10_f64.powf(feed.gain_db / 20.0);
653            let mut value = pre.clone();
654            multiply(&mut value, send.iter().copied());
655            for item in &mut value {
656                *item *= factor;
657            }
658            if let Some(responses) = sub_responses {
659                multiply(&mut value, responses[sub_index].iter().copied());
660            } else {
661                multiply(&mut value, response_chain(&feed.ops, f, skip_fixed)?);
662            }
663            add_response(&mut out, role, value);
664        }
665    }
666    for path in &plan.mimo {
667        if path.input == source {
668            add_response(
669                &mut out,
670                &path.support,
671                response_chain(&path.ops, f, skip_fixed)?,
672            );
673        }
674    }
675    Ok(out)
676}
677
678/// Largest non-LFE output boost from 20 Hz through 20 kHz.
679pub fn headroom_db(plan: &FilterPlan) -> Result<f64, GraphError> {
680    let firs = plan.firs();
681    for (_, taps) in &firs {
682        taps.validate()?;
683    }
684    let taps = firs
685        .iter()
686        .map(|(_, taps)| taps.len_48k())
687        .max()
688        .unwrap_or(1);
689    let n = 65_536_usize.max(taps.max(2).next_power_of_two());
690    let f = (0..=n / 2)
691        .map(|index| index as f64 * f64::from(SAMPLE_RATE) / n as f64)
692        .collect::<Vec<_>>();
693    // The same sub output chain is reached from every input. Compute it once;
694    // keep multiplication and summation in the original per-input order.
695    let sub_responses = plan
696        .subs
697        .iter()
698        .map(|(_, feed)| response_chain(&feed.ops, &f, false))
699        .collect::<Result<Vec<_>, _>>()?;
700    let mut peak = 0.0_f64;
701    for source in plan.inputs.iter().filter(|source| source.as_str() != "LFE") {
702        for (_, response) in plan_responses_prepared(plan, &f, source, false, Some(&sub_responses))?
703        {
704            for (&frequency, value) in f.iter().zip(response) {
705                if (20.0..=20_000.0).contains(&frequency) {
706                    peak = peak.max(value.norm());
707                }
708            }
709        }
710    }
711    Ok(if peak > 0.0 {
712        (20.0 * peak.log10()).max(0.0)
713    } else {
714        0.0
715    })
716}