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 seat_guard: bool,
30}
31
32impl MimoKey {
33 pub fn from_settings(settings: &CalibrationSettings) -> Self {
35 let mut exclude: Vec<_> = settings
36 .subs_manual
37 .iter()
38 .filter(|(_, s)| !s.mimo_support)
39 .map(|(r, _)| r.clone())
40 .collect();
41 exclude.sort();
42 Self {
43 fsiso: if settings.fsiso.is_finite() {
44 settings.fsiso.clamp(50.0, 150.0)
45 } else {
46 150.0
47 },
48 f_low: settings
49 .mimo_low
50 .filter(|f| f.is_finite())
51 .map(|f| f.clamp(15.0, 150.0)),
52 exclude,
53 max_supports: settings.mimo_max_supports.clamp(0, 12) as usize,
54 fir: settings.mimo_fir,
55 seat_guard: settings.mimo_seat_guard,
56 }
57 }
58
59 pub(crate) fn request(&self) -> MimoRequest {
60 MimoRequest {
61 fsiso: self.fsiso,
62 f_low: self.f_low,
63 exclude: self.exclude.clone(),
64 max_supports: (self.max_supports != 0).then_some(self.max_supports),
65 seat_guard: self.seat_guard,
66 taps_low: if self.fir {
67 FIR_TAPS_LOW
68 } else {
69 MimoRequest::default().taps_low
70 },
71 ..MimoRequest::default()
72 }
73 }
74}
75
76#[derive(Debug, Clone, PartialEq)]
77pub struct MimoRoleScore {
78 pub role: String,
79 pub after_mimo: Metrics,
80}
81
82#[derive(Debug, Clone, PartialEq)]
83pub struct MimoRoleLateEnergy {
84 pub role: String,
85 pub position: u32,
86 pub after_mimo_db: f64,
87}
88
89#[derive(Debug, Clone, PartialEq)]
90pub struct MimoRun {
91 pub key: MimoKey,
92 pub ideal: MimoDesign,
93 pub paths: MimoPaths,
94 pub final_resp: Vec<(String, Vec<C>)>,
95 pub applied: Vec<(String, Vec<C>)>,
97 pub scorecard: Vec<MimoRoleScore>,
100 pub late_energy: Vec<MimoRoleLateEnergy>,
102 pub spl_mimo: Vec<(String, Vec<f64>)>,
104 pub notes: Vec<Note>,
105 pub fir: bool,
106 pub compute: secs_types::compute::ComputeReport,
107}
108
109#[derive(Debug, Clone)]
110pub(crate) enum Support {
111 Iir(IirSupport),
112 Fir { taps: Vec<f64>, fs_low: f64 },
113}
114pub(crate) type Supports = Vec<(String, Vec<(String, Support)>)>;
115
116pub(crate) fn fir_supports(ideal: &MimoDesign) -> Supports {
117 ideal
118 .supports
119 .iter()
120 .filter(|(_, s)| !s.is_empty())
121 .map(|(r, supports)| {
122 (
123 r.clone(),
124 supports
125 .iter()
126 .map(|(s, taps)| {
127 (
128 s.clone(),
129 Support::Fir {
130 taps: taps.clone(),
131 fs_low: ideal.fs_low,
132 },
133 )
134 })
135 .collect(),
136 )
137 })
138 .collect()
139}
140
141pub(crate) fn fir_response(taps: &[f64], fs_low: f64, frequencies: &[f64]) -> Vec<C> {
143 secs_dsp::fir::lowrate_response(taps, fs_low, frequencies)
144}
145
146pub fn fir_to_48k(taps_low: &[f64], fs_low: f64) -> Vec<f64> {
149 secs_dsp::fir::lowrate_to_48k(taps_low, fs_low)
150}
151
152pub fn availability(positions: usize) -> (bool, String) {
153 if positions < 3 {
154 (
155 false,
156 format!("측정 위치 {positions}곳 — MIMO 는 3곳 이상 필요"),
157 )
158 } else {
159 (true, String::new())
160 }
161}
162
163pub fn kind_text(fir: bool) -> &'static str {
164 if fir { "FIR" } else { "IIR" }
165}
166
167fn grouped(n: usize) -> String {
168 let s = n.to_string();
169 let mut out = String::new();
170 for (i, c) in s.chars().enumerate() {
171 if i != 0 && (s.len() - i).is_multiple_of(3) {
172 out.push(',');
173 }
174 out.push(c);
175 }
176 out
177}
178
179pub fn fir_budget_text(run: &MimoRun, budget: usize) -> String {
180 if !run.fir || budget == 0 {
181 return String::new();
182 }
183 let total: usize = run.paths.paths.iter().map(|p| p.filter.fir_len_48k()).sum();
184 format!(
185 "FIR 서포트 총 {}탭 — DSP FIR 예산 {}탭 {}",
186 grouped(total),
187 grouped(budget),
188 if total <= budget { "안" } else { "초과" }
189 )
190}
191
192pub fn cap_text(max_supports: usize) -> String {
193 if max_supports == 0 {
194 String::new()
195 } else {
196 format!(" · 입력당 서포트 ≤ {max_supports}")
197 }
198}
199
200pub fn estimate_seconds(inputs: usize, positions: usize, workers: usize, fir: bool) -> f64 {
202 let rounds = if fir {
203 0
204 } else {
205 inputs.max(1).div_ceil(workers.max(1))
206 };
207 (10.0_f64).max((40.0 + rounds as f64 * 355.0) * (positions.max(3) as f64 / 9.0))
208}
209
210fn check(ctx: &Context) -> Result<(), EngineError> {
211 ctx.cancel().check().map_err(|_| EngineError::Cancelled)
212}
213
214pub fn compute(
215 design: &Design,
216 measurements: &MeasurementSet,
217 config: &EngineConfig,
218 settings: &CalibrationSettings,
219 tuning: &Tuning,
220 ctx: &Context,
221) -> Result<MimoRun, EngineError> {
222 compute_observed(
223 design,
224 measurements,
225 config,
226 settings,
227 tuning,
228 ctx,
229 &|_, _| {},
230 )
231}
232
233#[allow(clippy::too_many_arguments)]
234pub(crate) fn compute_observed(
235 design: &Design,
236 measurements: &MeasurementSet,
237 config: &EngineConfig,
238 settings: &CalibrationSettings,
239 tuning: &Tuning,
240 ctx: &Context,
241 observe: &(dyn Fn(&str, std::time::Duration) + Sync),
242) -> Result<MimoRun, EngineError> {
243 compute_observed_accelerated(
244 design,
245 measurements,
246 config,
247 settings,
248 tuning,
249 ctx,
250 observe,
251 None,
252 )
253}
254
255#[allow(clippy::too_many_arguments)]
257pub fn compute_with_acceleration(
258 design: &Design,
259 measurements: &MeasurementSet,
260 config: &EngineConfig,
261 settings: &CalibrationSettings,
262 tuning: &Tuning,
263 ctx: &Context,
264 acceleration: &super::AccelerationSelection,
265) -> Result<MimoRun, EngineError> {
266 if acceleration.status.preference == secs_types::compute::ComputePreference::Gpu
267 && (acceleration.backend.is_none()
268 || acceleration.status.selected != secs_types::compute::ComputeBackend::Cuda)
269 {
270 return Err(EngineError::Input("requested GPU is unavailable".into()));
271 }
272 compute_observed_accelerated(
273 design,
274 measurements,
275 config,
276 settings,
277 tuning,
278 ctx,
279 &|_, _| {},
280 Some(acceleration),
281 )
282}
283
284#[allow(clippy::too_many_arguments)]
285fn compute_observed_accelerated(
286 design: &Design,
287 measurements: &MeasurementSet,
288 config: &EngineConfig,
289 settings: &CalibrationSettings,
290 tuning: &Tuning,
291 ctx: &Context,
292 observe: &(dyn Fn(&str, std::time::Duration) + Sync),
293 acceleration: Option<&super::AccelerationSelection>,
294) -> Result<MimoRun, EngineError> {
295 check(ctx)?;
296 if !settings.mimo_on {
297 return Err(EngineError::Input("MIMO is disabled".into()));
298 }
299 let (ok, reason) = availability(design.spectra.positions.len());
300 if !ok {
301 return Err(EngineError::Input(reason));
302 }
303 let key = MimoKey::from_settings(settings);
304 let iir = IirSolveOptions {
305 derivatives: if settings.mimo_iir_analytic_derivatives {
306 IirDerivatives::Analytic
307 } else {
308 IirDerivatives::CentralDifference
309 },
310 seed_peq_derivatives: if settings.peq_analytic_derivatives {
311 PeqDerivatives::Analytic
312 } else {
313 PeqDerivatives::TwoPoint
314 },
315 ..IirSolveOptions::default()
316 };
317 let iir_reference = acceleration.filter(|_| !key.fir).map(|selection| {
320 let mut selection = selection.clone();
321 selection.backend = None;
322 selection.status.reason = secs_types::compute::ComputeReason::IirReference;
323 selection
324 });
325 let acceleration = iir_reference.as_ref().or(acceleration);
326 let req = key.request();
327 let progress = ctx.stage("engine.mimo", 2);
328 let started = std::time::Instant::now();
329 let prepared = super::prepared::Prepared::new(design, measurements, config, ctx)?;
330 observe("preparation", started.elapsed());
331 let started = std::time::Instant::now();
332 let acceleration = acceleration.map(super::acceleration::RunAcceleration::new);
333 let ideal = super::design::design_mimo_prepared_accelerated(
334 design,
335 &prepared,
336 &req,
337 ctx,
338 None,
339 observe,
340 acceleration.as_ref(),
341 )?
342 .0;
343 observe("design_wall", started.elapsed());
344 progress.advance(1);
345 check(ctx)?;
346 let started = std::time::Instant::now();
347 let supports = if key.fir {
348 fir_supports(&ideal)
349 } else if ideal.supports.is_empty() {
350 Vec::new()
351 } else {
352 super::iir::design_mimo_iir_prepared_with_options(
353 design, &prepared, &ideal, &req, tuning, &iir, ctx,
354 )?
355 .supports
356 .into_iter()
357 .map(|(r, s)| {
358 (
359 r,
360 s.into_iter().map(|(r, s)| (r, Support::Iir(s))).collect(),
361 )
362 })
363 .collect()
364 };
365 observe("realization", started.elapsed());
366 let started = std::time::Instant::now();
367 let mut run = assemble_prepared(design, &prepared, &ideal, supports, key, ctx)?;
368 if let Some(acceleration) = acceleration {
369 run.compute = acceleration.report();
370 }
371 observe("assemble", started.elapsed());
372 progress.advance(1);
373 Ok(run)
374}
375
376#[cfg(test)]
377#[allow(clippy::too_many_arguments)]
378pub(crate) fn assemble(
379 design: &Design,
380 measurements: &MeasurementSet,
381 config: &EngineConfig,
382 ideal: &MimoDesign,
383 supports: Supports,
384 key: MimoKey,
385 ctx: &Context,
386) -> Result<MimoRun, EngineError> {
387 check(ctx)?;
388 let prepared = super::prepared::Prepared::before(design, measurements, config)?;
389 assemble_prepared(design, &prepared, ideal, supports, key, ctx)
390}
391
392pub(crate) fn without_input_cut<'a>(
398 response: &'a [C],
399 cut: Option<&[C]>,
400) -> std::borrow::Cow<'a, [C]> {
401 match cut {
402 None => std::borrow::Cow::Borrowed(response),
403 Some(cut) => response
404 .iter()
405 .zip(cut)
406 .map(|(r, h)| if h.norm_sqr() == 0.0 { *r } else { r / h })
407 .collect(),
408 }
409}
410
411struct ResponseAt<'a> {
412 role: &'a str,
413 position: u32,
414}
415
416fn applied_response(
417 design: &Design,
418 prepared: &super::prepared::Prepared<'_>,
419 ideal: &MimoDesign,
420 supports: &Supports,
421 lp: &[C],
422 at: ResponseAt<'_>,
423 ctx: &Context,
424) -> Result<(Vec<C>, Vec<C>), EngineError> {
425 check(ctx)?;
426 let f = design_freqs();
427 let base = crate::stage_responses(design, at.role, &prepared.spectra, at.position)
428 .ok_or_else(|| {
429 EngineError::Input(format!(
430 "missing MIMO final response {}/{}",
431 at.position, at.role
432 ))
433 })?
434 .output;
435 let mut extra = vec![C::new(0.0, 0.0); f.len()];
436 if let Some((_, sups)) = supports.iter().find(|(input, _)| input == at.role) {
437 for (support, realization) in sups {
438 check(ctx)?;
439 let path = support_path(
440 ideal,
441 support,
442 &prepared.spectra,
443 at.position,
444 &prepared.align_delay_ms,
445 )?;
446 let response = match realization {
447 Support::Iir(support) => support.response(f),
448 Support::Fir { taps, fs_low } => fir_response(taps, *fs_low, f),
449 };
450 for index in 0..f.len() {
451 extra[index] += path[index] * lp[index] * response[index];
452 }
453 }
454 }
455 let applied = base
456 .iter()
457 .zip(extra)
458 .map(|(base, extra)| base + extra)
459 .collect();
460 Ok((base, applied))
461}
462
463pub(crate) fn assemble_prepared(
464 design: &Design,
465 prepared: &super::prepared::Prepared<'_>,
466 ideal: &MimoDesign,
467 supports: Supports,
468 key: MimoKey,
469 ctx: &Context,
470) -> Result<MimoRun, EngineError> {
471 check(ctx)?;
472 let f = design_freqs();
473 let mut paths = Vec::new();
474 for (input, sups) in &supports {
475 for (support, sp) in sups {
476 check(ctx)?;
477 let low_hz = ideal
478 .ranges
479 .iter()
480 .find(|(r, _)| r == support)
481 .and_then(|(_, r)| *r)
482 .map_or(50.0, |r| r.0);
483 let delay_ms = prepared
484 .align_delay_ms
485 .iter()
486 .find(|(r, _)| r == support)
487 .map_or(0.0, |(_, d)| *d)
488 + design.chain.main_extra_ms;
489 let filter = match sp {
490 Support::Iir(s) => MimoFilter::Iir {
491 gain_db: s.gain_db,
492 delay_ms: s.delay_ms,
493 polarity: s.polarity,
494 peq: s.peq.clone(),
495 allpass: s.allpass.clone(),
496 },
497 Support::Fir { taps, fs_low } => MimoFilter::FirLowRate {
498 taps_low: taps.clone(),
499 fs_low: *fs_low,
500 },
501 };
502 paths.push(MimoPath {
503 input: input.clone(),
504 support: support.clone(),
505 filter,
506 low_hz,
507 delay_ms,
508 });
509 }
510 }
511 let mut ideal = ideal.clone();
512 if key.max_supports != 0 {
513 ideal
514 .notes
515 .push(Note::Mimo(crate::notes::MimoNote::SupportLimit {
516 max_supports: key.max_supports,
517 }));
518 }
519 if key.fir {
520 let total: usize = paths.iter().map(|p| p.filter.fir_len_48k()).sum();
521 ideal
522 .notes
523 .push(Note::Mimo(crate::notes::MimoNote::FirBudget {
524 support_count: paths.len(),
525 taps_total: total,
526 }));
527 }
528 let lp = if key.fir {
529 vec![C::new(1.0, 0.0); f.len()]
530 } else {
531 lr4(f, key.fsiso, "LP")
532 };
533 let spl_grid = crate::design::geomspace(5.0, 20000.0, 1390);
534 let target = design.target.sample(
535 &f.iter()
536 .map(|frequency| frequency.max(1.0))
537 .collect::<Vec<_>>(),
538 );
539 let input_cut = design
540 .chain
541 .input_hp_hz
542 .map(|hz| secs_dsp::response::band_filter(f, hz, "HP", "BW48"))
543 .transpose()?;
544 let mut final_resp = Vec::new();
545 let mut applied = Vec::new();
546 let mut scorecard = Vec::new();
547 let mut late_energy = Vec::new();
548 let mut spl_mimo = Vec::new();
549 for &position in &design.spectra.positions {
550 for main in &design.chain.mains {
551 let role = &main.role;
552 let (base, sum) = applied_response(
553 design,
554 prepared,
555 &ideal,
556 &supports,
557 &lp,
558 ResponseAt { role, position },
559 ctx,
560 )?;
561 late_energy.push(MimoRoleLateEnergy {
562 role: role.clone(),
563 position,
564 after_mimo_db: crate::decay::late_db_20_150(&sum),
565 });
566 if position == design.spectra.main_position {
567 scorecard.push(MimoRoleScore {
568 role: role.clone(),
569 after_mimo: crate::scorecard::metrics(
570 &without_input_cut(&sum, input_cut.as_deref()),
571 f,
572 &target,
573 (100.0, 400.0),
574 ),
575 });
576 spl_mimo.push((role.clone(), crate::design::spl_db(&sum, &spl_grid)));
577 final_resp.push((role.clone(), base));
578 applied.push((role.clone(), sum));
579 }
580 }
581 }
582 check(ctx)?;
583 Ok(MimoRun {
584 compute: secs_types::compute::ComputeReport::default(),
585 notes: ideal.notes.clone(),
586 ideal,
587 paths: MimoPaths {
588 fsiso: key.fsiso,
589 paths,
590 },
591 final_resp,
592 applied,
593 scorecard,
594 late_energy,
595 spl_mimo,
596 fir: key.fir,
597 key,
598 })
599}