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, 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/// 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    // The fitted budget; mode_decay puts its cuts beside it (ADR 0025), so
110    // the per-channel count printed later can be larger.
111    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
260/// Builds the upstream RC note fragments from cached scalar values.
261pub 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
339/// Export-text pieces for the optional LF and HF corrections. Zero mode has
340/// no FIR, so the engine skips `lf_align` and `hf_phase` there and the text does too.
341pub 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
785/// Returns the REW mdat file name for the selected contents.
786pub 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
794/// Builds the REW mdat items for the selected contents.
795pub 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
824/// `spl_offset_db` is the project's median REW SPL offset
825/// (`secs_project::project_spl_offset`).
826pub 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
847/// Writes the staged mdat using an injected UUID source.
848pub 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
901/// Writes the before/after mdat using an injected UUID source.
902pub 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/// Writes the selected measurements and correction results to one REW mdat.
929#[allow(clippy::too_many_arguments)] // Signature keeps the existing injected-writer API shape.
930pub 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}