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