Skip to main content

secs_project/
project.rs

1use crate::pyjson::{Shape, merge_with_file};
2use crate::{
3    DspProfile, ProjectError, PyJson, SpeakerConfig, TargetCurve, TargetJson, dumps, loads,
4};
5
6pub const PROJECT_VERSION: i64 = 3;
7pub const FOCUS_STEPS: [i64; 5] = [0, 25, 50, 75, 100];
8
9const PROJECT_KEYS: [&str; 14] = [
10    "version",
11    "name",
12    "config",
13    "dsp",
14    "target",
15    "curtain",
16    "xo",
17    "bc",
18    "mso",
19    "measurements",
20    "verify",
21    "tesira",
22    "snapshots",
23    "extra",
24];
25
26pub(crate) fn integer(
27    value: Option<&PyJson>,
28    default: i64,
29    field: &str,
30) -> Result<i64, ProjectError> {
31    match value {
32        None => Ok(default),
33        Some(PyJson::Int(value)) => value
34            .parse()
35            .map_err(|_| ProjectError::Invalid(format!("{field} must fit in an integer"))),
36        Some(PyJson::Float(value)) if value.is_finite() => {
37            let truncated = value.trunc();
38            if truncated < i64::MIN as f64 || truncated >= -(i64::MIN as f64) {
39                Err(ProjectError::Invalid(format!(
40                    "{field} must fit in an integer"
41                )))
42            } else {
43                Ok(truncated as i64)
44            }
45        }
46        Some(PyJson::Bool(value)) => Ok(i64::from(*value)),
47        Some(PyJson::Str(value)) => value
48            .parse()
49            .map_err(|_| ProjectError::Invalid(format!("{field} must be an integer"))),
50        Some(_) => Err(ProjectError::Invalid(format!("{field} must be an integer"))),
51    }
52}
53
54pub(crate) fn float(value: &PyJson) -> Result<f64, ProjectError> {
55    match value {
56        PyJson::Int(value) => {
57            let parsed = value
58                .parse::<f64>()
59                .map_err(|_| ProjectError::Invalid("expected number".into()))?;
60            if !parsed.is_finite() {
61                Err(ProjectError::Invalid(
62                    "integer is too large for a Python float".into(),
63                ))
64            } else {
65                Ok(parsed)
66            }
67        }
68        PyJson::Float(value) => Ok(*value),
69        PyJson::Bool(value) => Ok(if *value { 1.0 } else { 0.0 }),
70        PyJson::Str(value) => value
71            .parse()
72            .map_err(|_| ProjectError::Invalid("expected number".into())),
73        _ => Err(ProjectError::Invalid("expected number".into())),
74    }
75}
76
77pub(crate) fn string(value: Option<&PyJson>, default: &str) -> String {
78    match value {
79        None => default.into(),
80        Some(PyJson::Str(value) | PyJson::Int(value)) => value.clone(),
81        Some(PyJson::Float(value)) => crate::pyjson::dumps(&PyJson::Float(*value)),
82        Some(PyJson::Bool(true)) => "True".into(),
83        Some(PyJson::Bool(false)) => "False".into(),
84        Some(PyJson::Null) => "None".into(),
85        Some(value) => crate::pyjson::dumps(value),
86    }
87}
88
89pub(crate) fn optional_bool(value: Option<&PyJson>) -> Option<bool> {
90    value.map(PyJson::is_truthy)
91}
92
93fn get<'a>(object: &'a [(String, PyJson)], key: &str) -> Option<&'a PyJson> {
94    object
95        .iter()
96        .find(|(name, _)| name == key)
97        .map(|(_, value)| value)
98}
99
100fn set(object: &mut Vec<(String, PyJson)>, key: &str, value: PyJson) {
101    if let Some((_, old)) = object.iter_mut().find(|(name, _)| name == key) {
102        *old = value;
103    } else {
104        object.push((key.into(), value));
105    }
106}
107
108fn object_or_empty(
109    value: Option<&PyJson>,
110    field: &str,
111) -> Result<Vec<(String, PyJson)>, ProjectError> {
112    match value {
113        None | Some(PyJson::Null) => Ok(Vec::new()),
114        Some(PyJson::Object(value)) => Ok(value.clone()),
115        Some(_) => Err(ProjectError::Invalid(format!("{field} must be an object"))),
116    }
117}
118
119fn array_or_empty(value: Option<&PyJson>, field: &str) -> Result<Vec<PyJson>, ProjectError> {
120    match value {
121        None | Some(PyJson::Null) => Ok(Vec::new()),
122        Some(PyJson::Array(value)) => Ok(value.clone()),
123        Some(_) => Err(ProjectError::Invalid(format!("{field} must be an array"))),
124    }
125}
126
127pub fn snap_focus(value: &PyJson) -> i64 {
128    let x = float(value).unwrap_or(50.0);
129    let mut best = FOCUS_STEPS[0];
130    let mut distance = (best as f64 - x).abs();
131    for step in FOCUS_STEPS.into_iter().skip(1) {
132        let candidate = (step as f64 - x).abs();
133        if candidate < distance {
134            best = step;
135            distance = candidate;
136        }
137    }
138    best
139}
140
141pub fn rename_keys(value: &PyJson, table: &[(&str, &str)]) -> PyJson {
142    let PyJson::Object(object) = value else {
143        return value.clone();
144    };
145    if !table
146        .iter()
147        .any(|(old, _)| object.iter().any(|(key, _)| key == old))
148    {
149        return value.clone();
150    }
151    let mut out = Vec::new();
152    for (key, value) in object {
153        if let Some((_, new)) = table.iter().find(|(old, _)| key == *old) {
154            if !object.iter().any(|(key, _)| key == new) {
155                out.push(((*new).into(), value.clone()));
156            }
157        } else {
158            out.push((key.clone(), value.clone()));
159        }
160    }
161    PyJson::Object(out)
162}
163
164pub fn migrate_group_keys(value: &PyJson) -> PyJson {
165    rename_keys(value, &[("하이트", "탑")])
166}
167
168pub fn migrate_calibration_keys(value: &PyJson) -> PyJson {
169    let mut out = rename_keys(
170        value,
171        &[
172            ("art_on", "mimo_on"),
173            ("art_low", "mimo_low"),
174            ("art_max_supports", "mimo_max_supports"),
175        ],
176    );
177    if let Some(PyJson::Object(manual)) = out.get("subs_manual").cloned() {
178        let migrated = PyJson::Object(
179            manual
180                .into_iter()
181                .map(|(role, entry)| {
182                    (
183                        role,
184                        rename_keys(&entry, &[("art_support", "mimo_support")]),
185                    )
186                })
187                .collect(),
188        );
189        set(
190            out.object_mut()
191                .expect("renaming an object returns an object"),
192            "subs_manual",
193            migrated,
194        );
195    }
196    out
197}
198
199#[derive(Debug, Clone, PartialEq)]
200pub struct Project {
201    pub version: i64,
202    pub name: String,
203    pub config: SpeakerConfig,
204    pub dsp: DspProfile,
205    pub target: TargetCurve,
206    pub curtain: (f64, f64),
207    pub xo: Vec<(String, f64)>,
208    pub bc: PyJson,
209    pub mso: PyJson,
210    pub measurements: PyJson,
211    pub verify: PyJson,
212    pub tesira: PyJson,
213    pub snapshots: PyJson,
214    pub extra: Vec<(String, PyJson)>,
215    /// Fingerprints of calibration attached to recorded measurements, keyed by
216    /// position and role. This runtime index is rebuilt from source sidecars and
217    /// is never written to the project file (ADR 0015).
218    pub measurement_calibration: std::collections::BTreeMap<(i64, String), u64>,
219    /// The `config`, `dsp`, `target` and `calibration` values as the file
220    /// held them (after migration). Saving rebuilds those from the typed
221    /// fields and [`crate::store_calibration`], then keeps the keys this
222    /// version does not know from these (ADR 0004, docs/DEVIATIONS.md F16).
223    /// Empty for a project that was not read from a file.
224    pub loaded: Vec<(String, PyJson)>,
225}
226
227/// The fields rebuilt on save and the shape each meets its loaded value with.
228const REBUILT: [(&str, &Shape); 4] = [
229    ("config", &crate::config::CONFIG_SHAPE),
230    ("dsp", &crate::dsp_profile::DSP_SHAPE),
231    ("target", &crate::target::TARGET_SHAPE),
232    ("calibration", &crate::calibration::CALIBRATION_SHAPE),
233];
234
235impl Default for Project {
236    fn default() -> Self {
237        Self {
238            version: PROJECT_VERSION,
239            name: String::new(),
240            config: SpeakerConfig::default(),
241            dsp: DspProfile::default(),
242            target: TargetCurve::default(),
243            curtain: (24.0, 300.0),
244            xo: Vec::new(),
245            bc: PyJson::Object(vec![
246                ("mode".into(), PyJson::Str("full".into())),
247                ("phase".into(), PyJson::Str("minphase".into())),
248                ("target_preset".into(), PyJson::Str("flat".into())),
249            ]),
250            mso: PyJson::Object(vec![
251                ("lo".into(), PyJson::Int("20".into())),
252                ("hi".into(), PyJson::Int("150".into())),
253                ("hp".into(), PyJson::Int("25".into())),
254                ("maxGain".into(), PyJson::Int("-6".into())),
255                ("maxDelay".into(), PyJson::Int("20".into())),
256                ("maxPer".into(), PyJson::Int("4".into())),
257                ("focus".into(), PyJson::Int("50".into())),
258                ("active".into(), PyJson::Object(vec![])),
259                ("level".into(), PyJson::Object(vec![])),
260                ("decay".into(), PyJson::Int("1".into())),
261            ]),
262            measurements: PyJson::Array(vec![]),
263            verify: PyJson::Object(vec![("A".into(), PyJson::Null), ("B".into(), PyJson::Null)]),
264            tesira: PyJson::Object(vec![
265                ("ip".into(), PyJson::Str(String::new())),
266                ("method".into(), PyJson::Str("ttp".into())),
267                ("slot".into(), PyJson::Int("1".into())),
268            ]),
269            snapshots: PyJson::Array(vec![]),
270            extra: Vec::new(),
271            measurement_calibration: std::collections::BTreeMap::new(),
272            loaded: Vec::new(),
273        }
274    }
275}
276
277impl Project {
278    pub fn from_preset(name: &str) -> Result<Self, ProjectError> {
279        Ok(Self {
280            config: SpeakerConfig::from_preset(name, "", false)?,
281            ..Self::default()
282        })
283    }
284
285    pub fn from_json(value: PyJson) -> Result<Self, ProjectError> {
286        Self::from_json_with_warnings(value).map(|(project, _)| project)
287    }
288
289    pub(crate) fn from_json_with_warnings(
290        value: PyJson,
291    ) -> Result<(Self, Vec<crate::MigrationWarning>), ProjectError> {
292        let object = value
293            .object()
294            .ok_or_else(|| ProjectError::Invalid("project root must be an object".into()))?;
295        let mut project = Self {
296            version: integer(get(object, "version"), 1, "version")?,
297            name: string(get(object, "name"), ""),
298            config: SpeakerConfig::from_json(
299                get(object, "config")
300                    .cloned()
301                    .unwrap_or(PyJson::Object(vec![])),
302            )?,
303            dsp: DspProfile::from_json(
304                get(object, "dsp")
305                    .cloned()
306                    .unwrap_or(PyJson::Object(vec![])),
307            )?,
308            target: TargetCurve::from_json(
309                get(object, "target")
310                    .cloned()
311                    .unwrap_or(PyJson::Object(vec![])),
312            )?,
313            ..Self::default()
314        };
315        if let Some(curtain) = get(object, "curtain") {
316            let values = curtain
317                .array()
318                .ok_or_else(|| ProjectError::Invalid("curtain must be an array".into()))?;
319            if values.len() < 2 {
320                return Err(ProjectError::Invalid("curtain needs two numbers".into()));
321            }
322            project.curtain = (float(&values[0])?, float(&values[1])?);
323        }
324        project.xo = object_or_empty(get(object, "xo"), "xo")?
325            .into_iter()
326            .map(|(key, value)| Ok((string(Some(&PyJson::Str(key)), ""), float(&value)?)))
327            .collect::<Result<_, ProjectError>>()?;
328        for (field, target) in [
329            ("bc", &mut project.bc),
330            ("mso", &mut project.mso),
331            ("verify", &mut project.verify),
332            ("tesira", &mut project.tesira),
333        ] {
334            if let Some(value) = get(object, field) {
335                *target = PyJson::Object(object_or_empty(Some(value), field)?);
336            }
337        }
338        for (field, target) in [
339            ("measurements", &mut project.measurements),
340            ("snapshots", &mut project.snapshots),
341        ] {
342            if let Some(value) = get(object, field) {
343                *target = PyJson::Array(array_or_empty(Some(value), field)?);
344            }
345        }
346        project.extra = object_or_empty(get(object, "extra"), "extra")?;
347        for (key, value) in object {
348            if !PROJECT_KEYS.contains(&key.as_str()) {
349                set(&mut project.extra, key, value.clone());
350            }
351        }
352        project.migrate_legacy()?;
353        project.loaded = REBUILT
354            .iter()
355            .filter_map(|(key, _)| {
356                let value = if *key == "calibration" {
357                    get(&project.extra, key)
358                } else {
359                    get(object, key)
360                };
361                value.map(|value| ((*key).to_owned(), value.clone()))
362            })
363            .collect();
364        let warnings = project.config.repair_output_numbers();
365        Ok((project, warnings))
366    }
367
368    /// `value` merged with the loaded value of `key`, if the file had one.
369    fn keep_unknown(&self, key: &str, value: PyJson) -> PyJson {
370        match (
371            REBUILT.iter().find(|(name, _)| *name == key),
372            get(&self.loaded, key),
373        ) {
374            (Some((_, shape)), Some(old)) => merge_with_file(value, old, shape),
375            _ => value,
376        }
377    }
378
379    pub fn to_json(&self) -> PyJson {
380        let mut out = self
381            .extra
382            .iter()
383            .filter(|(key, _)| !PROJECT_KEYS.contains(&key.as_str()))
384            .map(|(key, value)| (key.clone(), self.keep_unknown(key, value.clone())))
385            .collect::<Vec<_>>();
386        out.extend([
387            ("version".into(), PyJson::Int(self.version.to_string())),
388            ("name".into(), PyJson::Str(self.name.clone())),
389            (
390                "config".into(),
391                self.keep_unknown("config", self.config.to_json()),
392            ),
393            ("dsp".into(), self.keep_unknown("dsp", self.dsp.to_json())),
394            (
395                "target".into(),
396                self.keep_unknown("target", self.target.to_json()),
397            ),
398            (
399                "curtain".into(),
400                PyJson::Array(vec![
401                    PyJson::Float(self.curtain.0),
402                    PyJson::Float(self.curtain.1),
403                ]),
404            ),
405            (
406                "xo".into(),
407                PyJson::Object(
408                    self.xo
409                        .iter()
410                        .map(|(key, value)| (key.clone(), PyJson::Float(*value)))
411                        .collect(),
412                ),
413            ),
414            ("bc".into(), self.bc.clone()),
415            ("mso".into(), self.mso.clone()),
416            ("measurements".into(), self.measurements.clone()),
417            ("verify".into(), self.verify.clone()),
418            ("tesira".into(), self.tesira.clone()),
419            ("snapshots".into(), self.snapshots.clone()),
420        ]);
421        PyJson::Object(out)
422    }
423
424    pub fn load_text(text: &str) -> Result<Self, ProjectError> {
425        Self::from_json(loads(text)?)
426    }
427    pub fn save_text(&self) -> String {
428        let mut text = dumps(&self.to_json());
429        text.push('\n');
430        text
431    }
432
433    fn migrate_legacy(&mut self) -> Result<(), ProjectError> {
434        if self.bc.get("phase").and_then(PyJson::str) == Some("fir") {
435            set(
436                self.bc.object_mut().expect("bc is checked object"),
437                "phase",
438                PyJson::Str("minphase".into()),
439            );
440            set(&mut self.extra, "_migrated_fir_phase", PyJson::Bool(true));
441        }
442        if self.dsp.fir_taps_auto.is_none() || self.dsp.support_taps_auto.is_none() {
443            let phase = self
444                .bc
445                .get("phase")
446                .and_then(PyJson::str)
447                .unwrap_or("minphase");
448            let (channel, support) =
449                self.dsp
450                    .allocate_taps(&self.config, phase, self.bass_path_count() as i64, 256);
451            if self.dsp.fir_taps_auto.is_none() {
452                self.dsp.fir_taps_auto = Some(self.dsp.fir_taps == channel);
453            }
454            if self.dsp.support_taps_auto.is_none() {
455                self.dsp.support_taps_auto = Some(self.dsp.support_taps == support);
456            }
457        }
458        if let Some(object) = self.mso.object_mut() {
459            let focus = object
460                .iter()
461                .find(|(key, _)| key == "focus")
462                .map(|(_, value)| snap_focus(value))
463                .unwrap_or(50);
464            set(object, "focus", PyJson::Int(focus.to_string()));
465        }
466        if self.version < 2 {
467            self.xo = match migrate_group_keys(&PyJson::Object(
468                self.xo
469                    .iter()
470                    .map(|(key, value)| (key.clone(), PyJson::Float(*value)))
471                    .collect(),
472            )) {
473                PyJson::Object(values) => values
474                    .into_iter()
475                    .map(|(key, value)| Ok((key, float(&value)?)))
476                    .collect::<Result<_, ProjectError>>()?,
477                _ => unreachable!(),
478            };
479            if let Some(calibration) = self
480                .extra
481                .iter_mut()
482                .find(|(key, _)| key == "calibration")
483                .map(|(_, value)| value)
484                .and_then(PyJson::object_mut)
485            {
486                for key in ["xo", "xo_rec"] {
487                    if let Some((_, value)) = calibration.iter_mut().find(|(name, _)| name == key) {
488                        *value = migrate_group_keys(value);
489                    }
490                }
491            }
492        }
493        if self.version < 3
494            && let Some((_, calibration)) =
495                self.extra.iter_mut().find(|(key, _)| key == "calibration")
496        {
497            *calibration = migrate_calibration_keys(calibration);
498        }
499        if self.version < PROJECT_VERSION {
500            self.version = PROJECT_VERSION;
501        }
502        Ok(())
503    }
504
505    pub fn bass_path_count(&self) -> usize {
506        self.extra
507            .iter()
508            .find(|(key, _)| key == "bass")
509            .map(|(_, value)| value)
510            .and_then(|value| value.get("paths"))
511            .map_or(0, |paths| match paths {
512                PyJson::Array(value) => value.len(),
513                PyJson::Object(value) => value.len(),
514                _ => 0,
515            })
516    }
517
518    pub fn apply_recommended_taps(&mut self) -> bool {
519        let phase = if self.bc.get("phase").and_then(PyJson::str) == Some("mixed") {
520            "mixed"
521        } else {
522            "minphase"
523        };
524        let (channel, support) =
525            self.dsp
526                .allocate_taps(&self.config, phase, self.bass_path_count() as i64, 256);
527        let mut changed = false;
528        if self.dsp.fir_taps_auto == Some(true) && self.dsp.fir_taps != channel {
529            self.dsp.fir_taps = channel;
530            changed = true;
531        }
532        if self.dsp.support_taps_auto == Some(true) && self.dsp.support_taps != support {
533            self.dsp.support_taps = support;
534            changed = true;
535        }
536        changed
537    }
538
539    pub fn validate(&self) -> Vec<String> {
540        self.config.validate()
541    }
542}