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