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