1use 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
17pub(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 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 pub applied: Vec<(String, Vec<C>)>,
94 pub scorecard: Vec<MimoRoleScore>,
97 pub late_energy: Vec<MimoRoleLateEnergy>,
99 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
138pub(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
143pub 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
197pub 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#[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 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
389pub(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}