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 pub measurement_calibration: std::collections::BTreeMap<(i64, String), u64>,
219 pub loaded: Vec<(String, PyJson)>,
225}
226
227const 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 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}