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