Skip to main content

secs_export/
lib.rs

1#![forbid(unsafe_code)]
2#![deny(unsafe_op_in_unsafe_fn)]
3//! SECS 2's user-visible REW `.mdat` exports.
4
5use 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/// Inputs already computed by the correction engine.
29#[derive(Debug)]
30pub struct ExportRun<'a> {
31    pub design: &'a Design,
32    pub settings: &'a CalibrationSettings,
33}
34
35/// One stored measurement, as the project's side mdat writes it.
36#[derive(Clone, Debug)]
37pub struct Original {
38    pub position: u32,
39    pub role: String,
40    pub item: MeasurementInput,
41}
42
43/// MIMO support-filter realization used in exported labels.
44#[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/// MIMO paths and already-applied design-grid responses.
60#[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/// Typed future seam for M3 MIMO stage responses.
70#[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
250/// Builds the upstream RC note fragments from cached scalar values.
251pub 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
707/// Returns the REW mdat file name for the selected contents.
708pub 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
716/// Builds the REW mdat items for the selected contents.
717pub 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
746/// `spl_offset_db` is the project's median REW SPL offset
747/// (`secs_project::project_spl_offset`).
748pub 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
769/// Writes the staged mdat using an injected UUID source.
770pub 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
823/// Writes the before/after mdat using an injected UUID source.
824pub 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/// Writes the selected measurements and correction results to one REW mdat.
851#[allow(clippy::too_many_arguments)] // Signature keeps the existing injected-writer API shape.
852pub 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}