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