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 pub mimo_early_ms: f64,
30 pub seat_guard: bool,
31}
32
33impl MimoKey {
34 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 pub applied: Vec<(String, Vec<C>)>,
101 pub scorecard: Vec<MimoRoleScore>,
104 pub late_energy: Vec<MimoRoleLateEnergy>,
106 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
145pub(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
150pub 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
204pub 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#[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 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
396pub(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}