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