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>,
96 pub late_energy: Vec<MimoRoleLateEnergy>,
98 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
137pub(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
142pub 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
196pub 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#[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 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}