1#![forbid(unsafe_code)]
2#![deny(unsafe_op_in_unsafe_fn)]
3use std::{
6 collections::BTreeMap,
7 fs,
8 path::{Path, PathBuf},
9};
10
11use secs_camilla::pyfmt::{format_fixed, format_g};
12use secs_engine::{
13 Design, EngineError, ExportAxis, StageResponses, input::LayoutKind, stage_responses,
14};
15use secs_mdat::{MdatError, MeasurementInput, SourceInfo, TEMPLATE};
16use secs_types::{
17 calibration::{CalibrationSettings, Mode, RcBand},
18 chain::{MimoPaths, SubChannel},
19};
20
21pub use secs_types::ipc::MdatScope;
22
23const SR: u32 = 48_000;
24const SUB_GAIN_WARN_DB: f64 = 12.0;
25
26pub type Sources = BTreeMap<u32, BTreeMap<String, SourceInfo>>;
27
28#[derive(Debug)]
30pub struct ExportRun<'a> {
31 pub design: &'a Design,
32 pub settings: &'a CalibrationSettings,
33}
34
35#[derive(Clone, Debug)]
37pub struct Original {
38 pub position: u32,
39 pub role: String,
40 pub item: MeasurementInput,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum MimoKind {
46 Iir,
47 Fir,
48}
49
50impl MimoKind {
51 fn name(self) -> &'static str {
52 match self {
53 Self::Iir => "IIR",
54 Self::Fir => "FIR",
55 }
56 }
57}
58
59#[derive(Debug)]
61pub struct MimoResponses {
62 pub kind: MimoKind,
63 pub fsiso: f64,
64 pub cap_text: String,
65 pub paths: MimoPaths,
66 pub applied: Vec<(String, Vec<secs_engine::C>)>,
67}
68
69#[derive(Debug, Clone, Copy)]
71enum MimoExport<'a> {
72 None,
73 Responses(&'a MimoResponses),
74}
75
76#[derive(Debug, Clone, PartialEq)]
77pub enum SubWarning {
78 NarrowBand {
79 role: String,
80 hp_hz: f64,
81 lp_hz: f64,
82 },
83 HighGain {
84 role: String,
85 gain_db: f64,
86 },
87}
88
89#[derive(Debug, thiserror::Error)]
90pub enum ExportError {
91 #[error(transparent)]
92 Engine(#[from] EngineError),
93 #[error(transparent)]
94 Mdat(#[from] MdatError),
95 #[error(transparent)]
96 Io(#[from] std::io::Error),
97 #[error("operating-system randomness failed: {0}")]
98 Random(String),
99 #[error("there are no stored measurements to export")]
100 NoMeasurements,
101 #[error("calibration must run before export")]
102 NoResult,
103 #[error("missing export response for {0}")]
104 MissingResponse(String),
105}
106
107fn chain_text(run: &ExportRun<'_>) -> String {
108 let peq = run.design.replay.config().peq_bands.unwrap_or(16);
109 let peq = if run.design.replay.config().mode_decay {
112 format!("{peq} + 울림 컷")
113 } else {
114 peq.to_string()
115 };
116 if run.design.replay.config().taps > 0 {
117 format!(
118 "PEQ {peq} + FIR {}",
119 grouped(run.design.replay.config().taps)
120 )
121 } else {
122 format!("PEQ {peq}")
123 }
124}
125
126fn grouped(value: usize) -> String {
127 let digits = value.to_string();
128 let mut out = String::new();
129 for (index, ch) in digits.chars().enumerate() {
130 if index > 0 && (digits.len() - index).is_multiple_of(3) {
131 out.push(',');
132 }
133 out.push(ch);
134 }
135 out
136}
137
138fn sub_mode_text(mode: &str) -> String {
139 if mode == "fixed" {
140 "고정".into()
141 } else if let Some(rest) = mode.strip_prefix("bundle:") {
142 let mut parts = rest.split(':');
143 let bundle = parts.next().unwrap_or_default();
144 let place = match parts.next() {
145 Some("dba-front") => " DBA 앞",
146 Some("dba-rear") => " DBA 뒤",
147 Some("multi") => " 멀티",
148 _ => "",
149 };
150 format!("Group {bundle}{place}")
151 } else {
152 "자동".into()
153 }
154}
155
156fn band_type_text(name: &str) -> String {
157 let split = name.len().min(2);
158 format!("{} {}", &name[..split], &name[split..])
159}
160
161fn sub_filter_text(sub: &SubChannel) -> String {
162 let mut parts = Vec::new();
163 if sub.hp_hz != 0.0 {
164 parts.push(format!(
165 "HP {} Hz {}",
166 format_g(sub.hp_hz, false),
167 band_type_text(sub.hp_type.name())
168 ));
169 }
170 if sub.lp_hz != 0.0 {
171 parts.push(format!(
172 "LP {} Hz {}",
173 format_g(sub.lp_hz, false),
174 band_type_text(sub.lp_type.name())
175 ));
176 }
177 parts.extend(sub.peq.iter().map(|&(hz, q, gain)| {
178 format!(
179 "PEQ {} Hz Q {} {} dB",
180 format_fixed(hz, 0, false),
181 format_fixed(q, 2, false),
182 format_fixed(gain, 1, true)
183 )
184 }));
185 parts.join(" ")
186}
187
188fn sub_value_text(sub: &SubChannel) -> String {
189 let mut out = format!(
190 "{} {} {} dB {} ms {}",
191 sub.role,
192 sub_mode_text(&sub.mode),
193 format_fixed(sub.gain_db, 2, true),
194 format_fixed(sub.delay_ms, 2, false),
195 if sub.polarity > 0 { "정상" } else { "반전" }
196 );
197 for &(hz, q) in &sub.allpass {
198 out.push_str(&format!(
199 " AP {} Hz Q {}",
200 format_fixed(hz, 0, false),
201 format_fixed(q, 2, false)
202 ));
203 }
204 let filters = sub_filter_text(sub);
205 if !filters.is_empty() {
206 out.push(' ');
207 out.push_str(&filters);
208 }
209 out
210}
211
212pub fn layout_lines(run: &ExportRun<'_>) -> Vec<String> {
213 let Some(subs) = &run.design.subs else {
214 return Vec::new();
215 };
216 let mut layouts: Vec<_> = subs.layout_info.iter().collect();
217 layouts.sort_by(|a, b| a.0.cmp(&b.0));
218 layouts
219 .into_iter()
220 .map(|(name, info)| match info.kind {
221 LayoutKind::Dba => format!(
222 "Group {name} DBA: 앞 {}, 뒤 {} 반전 +{} ms(자동 {} 보정 {}), 같은 필터 PEQ {}",
223 info.front.join("·"),
224 info.rear.join("·"),
225 format_fixed(info.rear_ms, 2, false),
226 format_fixed(info.auto_ms.unwrap_or(0.0), 2, false),
227 format_fixed(info.trim_ms, 2, true),
228 info.peq.len()
229 ),
230 LayoutKind::Multi => format!(
231 "Group {name} 멀티서브: {}, 같은 필터 PEQ {}",
232 info.members.join("·"),
233 info.peq.len()
234 ),
235 })
236 .collect()
237}
238
239pub fn sub_values_text(run: &ExportRun<'_>) -> String {
240 layout_lines(run)
241 .into_iter()
242 .chain(run.design.chain.subs.iter().map(sub_value_text))
243 .collect::<Vec<_>>()
244 .join(" · ")
245}
246
247pub fn rc_pieces(run: &ExportRun<'_>) -> Vec<String> {
248 let mut pieces = rc_pieces_from_values_with_bands(
249 run.design.rc_hi,
250 run.design.rc_auto,
251 run.design.schroeder_hz,
252 run.settings.room_volume_m3,
253 false,
254 &run.settings.rc_bands,
255 );
256 pieces.extend(lf_correction_pieces(run.settings));
257 pieces
258}
259
260pub fn rc_pieces_from_values(
262 rc_hi: f64,
263 rc_auto: bool,
264 schroeder_hz: Option<f64>,
265 room_volume_m3: Option<f64>,
266 legacy_fallback: bool,
267) -> Vec<String> {
268 rc_pieces_from_values_with_bands(
269 rc_hi,
270 rc_auto,
271 schroeder_hz,
272 room_volume_m3,
273 legacy_fallback,
274 &[],
275 )
276}
277
278pub fn rc_pieces_from_values_with_bands(
279 rc_hi: f64,
280 rc_auto: bool,
281 schroeder_hz: Option<f64>,
282 room_volume_m3: Option<f64>,
283 legacy_fallback: bool,
284 rc_bands: &[RcBand],
285) -> Vec<String> {
286 let hz = format_fixed(rc_hi, 0, false);
287 let head = if rc_auto {
288 if let Some(schroeder) = schroeder_hz {
289 format!(
290 "RC 상한 {hz} Hz (슈뢰더 {} Hz)",
291 format_fixed(schroeder, 0, false)
292 )
293 } else if legacy_fallback {
294 format!("RC 상한 {hz} Hz (자동)")
295 } else {
296 format!("RC 상한 {hz} Hz (기본값)")
297 }
298 } else {
299 format!("RC 상한 {hz} Hz (수동)")
300 };
301 let mut out = vec![head];
302 if rc_auto {
303 match room_volume_m3.filter(|v| *v != 0.0) {
304 Some(volume) => {
305 out.push(format!(
306 "자동: 룸 볼륨 {} m³",
307 format_fixed(volume, 0, false)
308 ));
309 if schroeder_hz.is_some_and(|v| v > rc_hi + 0.5) {
310 out.push(format!("자동 상한 최대 {hz} Hz"));
311 }
312 }
313 None => out.push("자동: 룸 볼륨 없음".into()),
314 }
315 }
316 if let Some(piece) = rc_bands_piece(rc_bands) {
317 out.push(piece);
318 }
319 out
320}
321
322fn rc_bands_piece(rc_bands: &[RcBand]) -> Option<String> {
323 (!rc_bands.is_empty()).then(|| {
324 format!(
325 "RC 추가 구간 {} Hz",
326 rc_bands
327 .iter()
328 .map(|band| format!(
329 "{}–{}",
330 format_fixed(band.lo_hz, 0, false),
331 format_fixed(band.hi_hz, 0, false)
332 ))
333 .collect::<Vec<_>>()
334 .join(", ")
335 )
336 })
337}
338
339pub fn lf_correction_pieces(settings: &CalibrationSettings) -> Vec<String> {
342 let mut pieces = Vec::new();
343 if let Some(hz) = settings.input_hp_hz {
344 pieces.push(format!("극저역 보호(수동) {hz} Hz"));
345 }
346 if settings.lf_align && settings.mode != Mode::Zero {
347 pieces.push("저음 도착 맞춤".into());
348 }
349 if settings.mode_decay {
350 pieces.push("방 울림 줄임".into());
351 }
352 if settings.hf_phase && settings.mode != Mode::Zero {
353 pieces.push("고역 위상 맞춤".into());
354 }
355 pieces
356}
357
358pub fn sub_filter_warnings(settings: &CalibrationSettings, roles: &[String]) -> Vec<SubWarning> {
359 roles
360 .iter()
361 .filter_map(|role| {
362 let entry = settings
363 .subs_manual
364 .iter()
365 .find(|(r, _)| r == role)
366 .map(|(_, e)| e)?;
367 (entry.hp_hz != 0.0 && entry.lp_hz != 0.0 && entry.lp_hz < 2.0 * entry.hp_hz).then(
368 || SubWarning::NarrowBand {
369 role: role.clone(),
370 hp_hz: entry.hp_hz,
371 lp_hz: entry.lp_hz,
372 },
373 )
374 })
375 .collect()
376}
377
378pub fn sub_gain_warnings(design: &Design) -> Vec<SubWarning> {
379 design
380 .chain
381 .subs
382 .iter()
383 .filter(|sub| sub.gain_db > SUB_GAIN_WARN_DB)
384 .map(|sub| SubWarning::HighGain {
385 role: sub.role.clone(),
386 gain_db: sub.gain_db,
387 })
388 .collect()
389}
390
391fn item(
392 design: &Design,
393 title: String,
394 notes: String,
395 spectrum: &[secs_engine::C],
396 axis: ExportAxis,
397) -> MeasurementInput {
398 let (samples, t0) = design.replay.export_ir(design, spectrum, axis);
399 MeasurementInput {
400 title,
401 notes,
402 sample_rate_hz: SR,
403 samples,
404 start_time_s: t0,
405 spl_offset_db: None,
406 source_info: None,
407 computed: false,
408 }
409}
410
411fn stage_items_with_mimo(
412 run: &ExportRun<'_>,
413 mimo: MimoExport<'_>,
414 label: Option<&str>,
415) -> Result<Vec<MeasurementInput>, ExportError> {
416 let view = run.design.replay.export_view(run.design)?;
417 let position = run.design.replay.config().main_position;
418 let subs: Vec<_> = run
419 .design
420 .replay
421 .config()
422 .subs
423 .iter()
424 .filter_map(|role| view.spectra.get(position, role))
425 .collect();
426 let label = label.map_or_else(|| run.settings.label(), str::to_owned);
427 let mut items = Vec::new();
428 for (index, main) in run.design.chain.mains.iter().enumerate() {
429 let seq = index + 1;
430 let raw = view
431 .spectra
432 .get(position, &main.role)
433 .ok_or_else(|| ExportError::MissingResponse(main.role.clone()))?;
434 let stages = stage_responses(run.design, &main.role, &view.spectra, position)
435 .ok_or_else(|| ExportError::MissingResponse(main.role.clone()))?;
436 let prefix = format!("SECS 보정 설계 · {label} · 청취 위치 {position} · 계산값 — ");
437 items.push(item(
438 run.design,
439 format!("{seq:02}_{}_1 보정 전 메인", main.role),
440 format!("{prefix}메인 스피커 원 응답(2 s 창, 필터 없음)"),
441 raw,
442 ExportAxis::Measurement,
443 ));
444 if !subs.is_empty() {
445 let mut sum = vec![secs_engine::C::new(0.0, 0.0); raw.len()];
446 for spectrum in &subs {
447 for (out, value) in sum.iter_mut().zip(spectrum.iter()) {
448 *out += value;
449 }
450 }
451 let tail = if view.has_sub_filters {
452 "서브 원 응답의 단순 합(정렬 없음 — 서브 고정 출력 필터만)"
453 } else {
454 "서브 원 응답의 단순 합(정렬 없음·필터 없음)"
455 };
456 items.push(item(
457 run.design,
458 format!("{seq:02}_{}_1 보정 전 서브", main.role),
459 format!("{prefix}{tail}"),
460 &sum,
461 ExportAxis::Measurement,
462 ));
463 }
464 add_xo_items(run, &mut items, seq, main.role.as_str(), &prefix, &stages);
465 let mut correction_pieces = lf_correction_pieces(run.settings);
466 if let Some(piece) = rc_bands_piece(&run.settings.rc_bands) {
467 correction_pieces.insert(0, piece);
468 }
469 let correction_pieces = if correction_pieces.is_empty() {
470 String::new()
471 } else {
472 format!(" · {}", correction_pieces.join(" · "))
473 };
474 items.push(item(
475 run.design,
476 format!("{seq:02}_{}_3 합 후 보정", main.role),
477 format!(
478 "{prefix}2차: 탑+서브 합에 입력 보정({}) · 전체 지연 {} ms(시작 시각에서 뺌) · RC 상한 {} Hz{correction_pieces}",
479 chain_text(run),
480 format_fixed(run.design.chain.latency_ms, 1, false),
481 format_fixed(run.design.rc_hi, 0, false)
482 ),
483 &stages.output,
484 ExportAxis::Corrected,
485 ));
486 if let MimoExport::Responses(mimo) = mimo {
487 let supports: Vec<_> = mimo
488 .paths
489 .paths
490 .iter()
491 .filter(|path| path.input == main.role)
492 .collect();
493 if !supports.is_empty() {
494 let response = mimo
495 .applied
496 .iter()
497 .find(|(role, _)| role == &main.role)
498 .map(|(_, response)| response.as_slice())
499 .ok_or_else(|| ExportError::MissingResponse(main.role.clone()))?;
500 let description = supports
501 .iter()
502 .map(|path| match &path.filter {
503 secs_types::chain::MimoFilter::Fir { taps_48k } => {
504 format!("{} FIR {}탭", path.support, grouped(taps_48k.len()))
505 }
506 secs_types::chain::MimoFilter::FirLowRate { .. } => {
507 format!(
508 "{} FIR {}탭",
509 path.support,
510 grouped(path.filter.fir_len_48k())
511 )
512 }
513 secs_types::chain::MimoFilter::Iir {
514 gain_db,
515 delay_ms,
516 polarity,
517 peq,
518 allpass,
519 } => format!(
520 "{} {} dB {} ms {} PEQ {} AP {}",
521 path.support,
522 format_fixed(*gain_db, 1, false),
523 format_fixed(*delay_ms, 1, false),
524 if *polarity > 0 { "+" } else { "-" },
525 peq.len(),
526 allpass.len()
527 ),
528 })
529 .collect::<Vec<_>>()
530 .join("; ");
531 items.push(item(
532 run.design,
533 format!("{seq:02}_{}_4 MIMO 적용({})", main.role, mimo.kind.name()),
534 format!(
535 "{prefix}MIMO {} 서포트 {}개(Fsiso {} Hz{}) — {description}",
536 mimo.kind.name(),
537 supports.len(),
538 format_g(mimo.fsiso, false),
539 mimo.cap_text
540 ),
541 response,
542 ExportAxis::Corrected,
543 ));
544 }
545 }
546 }
547 Ok(items)
548}
549
550fn add_xo_items(
551 run: &ExportRun<'_>,
552 items: &mut Vec<MeasurementInput>,
553 seq: usize,
554 role: &str,
555 prefix: &str,
556 stages: &StageResponses,
557) {
558 let Some(xo) = run
559 .design
560 .chain
561 .mains
562 .iter()
563 .find(|m| m.role == role)
564 .and_then(|m| m.xo_hz)
565 .filter(|v| *v != 0.0)
566 else {
567 return;
568 };
569 items.push(item(
570 run.design,
571 format!("{seq:02}_{role}_2 XO 타겟 메인"),
572 format!(
573 "{prefix}1차: 게인·딜레이·출력 PEQ(음향 XO 타겟)·전기 HP {} Hz",
574 format_g(xo, false)
575 ),
576 &stages.top_xo,
577 ExportAxis::Measurement,
578 ));
579 if let Some(sub) = &stages.sub_xo {
580 items.push(item(
581 run.design,
582 format!("{seq:02}_{role}_2 XO 타겟 서브"),
583 format!(
584 "{prefix}1차: BA(게인·지연·극성)·서브 버스 PEQ·전기 LP — {}",
585 sub_values_text(run)
586 ),
587 sub,
588 ExportAxis::Measurement,
589 ));
590 }
591}
592
593pub fn stage_items(run: &ExportRun<'_>, label: Option<&str>) -> Vec<MeasurementInput> {
594 stage_items_with_mimo(run, MimoExport::None, label).expect("validated engine export inputs")
595}
596
597pub fn stage_items_mimo(
598 run: &ExportRun<'_>,
599 mimo: &MimoResponses,
600 label: Option<&str>,
601) -> Result<Vec<MeasurementInput>, ExportError> {
602 stage_items_with_mimo(run, MimoExport::Responses(mimo), label)
603}
604
605fn before_after_items_with_mimo(
606 run: &ExportRun<'_>,
607 mimo: MimoExport<'_>,
608 sources: Option<&Sources>,
609 originals: Option<&[Original]>,
610) -> Result<Vec<MeasurementInput>, ExportError> {
611 let view = run.design.replay.export_view(run.design)?;
612 let position = run.design.replay.config().main_position;
613 let label = run.settings.label();
614 let source_env = sources.and_then(|sources| sources.get(&position));
615 let mut items = Vec::new();
616 let mut seq = 0;
617 for main in &run.design.chain.mains {
618 seq += 1;
619 if let Some(measured) = run.design.replay.measurements().get(position, &main.role) {
620 items.push(measured_item(
621 seq,
622 &main.role,
623 position,
624 measured,
625 "",
626 source_env.and_then(|sources| sources.get(&main.role)),
627 originals,
628 ));
629 }
630 let final_response = stage_responses(run.design, &main.role, &view.spectra, position)
631 .ok_or_else(|| ExportError::MissingResponse(main.role.clone()))?
632 .output;
633 let fir = if main.fir.len() > 1 {
634 format!("FIR {}탭", grouped(main.fir.len()))
635 } else {
636 "FIR 없음".into()
637 };
638 let mut notes = format!(
639 "SECS 보정 설계 · {label} · 청취 위치 {position} · 계산값 — 채널 보정(PEQ {}개 · {fir}) + 서브 합, 전체 지연 {} ms(시작 시각에서 뺌) · {}",
640 main.input_peq.len(),
641 format_fixed(run.design.chain.latency_ms, 1, false),
642 rc_pieces(run).join(" · ")
643 );
644 if !run.design.chain.subs.is_empty() && main.xo_hz.is_some_and(|v| v != 0.0) {
645 notes.push_str(" · 서브: ");
646 notes.push_str(&sub_values_text(run));
647 }
648 let mut corrected = item(
649 run.design,
650 format!("{seq:02}_{}_2 보정 후 {label}", main.role),
651 notes,
652 &final_response,
653 ExportAxis::Corrected,
654 );
655 corrected.source_info = source_env
656 .and_then(|sources| sources.get(&main.role))
657 .cloned();
658 corrected.computed = true;
659 if originals.is_some() {
660 clear_curves(&mut corrected);
661 }
662 items.push(corrected);
663 if let MimoExport::Responses(mimo) = mimo {
664 let support_count = mimo
665 .paths
666 .paths
667 .iter()
668 .filter(|path| path.input == main.role)
669 .count();
670 if support_count != 0 {
671 let response = mimo
672 .applied
673 .iter()
674 .find(|(role, _)| role == &main.role)
675 .map(|(_, response)| response.as_slice())
676 .ok_or_else(|| ExportError::MissingResponse(main.role.clone()))?;
677 let mut corrected = item(
678 run.design,
679 format!(
680 "{seq:02}_{}_3 보정 후 + MIMO({})",
681 main.role,
682 mimo.kind.name()
683 ),
684 format!(
685 "SECS 보정 설계 · {label} · 청취 위치 {position} · 계산값 — MIMO {} 서포트 {support_count}개 (Fsiso {} Hz{})",
686 mimo.kind.name(),
687 format_g(mimo.fsiso, false),
688 mimo.cap_text
689 ),
690 response,
691 ExportAxis::Corrected,
692 );
693 if originals.is_some() {
694 corrected.computed = true;
695 clear_curves(&mut corrected);
696 }
697 items.push(corrected);
698 }
699 }
700 }
701 for sub in &run.design.chain.subs {
702 if let Some(measured) = run.design.replay.measurements().get(position, &sub.role) {
703 seq += 1;
704 items.push(measured_item(
705 seq,
706 &sub.role,
707 position,
708 measured,
709 &format!(" · DSP 값: {}", sub_value_text(sub)),
710 source_env.and_then(|sources| sources.get(&sub.role)),
711 originals,
712 ));
713 }
714 }
715 if let Some(originals) = originals {
716 items.extend(
717 originals
718 .iter()
719 .filter(|original| original.position != position)
720 .map(|original| original.item.clone()),
721 );
722 }
723 Ok(items)
724}
725
726fn measured_item(
727 seq: usize,
728 role: &str,
729 position: u32,
730 measured: &secs_engine::Measurement,
731 note: &str,
732 source_info: Option<&SourceInfo>,
733 originals: Option<&[Original]>,
734) -> MeasurementInput {
735 let mut item = MeasurementInput {
736 title: format!("{seq:02}_{role}_1 보정 전(원 측정)"),
737 notes: format!("SECS · 청취 위치 {position} · 원 측정 그대로(필터 없음){note}"),
738 sample_rate_hz: measured.sample_rate,
739 samples: (0..measured.samples.len())
740 .map(|index| f64::from(measured.samples.at(index) as f32))
741 .collect(),
742 start_time_s: measured.t0,
743 spl_offset_db: None,
744 source_info: source_info.cloned(),
745 computed: false,
746 };
747 let Some(originals) = originals else {
748 return item;
749 };
750 if let Some(original) = originals
751 .iter()
752 .find(|original| original.position == position && original.role == role)
753 {
754 let title = item.title;
755 let notes = item.notes;
756 item = original.item.clone();
757 item.title = title;
758 item.notes = notes;
759 item.computed = false;
760 } else {
761 clear_curves(&mut item);
762 }
763 item
764}
765
766fn clear_curves(item: &mut MeasurementInput) {
767 if let Some(source_info) = &mut item.source_info {
768 source_info.cal.clear();
769 }
770}
771
772pub fn before_after_items(run: &ExportRun<'_>, sources: Option<&Sources>) -> Vec<MeasurementInput> {
773 before_after_items_with_mimo(run, MimoExport::None, sources, None)
774 .expect("validated engine export inputs")
775}
776
777pub fn before_after_items_mimo(
778 run: &ExportRun<'_>,
779 mimo: &MimoResponses,
780 sources: Option<&Sources>,
781) -> Result<Vec<MeasurementInput>, ExportError> {
782 before_after_items_with_mimo(run, MimoExport::Responses(mimo), sources, None)
783}
784
785pub fn rew_mdat_filename(scope: MdatScope, label: &str) -> String {
787 match scope {
788 MdatScope::All => format!("SECS 원 측정+보정 결과 {label}.mdat"),
789 MdatScope::Measurements => "SECS 원 측정.mdat".into(),
790 MdatScope::Results => format!("SECS 보정 결과 {label}.mdat"),
791 }
792}
793
794pub fn rew_mdat_items(
796 run: Option<&ExportRun<'_>>,
797 scope: MdatScope,
798 originals: &[Original],
799 mimo: Option<&MimoResponses>,
800 sources: Option<&Sources>,
801) -> Result<Vec<MeasurementInput>, ExportError> {
802 if scope == MdatScope::Measurements {
803 if originals.is_empty() {
804 return Err(ExportError::NoMeasurements);
805 }
806 return Ok(originals
807 .iter()
808 .map(|original| original.item.clone())
809 .collect());
810 }
811 let run = run.ok_or(ExportError::NoResult)?;
812 let mut items = before_after_items_with_mimo(
813 run,
814 mimo.map_or(MimoExport::None, MimoExport::Responses),
815 sources,
816 Some(originals),
817 )?;
818 if scope == MdatScope::Results {
819 items.retain(|item| item.computed);
820 }
821 Ok(items)
822}
823
824pub fn export_mdat(
827 run: &ExportRun<'_>,
828 folder: &Path,
829 spl_offset_db: Option<f64>,
830 now_ms: i64,
831) -> Result<PathBuf, ExportError> {
832 export_mdat_with_mimo(run, folder, None, spl_offset_db, now_ms)
833}
834
835fn export_mdat_with_mimo(
836 run: &ExportRun<'_>,
837 folder: &Path,
838 mimo: Option<&MimoResponses>,
839 spl_offset_db: Option<f64>,
840 now_ms: i64,
841) -> Result<PathBuf, ExportError> {
842 export_mdat_with_mimo_using(run, folder, mimo, spl_offset_db, now_ms, &mut || {
843 random_uuid().map_err(|error| ExportError::Random(error.to_string()))
844 })
845}
846
847pub fn export_mdat_with_mimo_using(
849 run: &ExportRun<'_>,
850 folder: &Path,
851 mimo: Option<&MimoResponses>,
852 spl_offset_db: Option<f64>,
853 now_ms: i64,
854 uuid: &mut dyn FnMut() -> Result<u128, ExportError>,
855) -> Result<PathBuf, ExportError> {
856 let label = run.settings.label();
857 let items = stage_items_with_mimo(
858 run,
859 mimo.map_or(MimoExport::None, MimoExport::Responses),
860 Some(&label),
861 )?;
862 export_items_using(
863 folder,
864 &format!("SECS 보정 설계 {label}.mdat"),
865 &items,
866 spl_offset_db,
867 now_ms,
868 uuid,
869 )
870}
871
872pub fn export_before_after_mdat(
873 run: &ExportRun<'_>,
874 folder: &Path,
875 sources: Option<&Sources>,
876 spl_offset_db: Option<f64>,
877 now_ms: i64,
878) -> Result<PathBuf, ExportError> {
879 export_before_after_mdat_with_mimo(run, folder, None, sources, spl_offset_db, now_ms)
880}
881
882fn export_before_after_mdat_with_mimo(
883 run: &ExportRun<'_>,
884 folder: &Path,
885 mimo: Option<&MimoResponses>,
886 sources: Option<&Sources>,
887 spl_offset_db: Option<f64>,
888 now_ms: i64,
889) -> Result<PathBuf, ExportError> {
890 export_before_after_mdat_with_mimo_using(
891 run,
892 folder,
893 mimo,
894 sources,
895 spl_offset_db,
896 now_ms,
897 &mut || random_uuid().map_err(|error| ExportError::Random(error.to_string())),
898 )
899}
900
901pub fn export_before_after_mdat_with_mimo_using(
903 run: &ExportRun<'_>,
904 folder: &Path,
905 mimo: Option<&MimoResponses>,
906 sources: Option<&Sources>,
907 spl_offset_db: Option<f64>,
908 now_ms: i64,
909 uuid: &mut dyn FnMut() -> Result<u128, ExportError>,
910) -> Result<PathBuf, ExportError> {
911 let label = run.settings.label();
912 let items = before_after_items_with_mimo(
913 run,
914 mimo.map_or(MimoExport::None, MimoExport::Responses),
915 sources,
916 None,
917 )?;
918 export_items_using(
919 folder,
920 &format!("SECS 보정 전후 {label}.mdat"),
921 &items,
922 spl_offset_db,
923 now_ms,
924 uuid,
925 )
926}
927
928#[allow(clippy::too_many_arguments)] pub fn export_rew_mdat_using(
931 run: Option<&ExportRun<'_>>,
932 scope: MdatScope,
933 originals: &[Original],
934 folder: &Path,
935 mimo: Option<&MimoResponses>,
936 sources: Option<&Sources>,
937 spl_offset_db: Option<f64>,
938 now_ms: i64,
939 uuid: &mut dyn FnMut() -> Result<u128, ExportError>,
940) -> Result<PathBuf, ExportError> {
941 let items = rew_mdat_items(run, scope, originals, mimo, sources)?;
942 let label = run.map(|run| run.settings.label()).unwrap_or_default();
943 export_items_using(
944 folder,
945 &rew_mdat_filename(scope, &label),
946 &items,
947 spl_offset_db,
948 now_ms,
949 uuid,
950 )
951}
952
953fn export_items_using(
954 folder: &Path,
955 filename: &str,
956 items: &[MeasurementInput],
957 spl_offset_db: Option<f64>,
958 now_ms: i64,
959 uuid: &mut dyn FnMut() -> Result<u128, ExportError>,
960) -> Result<PathBuf, ExportError> {
961 let uuids = (0..items.len())
962 .map(|_| uuid())
963 .collect::<Result<Vec<_>, _>>()?;
964 let mut uuids = uuids.into_iter();
965 let bytes = secs_mdat::build(items, TEMPLATE, spl_offset_db, now_ms, &mut || {
966 uuids
967 .next()
968 .expect("one pre-generated UUID per validated measurement")
969 })?;
970 fs::create_dir_all(folder)?;
971 let destination = folder.join(filename);
972 atomic_replace(&destination, &bytes)?;
973 Ok(destination)
974}
975
976fn random_uuid() -> Result<u128, getrandom::Error> {
977 let mut bytes = [0_u8; 16];
978 getrandom::fill(&mut bytes)?;
979 let mut value = u128::from_be_bytes(bytes);
980 value &= !(0xf_u128 << 76);
981 value |= 4_u128 << 76;
982 value &= !(0x3_u128 << 62);
983 value |= 0x2_u128 << 62;
984 Ok(value)
985}
986
987fn atomic_replace(destination: &Path, bytes: &[u8]) -> Result<(), std::io::Error> {
988 secs_types::fs::write_atomic_bytes(destination, bytes)
989}
990
991#[cfg(test)]
992mod tests {
993 use super::*;
994 use std::sync::atomic::{AtomicU64, Ordering};
995
996 static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
997
998 #[test]
999 fn rc_pieces_from_values_preserves_live_and_legacy_wording() {
1000 assert_eq!(
1001 rc_pieces_from_values(300.0, true, None, None, false),
1002 ["RC 상한 300 Hz (기본값)", "자동: 룸 볼륨 없음"]
1003 );
1004 assert_eq!(
1005 rc_pieces_from_values(300.0, true, None, None, true),
1006 ["RC 상한 300 Hz (자동)", "자동: 룸 볼륨 없음"]
1007 );
1008 assert_eq!(
1009 rc_pieces_from_values(300.0, false, None, Some(80.0), true),
1010 ["RC 상한 300 Hz (수동)"]
1011 );
1012 assert_eq!(
1013 rc_pieces_from_values(300.0, true, Some(420.0), Some(80.0), false),
1014 [
1015 "RC 상한 300 Hz (슈뢰더 420 Hz)",
1016 "자동: 룸 볼륨 80 m³",
1017 "자동 상한 최대 300 Hz",
1018 ]
1019 );
1020 assert_eq!(
1021 rc_pieces_from_values_with_bands(
1022 300.0,
1023 false,
1024 None,
1025 None,
1026 false,
1027 &[
1028 RcBand {
1029 lo_hz: 1_000.0,
1030 hi_hz: 2_000.0,
1031 },
1032 RcBand {
1033 lo_hz: 15_000.0,
1034 hi_hz: 20_000.0,
1035 },
1036 ],
1037 ),
1038 [
1039 "RC 상한 300 Hz (수동)",
1040 "RC 추가 구간 1000–2000, 15000–20000 Hz"
1041 ]
1042 );
1043 let settings = CalibrationSettings {
1044 lf_align: true,
1045 mode_decay: true,
1046 hf_phase: true,
1047 input_hp_hz: Some(27.0),
1048 ..Default::default()
1049 };
1050 assert_eq!(
1051 lf_correction_pieces(&settings),
1052 [
1053 "극저역 보호(수동) 27 Hz",
1054 "저음 도착 맞춤",
1055 "방 울림 줄임",
1056 "고역 위상 맞춤"
1057 ]
1058 );
1059 let zero = CalibrationSettings {
1060 mode: Mode::Zero,
1061 ..settings
1062 };
1063 assert_eq!(
1064 lf_correction_pieces(&zero),
1065 ["극저역 보호(수동) 27 Hz", "방 울림 줄임"]
1066 );
1067 }
1068
1069 #[test]
1070 fn atomic_replace_overwrites_and_cleans_temporary_file() {
1071 let root = std::env::temp_dir().join(format!(
1072 "secs-export-atomic-{}-{}",
1073 std::process::id(),
1074 TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
1075 ));
1076 fs::create_dir(&root).unwrap();
1077 let destination = root.join("SECS 보정 설계 표준 모드.mdat");
1078 fs::write(&destination, b"old").unwrap();
1079
1080 atomic_replace(&destination, b"new").unwrap();
1081
1082 assert_eq!(fs::read(&destination).unwrap(), b"new");
1083 assert_eq!(fs::read_dir(&root).unwrap().count(), 1);
1084 fs::remove_dir_all(root).unwrap();
1085 }
1086
1087 #[test]
1088 fn atomic_replace_cleans_temporary_file_after_rename_failure() {
1089 let root = std::env::temp_dir().join(format!(
1090 "secs-export-atomic-failure-{}-{}",
1091 std::process::id(),
1092 TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
1093 ));
1094 fs::create_dir(&root).unwrap();
1095 let destination = root.join("occupied");
1096 fs::create_dir(&destination).unwrap();
1097
1098 assert!(atomic_replace(&destination, b"new").is_err());
1099
1100 assert!(destination.is_dir());
1101 assert_eq!(fs::read_dir(&root).unwrap().count(), 1);
1102 fs::remove_dir_all(root).unwrap();
1103 }
1104
1105 #[test]
1106 fn random_uuid_has_v4_variant() {
1107 let uuid = random_uuid().unwrap();
1108 assert_eq!((uuid >> 76) & 0xf, 4);
1109 assert_eq!((uuid >> 62) & 0x3, 2);
1110 }
1111
1112 #[test]
1113 fn warning_edges() {
1114 let mut settings = CalibrationSettings::default();
1115 let entry = secs_types::calibration::SubEntry {
1116 hp_hz: 30.0,
1117 lp_hz: 50.0,
1118 ..secs_types::calibration::SubEntry::default()
1119 };
1120 settings.subs_manual.push(("SW1".into(), entry));
1121 assert_eq!(
1122 sub_filter_warnings(&settings, &["SW1".into()]),
1123 vec![SubWarning::NarrowBand {
1124 role: "SW1".into(),
1125 hp_hz: 30.0,
1126 lp_hz: 50.0,
1127 }]
1128 );
1129 }
1130}