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