Skip to main content

secs_engine/mimo/
run.rs

1//! Port of SECS 2 `calibration.compute_mimo` and `export_camilladsp.mimo_paths`
2//! (GPL-3.0). Export filters and the displayed response share one run value.
3
4use secs_compute::Context;
5use secs_dsp::response::lr4;
6use secs_types::{
7    calibration::CalibrationSettings,
8    chain::{MimoFilter, MimoPath, MimoPaths},
9};
10
11use super::{IirDerivatives, IirSolveOptions, IirSupport, MimoDesign, MimoRequest, support_path};
12use crate::{
13    C, Design, EngineConfig, EngineError, MeasurementSet, Note, PeqDerivatives, Tuning,
14    grid::design_freqs, scorecard::Metrics,
15};
16
17/// Low-rate taps a FIR support solves for (upstream `mimo.FIR_TAPS_LOW`):
18/// 8,000 taps at 48 kHz are 100 at 600 Hz, and the 31-tap Fsiso low-pass
19/// takes 30 of them.
20pub(crate) const FIR_TAPS_LOW: usize = 70;
21
22#[derive(Debug, Clone, PartialEq)]
23pub struct MimoKey {
24    pub fsiso: f64,
25    pub f_low: Option<f64>,
26    pub exclude: Vec<String>,
27    pub max_supports: usize,
28    pub fir: bool,
29}
30
31impl MimoKey {
32    /// Normalize the typed settings using `CalibrationSettings.from_project`'s bounds.
33    pub fn from_settings(settings: &CalibrationSettings) -> Self {
34        let mut exclude: Vec<_> = settings
35            .subs_manual
36            .iter()
37            .filter(|(_, s)| !s.mimo_support)
38            .map(|(r, _)| r.clone())
39            .collect();
40        exclude.sort();
41        Self {
42            fsiso: if settings.fsiso.is_finite() {
43                settings.fsiso.clamp(50.0, 150.0)
44            } else {
45                150.0
46            },
47            f_low: settings
48                .mimo_low
49                .filter(|f| f.is_finite())
50                .map(|f| f.clamp(15.0, 150.0)),
51            exclude,
52            max_supports: settings.mimo_max_supports.clamp(0, 12) as usize,
53            fir: settings.mimo_fir,
54        }
55    }
56
57    pub(crate) fn request(&self) -> MimoRequest {
58        MimoRequest {
59            fsiso: self.fsiso,
60            f_low: self.f_low,
61            exclude: self.exclude.clone(),
62            max_supports: (self.max_supports != 0).then_some(self.max_supports),
63            taps_low: if self.fir {
64                FIR_TAPS_LOW
65            } else {
66                MimoRequest::default().taps_low
67            },
68            ..MimoRequest::default()
69        }
70    }
71}
72
73#[derive(Debug, Clone, PartialEq)]
74pub struct MimoRoleScore {
75    pub role: String,
76    pub after_mimo: Metrics,
77}
78
79#[derive(Debug, Clone, PartialEq)]
80pub struct MimoRoleLateEnergy {
81    pub role: String,
82    pub position: u32,
83    pub after_mimo_db: f64,
84}
85
86#[derive(Debug, Clone, PartialEq)]
87pub struct MimoRun {
88    pub key: MimoKey,
89    pub ideal: MimoDesign,
90    pub paths: MimoPaths,
91    pub final_resp: Vec<(String, Vec<C>)>,
92    /// Main-position responses consumed by the `보정 후 + MIMO` mdat items.
93    pub applied: Vec<(String, Vec<C>)>,
94    /// Scorecard metrics computed from those exact main-position responses,
95    /// with the common input cut divided back out (ADR 0031).
96    pub scorecard: Vec<MimoRoleScore>,
97    /// Late energy after MIMO, position-major and in main-chain order.
98    pub late_energy: Vec<MimoRoleLateEnergy>,
99    /// Upstream's 1390-point geometric SPL grid, 5–20,000 Hz, smoothed at 1/24 octave.
100    pub spl_mimo: Vec<(String, Vec<f64>)>,
101    pub notes: Vec<Note>,
102    pub fir: bool,
103    pub compute: secs_types::compute::ComputeReport,
104}
105
106#[derive(Debug, Clone)]
107pub(crate) enum Support {
108    Iir(IirSupport),
109    Fir { taps: Vec<f64>, fs_low: f64 },
110}
111pub(crate) type Supports = Vec<(String, Vec<(String, Support)>)>;
112
113pub(crate) fn fir_supports(ideal: &MimoDesign) -> Supports {
114    ideal
115        .supports
116        .iter()
117        .filter(|(_, s)| !s.is_empty())
118        .map(|(r, supports)| {
119            (
120                r.clone(),
121                supports
122                    .iter()
123                    .map(|(s, taps)| {
124                        (
125                            s.clone(),
126                            Support::Fir {
127                                taps: taps.clone(),
128                                fs_low: ideal.fs_low,
129                            },
130                        )
131                    })
132                    .collect(),
133            )
134        })
135        .collect()
136}
137
138/// Low-rate DTFT and upstream's linear 10% edge ramp (its comment says cosine).
139pub(crate) fn fir_response(taps: &[f64], fs_low: f64, frequencies: &[f64]) -> Vec<C> {
140    secs_dsp::fir::lowrate_response(taps, fs_low, frequencies)
141}
142
143/// Band-limited interpolation to 48 kHz, cropped to the low-rate FIR's duration.
144/// Invalid or oversized low-rate recipes produce an empty filter.
145pub fn fir_to_48k(taps_low: &[f64], fs_low: f64) -> Vec<f64> {
146    secs_dsp::fir::lowrate_to_48k(taps_low, fs_low)
147}
148
149pub fn availability(positions: usize) -> (bool, String) {
150    if positions < 3 {
151        (
152            false,
153            format!("측정 위치 {positions}곳 — MIMO 는 3곳 이상 필요"),
154        )
155    } else {
156        (true, String::new())
157    }
158}
159
160pub fn kind_text(fir: bool) -> &'static str {
161    if fir { "FIR" } else { "IIR" }
162}
163
164fn grouped(n: usize) -> String {
165    let s = n.to_string();
166    let mut out = String::new();
167    for (i, c) in s.chars().enumerate() {
168        if i != 0 && (s.len() - i).is_multiple_of(3) {
169            out.push(',');
170        }
171        out.push(c);
172    }
173    out
174}
175
176pub fn fir_budget_text(run: &MimoRun, budget: usize) -> String {
177    if !run.fir || budget == 0 {
178        return String::new();
179    }
180    let total: usize = run.paths.paths.iter().map(|p| p.filter.fir_len_48k()).sum();
181    format!(
182        "FIR 서포트 총 {}탭 — DSP FIR 예산 {}탭 {}",
183        grouped(total),
184        grouped(budget),
185        if total <= budget { "안" } else { "초과" }
186    )
187}
188
189pub fn cap_text(max_supports: usize) -> String {
190    if max_supports == 0 {
191        String::new()
192    } else {
193        format!(" · 입력당 서포트 ≤ {max_supports}")
194    }
195}
196
197/// Upstream's estimate with project input count and worker count made explicit.
198pub fn estimate_seconds(inputs: usize, positions: usize, workers: usize, fir: bool) -> f64 {
199    let rounds = if fir {
200        0
201    } else {
202        inputs.max(1).div_ceil(workers.max(1))
203    };
204    (10.0_f64).max((40.0 + rounds as f64 * 355.0) * (positions.max(3) as f64 / 9.0))
205}
206
207fn check(ctx: &Context) -> Result<(), EngineError> {
208    ctx.cancel().check().map_err(|_| EngineError::Cancelled)
209}
210
211pub fn compute(
212    design: &Design,
213    measurements: &MeasurementSet,
214    config: &EngineConfig,
215    settings: &CalibrationSettings,
216    tuning: &Tuning,
217    ctx: &Context,
218) -> Result<MimoRun, EngineError> {
219    compute_observed(
220        design,
221        measurements,
222        config,
223        settings,
224        tuning,
225        ctx,
226        &|_, _| {},
227    )
228}
229
230#[allow(clippy::too_many_arguments)]
231pub(crate) fn compute_observed(
232    design: &Design,
233    measurements: &MeasurementSet,
234    config: &EngineConfig,
235    settings: &CalibrationSettings,
236    tuning: &Tuning,
237    ctx: &Context,
238    observe: &(dyn Fn(&str, std::time::Duration) + Sync),
239) -> Result<MimoRun, EngineError> {
240    compute_observed_accelerated(
241        design,
242        measurements,
243        config,
244        settings,
245        tuning,
246        ctx,
247        observe,
248        None,
249    )
250}
251
252/// Application entry point with an explicitly selected optional accelerator.
253#[allow(clippy::too_many_arguments)]
254pub fn compute_with_acceleration(
255    design: &Design,
256    measurements: &MeasurementSet,
257    config: &EngineConfig,
258    settings: &CalibrationSettings,
259    tuning: &Tuning,
260    ctx: &Context,
261    acceleration: &super::AccelerationSelection,
262) -> Result<MimoRun, EngineError> {
263    if acceleration.status.preference == secs_types::compute::ComputePreference::Gpu
264        && (acceleration.backend.is_none()
265            || acceleration.status.selected != secs_types::compute::ComputeBackend::Cuda)
266    {
267        return Err(EngineError::Input("requested GPU is unavailable".into()));
268    }
269    compute_observed_accelerated(
270        design,
271        measurements,
272        config,
273        settings,
274        tuning,
275        ctx,
276        &|_, _| {},
277        Some(acceleration),
278    )
279}
280
281#[allow(clippy::too_many_arguments)]
282fn compute_observed_accelerated(
283    design: &Design,
284    measurements: &MeasurementSet,
285    config: &EngineConfig,
286    settings: &CalibrationSettings,
287    tuning: &Tuning,
288    ctx: &Context,
289    observe: &(dyn Fn(&str, std::time::Duration) + Sync),
290    acceleration: Option<&super::AccelerationSelection>,
291) -> Result<MimoRun, EngineError> {
292    check(ctx)?;
293    if !settings.mimo_on {
294        return Err(EngineError::Input("MIMO is disabled".into()));
295    }
296    let (ok, reason) = availability(design.spectra.positions.len());
297    if !ok {
298        return Err(EngineError::Input(reason));
299    }
300    let key = MimoKey::from_settings(settings);
301    let iir = IirSolveOptions {
302        derivatives: if settings.mimo_iir_analytic_derivatives {
303            IirDerivatives::Analytic
304        } else {
305            IirDerivatives::CentralDifference
306        },
307        seed_peq_derivatives: if settings.peq_analytic_derivatives {
308            PeqDerivatives::Analytic
309        } else {
310            PeqDerivatives::TwoPoint
311        },
312        ..IirSolveOptions::default()
313    };
314    // Capped IIR fitting amplifies even tiny changes in its ideal target.
315    // Retain the exact reference target for IIR, including explicit GPU requests.
316    let iir_reference = acceleration.filter(|_| !key.fir).map(|selection| {
317        let mut selection = selection.clone();
318        selection.backend = None;
319        selection.status.reason = secs_types::compute::ComputeReason::IirReference;
320        selection
321    });
322    let acceleration = iir_reference.as_ref().or(acceleration);
323    let req = key.request();
324    let progress = ctx.stage("engine.mimo", 2);
325    let started = std::time::Instant::now();
326    let prepared = super::prepared::Prepared::new(design, measurements, config, ctx)?;
327    observe("preparation", started.elapsed());
328    let started = std::time::Instant::now();
329    let acceleration = acceleration.map(super::acceleration::RunAcceleration::new);
330    let ideal = super::design::design_mimo_prepared_accelerated(
331        design,
332        &prepared,
333        &req,
334        ctx,
335        None,
336        observe,
337        acceleration.as_ref(),
338    )?
339    .0;
340    observe("design_wall", started.elapsed());
341    progress.advance(1);
342    check(ctx)?;
343    let started = std::time::Instant::now();
344    let supports = if key.fir {
345        fir_supports(&ideal)
346    } else if ideal.supports.is_empty() {
347        Vec::new()
348    } else {
349        super::iir::design_mimo_iir_prepared_with_options(
350            design, &prepared, &ideal, &req, tuning, &iir, ctx,
351        )?
352        .supports
353        .into_iter()
354        .map(|(r, s)| {
355            (
356                r,
357                s.into_iter().map(|(r, s)| (r, Support::Iir(s))).collect(),
358            )
359        })
360        .collect()
361    };
362    observe("realization", started.elapsed());
363    let started = std::time::Instant::now();
364    let mut run = assemble_prepared(design, &prepared, &ideal, supports, key, ctx)?;
365    if let Some(acceleration) = acceleration {
366        run.compute = acceleration.report();
367    }
368    observe("assemble", started.elapsed());
369    progress.advance(1);
370    Ok(run)
371}
372
373#[cfg(test)]
374#[allow(clippy::too_many_arguments)]
375pub(crate) fn assemble(
376    design: &Design,
377    measurements: &MeasurementSet,
378    config: &EngineConfig,
379    ideal: &MimoDesign,
380    supports: Supports,
381    key: MimoKey,
382    ctx: &Context,
383) -> Result<MimoRun, EngineError> {
384    check(ctx)?;
385    let prepared = super::prepared::Prepared::before(design, measurements, config)?;
386    assemble_prepared(design, &prepared, ideal, supports, key, ctx)
387}
388
389/// `response` without the common input cut, for the scorecard: ADR 0031 keeps
390/// every verdict as it is without the cut, and the cut multiplies every path
391/// alike, so dividing it back out leaves the response the verdict scores
392/// without it. Late energy and the SPL curves keep the cut. The cut is zero
393/// only at DC, where every path is zero too, so that bin stays as it is.
394pub(crate) fn without_input_cut<'a>(
395    response: &'a [C],
396    cut: Option<&[C]>,
397) -> std::borrow::Cow<'a, [C]> {
398    match cut {
399        None => std::borrow::Cow::Borrowed(response),
400        Some(cut) => response
401            .iter()
402            .zip(cut)
403            .map(|(r, h)| if h.norm_sqr() == 0.0 { *r } else { r / h })
404            .collect(),
405    }
406}
407
408struct ResponseAt<'a> {
409    role: &'a str,
410    position: u32,
411}
412
413fn applied_response(
414    design: &Design,
415    prepared: &super::prepared::Prepared<'_>,
416    ideal: &MimoDesign,
417    supports: &Supports,
418    lp: &[C],
419    at: ResponseAt<'_>,
420    ctx: &Context,
421) -> Result<(Vec<C>, Vec<C>), EngineError> {
422    check(ctx)?;
423    let f = design_freqs();
424    let base = crate::stage_responses(design, at.role, &prepared.spectra, at.position)
425        .ok_or_else(|| {
426            EngineError::Input(format!(
427                "missing MIMO final response {}/{}",
428                at.position, at.role
429            ))
430        })?
431        .output;
432    let mut extra = vec![C::new(0.0, 0.0); f.len()];
433    if let Some((_, sups)) = supports.iter().find(|(input, _)| input == at.role) {
434        for (support, realization) in sups {
435            check(ctx)?;
436            let path = support_path(
437                ideal,
438                support,
439                &prepared.spectra,
440                at.position,
441                &prepared.align_delay_ms,
442            )?;
443            let response = match realization {
444                Support::Iir(support) => support.response(f),
445                Support::Fir { taps, fs_low } => fir_response(taps, *fs_low, f),
446            };
447            for index in 0..f.len() {
448                extra[index] += path[index] * lp[index] * response[index];
449            }
450        }
451    }
452    let applied = base
453        .iter()
454        .zip(extra)
455        .map(|(base, extra)| base + extra)
456        .collect();
457    Ok((base, applied))
458}
459
460pub(crate) fn assemble_prepared(
461    design: &Design,
462    prepared: &super::prepared::Prepared<'_>,
463    ideal: &MimoDesign,
464    supports: Supports,
465    key: MimoKey,
466    ctx: &Context,
467) -> Result<MimoRun, EngineError> {
468    check(ctx)?;
469    let f = design_freqs();
470    let mut paths = Vec::new();
471    for (input, sups) in &supports {
472        for (support, sp) in sups {
473            check(ctx)?;
474            let low_hz = ideal
475                .ranges
476                .iter()
477                .find(|(r, _)| r == support)
478                .and_then(|(_, r)| *r)
479                .map_or(50.0, |r| r.0);
480            let delay_ms = prepared
481                .align_delay_ms
482                .iter()
483                .find(|(r, _)| r == support)
484                .map_or(0.0, |(_, d)| *d)
485                + design.chain.main_extra_ms;
486            let filter = match sp {
487                Support::Iir(s) => MimoFilter::Iir {
488                    gain_db: s.gain_db,
489                    delay_ms: s.delay_ms,
490                    polarity: s.polarity,
491                    peq: s.peq.clone(),
492                    allpass: s.allpass.clone(),
493                },
494                Support::Fir { taps, fs_low } => MimoFilter::FirLowRate {
495                    taps_low: taps.clone(),
496                    fs_low: *fs_low,
497                },
498            };
499            paths.push(MimoPath {
500                input: input.clone(),
501                support: support.clone(),
502                filter,
503                low_hz,
504                delay_ms,
505            });
506        }
507    }
508    let mut ideal = ideal.clone();
509    if key.max_supports != 0 {
510        ideal
511            .notes
512            .push(Note::Mimo(crate::notes::MimoNote::SupportLimit {
513                max_supports: key.max_supports,
514            }));
515    }
516    if key.fir {
517        let total: usize = paths.iter().map(|p| p.filter.fir_len_48k()).sum();
518        ideal
519            .notes
520            .push(Note::Mimo(crate::notes::MimoNote::FirBudget {
521                support_count: paths.len(),
522                taps_total: total,
523            }));
524    }
525    let lp = if key.fir {
526        vec![C::new(1.0, 0.0); f.len()]
527    } else {
528        lr4(f, key.fsiso, "LP")
529    };
530    let spl_grid = crate::design::geomspace(5.0, 20000.0, 1390);
531    let target = design.target.sample(
532        &f.iter()
533            .map(|frequency| frequency.max(1.0))
534            .collect::<Vec<_>>(),
535    );
536    let input_cut = design
537        .chain
538        .input_hp_hz
539        .map(|hz| secs_dsp::response::band_filter(f, hz, "HP", "BW48"))
540        .transpose()?;
541    let mut final_resp = Vec::new();
542    let mut applied = Vec::new();
543    let mut scorecard = Vec::new();
544    let mut late_energy = Vec::new();
545    let mut spl_mimo = Vec::new();
546    for &position in &design.spectra.positions {
547        for main in &design.chain.mains {
548            let role = &main.role;
549            let (base, sum) = applied_response(
550                design,
551                prepared,
552                &ideal,
553                &supports,
554                &lp,
555                ResponseAt { role, position },
556                ctx,
557            )?;
558            late_energy.push(MimoRoleLateEnergy {
559                role: role.clone(),
560                position,
561                after_mimo_db: crate::decay::late_db_20_150(&sum),
562            });
563            if position == design.spectra.main_position {
564                scorecard.push(MimoRoleScore {
565                    role: role.clone(),
566                    after_mimo: crate::scorecard::metrics(
567                        &without_input_cut(&sum, input_cut.as_deref()),
568                        f,
569                        &target,
570                        (100.0, 400.0),
571                    ),
572                });
573                spl_mimo.push((role.clone(), crate::design::spl_db(&sum, &spl_grid)));
574                final_resp.push((role.clone(), base));
575                applied.push((role.clone(), sum));
576            }
577        }
578    }
579    check(ctx)?;
580    Ok(MimoRun {
581        compute: secs_types::compute::ComputeReport::default(),
582        notes: ideal.notes.clone(),
583        ideal,
584        paths: MimoPaths {
585            fsiso: key.fsiso,
586            paths,
587        },
588        final_resp,
589        applied,
590        scorecard,
591        late_energy,
592        spl_mimo,
593        fir: key.fir,
594        key,
595    })
596}