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,
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 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 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
119pub(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
124pub 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
178pub 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#[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 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}