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    /// Common operations before input correction and the MIMO fork, including LFE.
146    pub pre_ops: Vec<Op>,
147    /// Input-correction operations by role.
148    pub input_ops: Vec<(String, Vec<Op>)>,
149    /// Main-path operations by role.
150    pub main_ops: Vec<(String, Vec<Op>)>,
151    /// Crossover sends by role.
152    pub send_ops: Vec<(String, Vec<Op>)>,
153    /// LFE input operations.
154    pub lfe_ops: Vec<Op>,
155    /// Sub feeds by role.
156    pub subs: Vec<(String, SubFeed)>,
157    /// MIMO paths.
158    pub mimo: Vec<MimoFeed>,
159}
160
161/// The low-rate FIR rule, written once: a design rate of at most 48 kHz
162/// whose 48 kHz expansion has a length `secs_dsp` accepts.
163pub(crate) fn lowrate_fits(taps: usize, fs_low: f64) -> bool {
164    fs_low <= f64::from(SAMPLE_RATE) && secs_dsp::fir::lowrate_len_48k(taps, fs_low).is_some()
165}
166
167/// One FIR of a plan, borrowed: collecting export files never clones stored
168/// FIR arrays.
169#[derive(Debug, Clone, Copy)]
170pub enum FirView<'a> {
171    /// 48 kHz taps.
172    Full(&'a [f64]),
173    /// Design-rate taps and their rate in hertz.
174    LowRate(&'a [f64], f64),
175}
176
177impl<'a> FirView<'a> {
178    /// Check a low-rate recipe against the low-rate FIR rule and its taps for
179    /// finite values. A plan from [`ExportGraph`](crate::ExportGraph) always
180    /// passes; a hand-built or revived plan may not.
181    pub fn validate(self) -> Result<(), GraphError> {
182        if let Self::LowRate(taps, fs_low) = self
183            && (!lowrate_fits(taps.len(), fs_low) || taps.iter().any(|tap| !tap.is_finite()))
184        {
185            return Err(GraphError::Invalid(
186                "invalid or oversized low-rate FIR recipe".into(),
187            ));
188        }
189        Ok(())
190    }
191
192    /// Number of taps at 48 kHz.
193    pub fn len_48k(self) -> usize {
194        match self {
195            Self::Full(taps) => taps.len(),
196            Self::LowRate(taps, fs_low) => {
197                (taps.len() as f64 * (f64::from(SAMPLE_RATE) / fs_low)).ceil() as usize
198            }
199        }
200    }
201
202    /// The 48 kHz taps, expanding a low-rate recipe.
203    pub fn materialize(self) -> Cow<'a, [f64]> {
204        match self {
205            Self::Full(taps) => Cow::Borrowed(taps),
206            Self::LowRate(taps, fs_low) => Cow::Owned(secs_dsp::fir::lowrate_to_48k(taps, fs_low)),
207        }
208    }
209}
210
211impl FilterPlan {
212    /// The plan's FIRs by stem, input corrections first, then MIMO paths; a
213    /// repeated stem keeps its first position and its last taps.
214    pub fn firs(&self) -> Vec<(&str, FirView<'_>)> {
215        let mut out: Vec<(&str, FirView<'_>)> = Vec::new();
216        for ops in self
217            .input_ops
218            .iter()
219            .map(|(_, ops)| ops)
220            .chain(self.mimo.iter().map(|m| &m.ops))
221        {
222            for op in ops {
223                let (name, view) = match op {
224                    Op::Fir { name, taps, .. } => (name.as_str(), FirView::Full(taps)),
225                    Op::FirLowRate {
226                        name,
227                        taps_low,
228                        fs_low,
229                        ..
230                    } => (name.as_str(), FirView::LowRate(taps_low, *fs_low)),
231                    _ => continue,
232                };
233                if let Some(found) = out.iter_mut().find(|(key, _)| *key == name) {
234                    found.1 = view;
235                } else {
236                    out.push((name, view));
237                }
238            }
239        }
240        out
241    }
242}
243
244fn find_ops<'a>(items: &'a [(String, Vec<Op>)], role: &str) -> Option<&'a [Op]> {
245    items
246        .iter()
247        .find(|(key, _)| key == role)
248        .map(|(_, value)| value.as_slice())
249}
250
251fn fixed_ops(chain: &SignalChain, role: &str) -> Vec<Op> {
252    let Some(sub) = chain.subs.iter().find(|sub| sub.role == role) else {
253        return Vec::new();
254    };
255    let mut ops = Vec::new();
256    if sub.hp_hz != 0.0 {
257        ops.push(Op::Hp {
258            frequency: sub.hp_hz,
259            kind: sub.hp_type,
260            fixed: true,
261        });
262    }
263    if sub.lp_hz != 0.0 {
264        ops.push(Op::Lp {
265            frequency: sub.lp_hz,
266            kind: sub.lp_type,
267            fixed: true,
268        });
269    }
270    ops.extend(sub.peq.iter().map(|&(frequency, q, gain_db)| Op::Peq {
271        frequency,
272        q,
273        gain_db,
274        fixed: true,
275    }));
276    ops
277}
278
279/// Convert a validated chain into the upstream filter plan.
280pub(crate) fn make(
281    chain: &SignalChain,
282    mimo: Option<&MimoPaths>,
283    lfe_choice: LfeChoice,
284) -> FilterPlan {
285    let lfe_gain_db = match lfe_choice {
286        LfeChoice::Omit => None,
287        LfeChoice::Include { gain_db, lp_hz } => Some((gain_db, lp_hz)),
288    };
289    let lfe = !chain.subs.is_empty() && lfe_gain_db.is_some();
290    let mut plan = FilterPlan {
291        inputs: chain.mains.iter().map(|main| main.role.clone()).collect(),
292        outputs: chain
293            .mains
294            .iter()
295            .map(|main| main.role.clone())
296            .chain(chain.subs.iter().map(|sub| sub.role.clone()))
297            .collect(),
298        pre_ops: chain
299            .input_hp_hz
300            .into_iter()
301            .map(|frequency| Op::Hp {
302                frequency,
303                kind: BandType::Bw48,
304                fixed: false,
305            })
306            .collect(),
307        input_ops: Vec::new(),
308        main_ops: Vec::new(),
309        send_ops: Vec::new(),
310        lfe_ops: Vec::new(),
311        subs: Vec::new(),
312        mimo: Vec::new(),
313    };
314    if lfe {
315        plan.inputs.push("LFE".into());
316    }
317    for main in &chain.mains {
318        let mut input = main
319            .input_peq
320            .iter()
321            .map(|&(frequency, q, gain_db)| Op::Peq {
322                frequency,
323                q,
324                gain_db,
325                fixed: false,
326            })
327            .collect::<Vec<_>>();
328        if main.fir.len() > 1 {
329            input.push(Op::Fir {
330                name: format!("{}_fir", main.role),
331                taps: main.fir.clone(),
332                fixed: false,
333            });
334        }
335        plan.input_ops.push((main.role.clone(), input));
336        let mut path = vec![Op::Gain {
337            db: main.gain_db,
338            fixed: false,
339        }];
340        path.extend(main.top_peq.iter().map(|&(frequency, q, gain_db)| Op::Peq {
341            frequency,
342            q,
343            gain_db,
344            fixed: false,
345        }));
346        if let Some(frequency) = main.xo_hz.filter(|value| *value != 0.0) {
347            path.push(Op::Hp {
348                frequency,
349                kind: BandType::Lr24,
350                fixed: false,
351            });
352            plan.send_ops.push((
353                main.role.clone(),
354                vec![Op::Lp {
355                    frequency,
356                    kind: BandType::Lr24,
357                    fixed: false,
358                }],
359            ));
360        }
361        path.push(Op::Delay {
362            ms: main.delay_ms,
363            fixed: false,
364        });
365        plan.main_ops.push((main.role.clone(), path));
366    }
367    if lfe {
368        let (gain_db, lp_hz) = lfe_gain_db.expect("LFE is enabled");
369        plan.lfe_ops = vec![
370            Op::Gain {
371                db: gain_db,
372                fixed: false,
373            },
374            Op::Lp {
375                frequency: lp_hz,
376                kind: BandType::Lr24,
377                fixed: false,
378            },
379        ];
380    }
381    let mut sources = plan
382        .send_ops
383        .iter()
384        .map(|(role, _)| role.clone())
385        .collect::<Vec<_>>();
386    if lfe {
387        sources.push("LFE".into());
388    }
389    for sub in &chain.subs {
390        let mut ops = chain
391            .sub_bus_peq
392            .iter()
393            .map(|&(frequency, q, gain_db)| Op::Peq {
394                frequency,
395                q,
396                gain_db,
397                fixed: false,
398            })
399            .collect::<Vec<_>>();
400        ops.extend(fixed_ops(chain, &sub.role));
401        ops.extend(sub.allpass.iter().map(|&(frequency, q)| Op::Ap {
402            frequency,
403            q,
404            fixed: false,
405        }));
406        ops.push(Op::Delay {
407            ms: sub.delay_ms,
408            fixed: false,
409        });
410        plan.subs.push((
411            sub.role.clone(),
412            SubFeed {
413                sources: sources.clone(),
414                gain_db: sub.gain_db,
415                polarity: if sub.polarity > 0 { 1 } else { -1 },
416                ops,
417            },
418        ));
419    }
420    if let Some(paths) = mimo {
421        for path in &paths.paths {
422            let mut ops = match &path.filter {
423                MimoFilter::Fir { taps_48k } => vec![
424                    Op::Delay {
425                        ms: path.delay_ms,
426                        fixed: false,
427                    },
428                    Op::Hp {
429                        frequency: path.low_hz,
430                        kind: BandType::Lr24,
431                        fixed: false,
432                    },
433                    Op::Fir {
434                        name: format!("mimo_{}_{}", path.input, path.support),
435                        taps: taps_48k.clone(),
436                        fixed: false,
437                    },
438                ],
439                MimoFilter::FirLowRate { taps_low, fs_low } => vec![
440                    Op::Delay {
441                        ms: path.delay_ms,
442                        fixed: false,
443                    },
444                    Op::Hp {
445                        frequency: path.low_hz,
446                        kind: BandType::Lr24,
447                        fixed: false,
448                    },
449                    Op::FirLowRate {
450                        name: format!("mimo_{}_{}", path.input, path.support),
451                        taps_low: taps_low.clone(),
452                        fs_low: *fs_low,
453                        fixed: false,
454                    },
455                ],
456                MimoFilter::Iir {
457                    gain_db,
458                    delay_ms,
459                    polarity,
460                    peq,
461                    allpass,
462                } => {
463                    let mut ops = vec![Op::Gain {
464                        db: *gain_db,
465                        fixed: false,
466                    }];
467                    if *polarity < 0 {
468                        ops.push(Op::Invert { fixed: false });
469                    }
470                    ops.push(Op::Delay {
471                        ms: path.delay_ms + delay_ms,
472                        fixed: false,
473                    });
474                    ops.extend(peq.iter().map(|&(frequency, q, gain_db)| Op::Peq {
475                        frequency,
476                        q,
477                        gain_db,
478                        fixed: false,
479                    }));
480                    ops.extend(allpass.iter().map(|&(frequency, q)| Op::Ap {
481                        frequency,
482                        q,
483                        fixed: false,
484                    }));
485                    ops.push(Op::Hp {
486                        frequency: path.low_hz,
487                        kind: BandType::Lr24,
488                        fixed: false,
489                    });
490                    ops.push(Op::Lp {
491                        frequency: paths.fsiso,
492                        kind: BandType::Lr24,
493                        fixed: false,
494                    });
495                    ops
496                }
497            };
498            ops.extend(fixed_ops(chain, &path.support));
499            plan.mimo.push(MimoFeed {
500                input: path.input.clone(),
501                support: path.support.clone(),
502                ops,
503            });
504        }
505    }
506    plan
507}
508
509fn multiply(out: &mut [Complex64], values: impl IntoIterator<Item = Complex64>) {
510    for (left, right) in out.iter_mut().zip(values) {
511        *left *= right;
512    }
513}
514
515fn op_response(op: &Op, f: &[f64], skip_fixed: bool) -> Result<Vec<Complex64>, GraphError> {
516    if skip_fixed && op.is_fixed() {
517        return Ok(vec![Complex64::new(1.0, 0.0); f.len()]);
518    }
519    let result = match op {
520        Op::Peq {
521            frequency,
522            q,
523            gain_db,
524            ..
525        } => peaking_response(f, *frequency, *q, *gain_db, f64::from(SAMPLE_RATE)),
526        Op::Fir { taps, .. } => {
527            let n = 2 * f.len().saturating_sub(1);
528            if n == 0 {
529                return Ok(Vec::new());
530            }
531            let mut padded = vec![0.0; n];
532            let count = n.min(taps.len());
533            padded[..count].copy_from_slice(&taps[..count]);
534            fft::rfft(&padded)
535        }
536        Op::FirLowRate {
537            taps_low, fs_low, ..
538        } => {
539            FirView::LowRate(taps_low, *fs_low).validate()?;
540            let taps = secs_dsp::fir::lowrate_to_48k(taps_low, *fs_low);
541            let n = 2 * f.len().saturating_sub(1);
542            if n == 0 {
543                return Ok(Vec::new());
544            }
545            let mut padded = vec![0.0; n];
546            let count = n.min(taps.len());
547            padded[..count].copy_from_slice(&taps[..count]);
548            fft::rfft(&padded)
549        }
550        Op::Gain { db, .. } => vec![Complex64::new(10_f64.powf(db / 20.0), 0.0); f.len()],
551        Op::Invert { .. } => vec![Complex64::new(-1.0, 0.0); f.len()],
552        Op::Hp {
553            frequency, kind, ..
554        } => band_filter(f, *frequency, "HP", kind.name())
555            .map_err(|error| GraphError::Invalid(error.to_string()))?,
556        Op::Lp {
557            frequency, kind, ..
558        } => band_filter(f, *frequency, "LP", kind.name())
559            .map_err(|error| GraphError::Invalid(error.to_string()))?,
560        Op::Ap { frequency, q, .. } => allpass2(f, *frequency, *q),
561        Op::Delay { ms, .. } => f
562            .iter()
563            .map(|frequency| Complex64::from_polar(1.0, -2.0 * PI * frequency * ms / 1000.0))
564            .collect(),
565    };
566    Ok(result)
567}
568
569fn response_chain(ops: &[Op], f: &[f64], skip_fixed: bool) -> Result<Vec<Complex64>, GraphError> {
570    let mut out = vec![Complex64::new(1.0, 0.0); f.len()];
571    for op in ops {
572        multiply(&mut out, op_response(op, f, skip_fixed)?);
573    }
574    Ok(out)
575}
576
577fn add_response(out: &mut Vec<(String, Vec<Complex64>)>, role: &str, value: Vec<Complex64>) {
578    if let Some((_, current)) = out.iter_mut().find(|(name, _)| name == role) {
579        for (left, right) in current.iter_mut().zip(value) {
580            *left += right;
581        }
582    } else {
583        out.push((role.to_owned(), value));
584    }
585}
586
587/// Compute electrical responses from one input to every reachable output.
588///
589/// A FIR's response is one real FFT of its taps, which lands on that FFT's
590/// grid, not on `f`. So when the plan holds a FIR, `f` must be the 48 kHz
591/// real-FFT grid `f[k] = k * 48000 / (2 * (f.len() - 1))` with at least two
592/// points, and any other `f` is [`GraphError::Invalid`]. Without a FIR,
593/// every response is evaluated on `f` itself.
594pub fn plan_responses(
595    plan: &FilterPlan,
596    f: &[f64],
597    source: &str,
598    skip_fixed: bool,
599) -> Result<Vec<(String, Vec<Complex64>)>, GraphError> {
600    if !plan.firs().is_empty() {
601        check_rfft_grid(f)?;
602    }
603    plan_responses_prepared(plan, f, source, skip_fixed, None)
604}
605
606fn check_rfft_grid(f: &[f64]) -> Result<(), GraphError> {
607    let n = 2 * f.len().saturating_sub(1);
608    let step = f64::from(SAMPLE_RATE) / n as f64;
609    // A millionth of a bin absorbs how a caller rounds `k * step`.
610    let on_grid = n > 0
611        && f.iter()
612            .enumerate()
613            .all(|(k, &value)| (value - k as f64 * step).abs() <= step * 1e-6);
614    if on_grid {
615        Ok(())
616    } else {
617        Err(GraphError::Invalid(format!(
618            "FIR responses need the 48 kHz real-FFT grid of {} points",
619            f.len()
620        )))
621    }
622}
623
624fn plan_responses_prepared(
625    plan: &FilterPlan,
626    f: &[f64],
627    source: &str,
628    skip_fixed: bool,
629    sub_responses: Option<&[Vec<Complex64>]>,
630) -> Result<Vec<(String, Vec<Complex64>)>, GraphError> {
631    let mut out = Vec::new();
632    let (pre, send) = if source == "LFE" {
633        if !plan.inputs.iter().any(|role| role == "LFE") {
634            return Err(GraphError::Invalid("unknown input LFE".into()));
635        }
636        (
637            response_chain(&plan.lfe_ops, f, false)?,
638            Some(vec![Complex64::new(1.0, 0.0); f.len()]),
639        )
640    } else {
641        let input = find_ops(&plan.input_ops, source)
642            .ok_or_else(|| GraphError::Invalid(format!("unknown input {source}")))?;
643        let pre = response_chain(input, f, false)?;
644        let mut main = pre.clone();
645        multiply(
646            &mut main,
647            response_chain(
648                find_ops(&plan.main_ops, source).expect("plan main"),
649                f,
650                false,
651            )?,
652        );
653        add_response(&mut out, source, main);
654        let send = find_ops(&plan.send_ops, source)
655            .map(|ops| response_chain(ops, f, false))
656            .transpose()?;
657        (pre, send)
658    };
659    for (sub_index, (role, feed)) in plan.subs.iter().enumerate() {
660        if feed.sources.iter().any(|item| item == source)
661            && let Some(send) = &send
662        {
663            let factor = f64::from(feed.polarity) * 10_f64.powf(feed.gain_db / 20.0);
664            let mut value = pre.clone();
665            multiply(&mut value, send.iter().copied());
666            for item in &mut value {
667                *item *= factor;
668            }
669            if let Some(responses) = sub_responses {
670                multiply(&mut value, responses[sub_index].iter().copied());
671            } else {
672                multiply(&mut value, response_chain(&feed.ops, f, skip_fixed)?);
673            }
674            add_response(&mut out, role, value);
675        }
676    }
677    for path in &plan.mimo {
678        if path.input == source {
679            add_response(
680                &mut out,
681                &path.support,
682                response_chain(&path.ops, f, skip_fixed)?,
683            );
684        }
685    }
686    // Every route starts at the same input. Factor its common response out
687    // of the sum so base and MIMO contributions receive it exactly once.
688    if !plan.pre_ops.is_empty() {
689        let common = response_chain(&plan.pre_ops, f, false)?;
690        for (_, response) in &mut out {
691            multiply(response, common.iter().copied());
692        }
693    }
694    Ok(out)
695}
696
697/// Largest non-LFE output boost from 20 Hz through 20 kHz.
698pub fn headroom_db(plan: &FilterPlan) -> Result<f64, GraphError> {
699    let firs = plan.firs();
700    for (_, taps) in &firs {
701        taps.validate()?;
702    }
703    let taps = firs
704        .iter()
705        .map(|(_, taps)| taps.len_48k())
706        .max()
707        .unwrap_or(1);
708    let n = 65_536_usize.max(taps.max(2).next_power_of_two());
709    let f = (0..=n / 2)
710        .map(|index| index as f64 * f64::from(SAMPLE_RATE) / n as f64)
711        .collect::<Vec<_>>();
712    // The same sub output chain is reached from every input. Compute it once;
713    // keep multiplication and summation in the original per-input order.
714    let sub_responses = plan
715        .subs
716        .iter()
717        .map(|(_, feed)| response_chain(&feed.ops, &f, false))
718        .collect::<Result<Vec<_>, _>>()?;
719    let mut peak = 0.0_f64;
720    for source in plan.inputs.iter().filter(|source| source.as_str() != "LFE") {
721        for (_, response) in plan_responses_prepared(plan, &f, source, false, Some(&sub_responses))?
722        {
723            for (&frequency, value) in f.iter().zip(response) {
724                if (20.0..=20_000.0).contains(&frequency) {
725                    peak = peak.max(value.norm());
726                }
727            }
728        }
729    }
730    Ok(if peak > 0.0 {
731        (20.0 * peak.log10()).max(0.0)
732    } else {
733        0.0
734    })
735}