1use crate::{
2 MAX_IR_BYTES, Project, ProjectError, PyJson, dumps_compact, ir_directory, ir_file, loads,
3 read_mono_float_wav, save_ir,
4};
5use secs_mdat::{Boxed, CalInfo, SourceDate, SourceInfo};
6use std::collections::{BTreeMap, BTreeSet, VecDeque};
7use std::fs;
8use std::path::{Component, Path, PathBuf};
9use std::time::UNIX_EPOCH;
10
11pub const IR_CACHE_LIMIT_BYTES: usize = 256 * 1024 * 1024;
12pub const MAX_PROJECT_BYTES: u64 = 64 * 1024 * 1024;
13
14#[derive(Debug, Clone, PartialEq)]
18pub struct AttachedCalibration<'a> {
19 pub meter: Option<&'a CalInfo>,
21 pub others: Vec<&'a CalInfo>,
23 pub c_weighted: bool,
25}
26
27impl AttachedCalibration<'_> {
28 pub fn is_empty(&self) -> bool {
30 self.meter.is_none() && self.others.is_empty() && !self.c_weighted
31 }
32}
33
34fn has_curve_arrays(cal: &CalInfo) -> bool {
35 matches!(
36 (&cal.freq, &cal.gain),
37 (Some(freq), Some(gain)) if freq.len() >= 2 && freq.len() == gain.len()
38 )
39}
40
41pub fn attached_calibration(info: &SourceInfo) -> AttachedCalibration<'_> {
43 let meter_entry = info.cal.iter().find(|(name, _)| name == "meterCal");
44 AttachedCalibration {
45 meter: info
46 .cal
47 .iter()
48 .find(|(name, cal)| name == "meterCal" && has_curve_arrays(cal))
49 .map(|(_, cal)| cal),
50 others: info
51 .cal
52 .iter()
53 .filter(|(name, cal)| name == "scCal" && has_curve_arrays(cal))
54 .map(|(_, cal)| cal)
55 .collect(),
56 c_weighted: meter_entry.is_some_and(|(_, cal)| cal.inverse_c_selected),
57 }
58}
59
60const FNV_OFFSET_BASIS_64: u64 = 0xcbf2_9ce4_8422_2325;
61const FNV_PRIME_64: u64 = 0x0000_0100_0000_01b3;
62
63fn fingerprint_bytes(hash: &mut u64, bytes: &[u8]) {
64 for &byte in bytes {
65 *hash ^= u64::from(byte);
66 *hash = hash.wrapping_mul(FNV_PRIME_64);
67 }
68}
69
70fn fingerprint_len(hash: &mut u64, len: usize) {
71 fingerprint_bytes(hash, &(len as u64).to_le_bytes());
72}
73
74fn fingerprint_array(hash: &mut u64, values: &[f32]) {
75 fingerprint_len(hash, values.len());
76 for value in values {
77 fingerprint_bytes(hash, &value.to_bits().to_le_bytes());
78 }
79}
80
81fn fingerprint_curve(hash: &mut u64, name: &str, cal: &CalInfo) {
82 fingerprint_len(hash, name.len());
83 fingerprint_bytes(hash, name.as_bytes());
84 fingerprint_array(hash, cal.freq.as_deref().unwrap_or_default());
85 fingerprint_array(hash, cal.gain.as_deref().unwrap_or_default());
86 match &cal.phase {
87 Some(phase) => {
88 fingerprint_bytes(hash, &[1]);
89 fingerprint_array(hash, phase);
90 }
91 None => fingerprint_bytes(hash, &[0]),
92 }
93}
94
95pub fn calibration_fingerprint(info: &SourceInfo) -> Option<u64> {
98 let attached = attached_calibration(info);
99 if attached.is_empty() {
100 return None;
101 }
102 let mut hash = FNV_OFFSET_BASIS_64;
103 fingerprint_bytes(&mut hash, &[u8::from(attached.c_weighted)]);
104 if let Some(meter) = attached.meter {
105 fingerprint_curve(&mut hash, "meterCal", meter);
106 }
107 for cal in attached.others {
108 fingerprint_curve(&mut hash, "scCal", cal);
109 }
110 Some(hash)
111}
112
113#[derive(Debug, Clone, PartialEq)]
114pub struct MeasurementResult {
115 pub role: String,
116 pub pos: i64,
117 pub ir: Vec<f32>,
118 pub sample_rate: u32,
119 pub t0_s: f64,
120 pub spl_db: Option<f64>,
121 pub spl_offset_db: Option<f64>,
122 pub snr_db: Option<f64>,
123 pub uuid: Option<String>,
124 pub source: Option<String>,
125 pub source_info: Option<SourceInfo>,
126}
127
128#[derive(Debug, Clone, PartialEq)]
129pub struct MeasurementRecord {
130 pub pos: i64,
131 pub label: String,
132 pub uuid: BTreeMap<String, Option<String>>,
133 pub mdat: Option<String>,
134 pub ir: BTreeMap<String, String>,
135 pub spl_db: BTreeMap<String, Option<f64>>,
136 pub t0: BTreeMap<String, f64>,
137 pub spl_offset: BTreeMap<String, f64>,
138 pub snr_db: BTreeMap<String, f64>,
139 pub source: BTreeMap<String, String>,
140 pub done: bool,
141 pub sample_rate: Option<u32>,
142}
143
144impl MeasurementRecord {
145 fn from_json(value: &PyJson) -> Option<Self> {
146 let object = value.object()?;
147 let get = |key: &str| {
148 object
149 .iter()
150 .find(|(name, _)| name == key)
151 .map(|(_, value)| value)
152 };
153 let pos = crate::project::integer(Some(get("pos")?), -1, "measurement.pos").ok()?;
154 Some(Self {
155 pos,
156 label: crate::project::string(get("label"), &format!("위치 {pos}")),
157 uuid: optional_string_map(get("uuid")),
158 mdat: get("mdat").and_then(PyJson::str).map(str::to_owned),
159 ir: string_map(get("ir")),
160 spl_db: optional_float_map(get("spl_db")),
161 t0: float_map(get("t0")),
162 spl_offset: float_map(get("spl_offset")),
163 snr_db: float_map(get("snr_db")),
164 source: string_map(get("source")),
165 done: get("done").and_then(PyJson::bool).unwrap_or(false),
166 sample_rate: get("sample_rate")
167 .and_then(|value| crate::project::integer(Some(value), 0, "sample_rate").ok())
168 .and_then(|value| u32::try_from(value).ok()),
169 })
170 }
171}
172
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct MigrationWarning {
175 pub code: &'static str,
176 pub entry: String,
177}
178
179#[derive(Debug)]
180pub struct MeasurementStore {
181 path: PathBuf,
182 project: Project,
183 snr_db: BTreeMap<(i64, String), f64>,
184 warnings: Vec<MigrationWarning>,
185 cache: IrCache,
186 saves: u64,
187}
188
189#[derive(Clone, Debug)]
192pub struct RemovedPosition {
193 pos: i64,
194 entries: Vec<(usize, PyJson)>,
195 snr_db: Vec<(String, f64)>,
196}
197
198impl RemovedPosition {
199 pub fn position(&self) -> i64 {
201 self.pos
202 }
203}
204
205impl MeasurementStore {
206 pub fn open(path: impl Into<PathBuf>) -> Result<Self, ProjectError> {
207 let path = path.into();
208 let bytes = crate::irs::read_bounded(&path, MAX_PROJECT_BYTES, "project")?;
209 let text = String::from_utf8(bytes).map_err(|error| {
210 ProjectError::Invalid(format!("project {} is not UTF-8: {error}", path.display()))
211 })?;
212 let (mut project, warnings) = Project::from_json_with_warnings(loads(&text)?)?;
213 for (pos, roles) in load_project_sources(&project, &path) {
214 for (role, info) in roles {
215 if let Some(fingerprint) = calibration_fingerprint(&info) {
216 project
217 .measurement_calibration
218 .insert((i64::from(pos), role), fingerprint);
219 }
220 }
221 }
222 let mut store = Self::new(path, project);
223 store.warnings.extend(warnings);
224 Ok(store)
225 }
226
227 pub fn new(path: impl Into<PathBuf>, project: Project) -> Self {
228 Self {
229 path: path.into(),
230 project,
231 snr_db: BTreeMap::new(),
232 warnings: Vec::new(),
233 cache: IrCache::default(),
234 saves: 0,
235 }
236 }
237
238 pub fn project(&self) -> &Project {
239 &self.project
240 }
241
242 pub fn project_mut(&mut self) -> &mut Project {
243 &mut self.project
244 }
245
246 pub fn records(&self) -> Vec<MeasurementRecord> {
247 measurement_values(&self.project)
248 .iter()
249 .filter_map(MeasurementRecord::from_json)
250 .map(|mut record| {
251 record.snr_db.extend(
252 self.snr_db
253 .iter()
254 .filter(|((pos, _), _)| *pos == record.pos)
255 .map(|((_, role), value)| (role.clone(), *value)),
256 );
257 record
258 })
259 .collect()
260 }
261
262 pub fn entry_for(&self, pos: i64) -> Option<MeasurementRecord> {
263 let mut record = measurement_values(&self.project)
264 .iter()
265 .find(|entry| pos_of(entry) == Some(pos))
266 .and_then(MeasurementRecord::from_json)?;
267 record.snr_db.extend(
268 self.snr_db
269 .iter()
270 .filter(|((entry_pos, _), _)| *entry_pos == pos)
271 .map(|((_, role), value)| (role.clone(), *value)),
272 );
273 Some(record)
274 }
275
276 pub fn done_positions(&self, roles: Option<&[String]>) -> Vec<i64> {
277 let needed = roles.map(|roles| roles.iter().map(String::as_str).collect::<BTreeSet<_>>());
278 let mut out = self
279 .records()
280 .into_iter()
281 .filter(|record| match &needed {
282 Some(needed) => needed
283 .iter()
284 .all(|role| record.ir.get(*role).is_some_and(|path| !path.is_empty())),
285 None => record.done,
286 })
287 .map(|record| record.pos)
288 .collect::<BTreeSet<_>>()
289 .into_iter()
290 .collect::<Vec<_>>();
291 out.sort_unstable();
292 out
293 }
294
295 pub fn pending_positions(&self, positions: &[i64], roles: &[String]) -> Vec<i64> {
296 let done = self
297 .done_positions(Some(roles))
298 .into_iter()
299 .collect::<BTreeSet<_>>();
300 positions
301 .iter()
302 .copied()
303 .filter(|pos| !done.contains(pos))
304 .collect()
305 }
306
307 pub fn label_for(&self, pos: i64) -> String {
308 format!("위치 {pos}")
309 }
310
311 pub fn start_position(&mut self, pos: i64) -> MeasurementRecord {
312 let entry = new_entry(pos, self.label_for(pos));
313 let records = measurement_values_mut(&mut self.project);
314 if let Some(index) = records.iter().position(|entry| pos_of(entry) == Some(pos)) {
315 records[index] = entry;
316 } else {
317 records.push(entry);
318 }
319 self.snr_db.retain(|(entry_pos, _), _| *entry_pos != pos);
320 self.entry_for(pos).expect("new entry has a valid position")
321 }
322
323 pub fn record(
331 &mut self,
332 result: MeasurementResult,
333 plan_roles: Option<&[String]>,
334 ) -> Result<MeasurementRecord, ProjectError> {
335 let source_fingerprint = result
336 .source_info
337 .as_ref()
338 .and_then(calibration_fingerprint);
339 let path = save_ir(
340 &self.path,
341 &result.role,
342 result.pos,
343 &result.ir,
344 result.sample_rate,
345 )?;
346 save_source(&path, result.source_info.as_ref())?;
347 self.cache.remember(&path, &result.ir, result.sample_rate)?;
348 let relative = relative_to_project(&self.path, &path);
349 let label = self.label_for(result.pos);
350 let role = result.role.clone();
351 let records = measurement_values_mut(&mut self.project);
352 let index = records
353 .iter()
354 .position(|entry| pos_of(entry) == Some(result.pos))
355 .unwrap_or_else(|| {
356 records.push(new_entry(result.pos, label.clone()));
357 records.len() - 1
358 });
359 let entry = records[index]
360 .object_mut()
361 .expect("new and matched measurement entries are objects");
362 repair_entry(entry, result.pos, label);
363 set_nested(
364 entry,
365 "uuid",
366 &role,
367 result.uuid.map_or(PyJson::Null, PyJson::Str),
368 );
369 set_nested(entry, "ir", &role, PyJson::Str(relative));
370 set_nested(
371 entry,
372 "spl_db",
373 &role,
374 result.spl_db.map_or(PyJson::Null, PyJson::Float),
375 );
376 set_nested(entry, "t0", &role, PyJson::Float(result.t0_s));
377 if let Some(value) = result.spl_offset_db {
378 setdefault_nested(entry, "spl_offset", &role, PyJson::Float(value));
379 }
380 set_value(
381 entry,
382 "sample_rate",
383 PyJson::Int(result.sample_rate.to_string()),
384 );
385 let done = plan_roles.map_or_else(
386 || nested_object(entry, "ir").is_some_and(|values| !values.is_empty()),
387 |roles| {
388 roles.iter().all(|role| {
389 nested_object(entry, "ir")
390 .and_then(|values| value_for(values, role))
391 .is_some_and(PyJson::is_truthy)
392 })
393 },
394 );
395 set_value(entry, "done", PyJson::Bool(done));
396 if let Some(source) = result.source {
397 if !value_for(entry, "source").is_some_and(|value| matches!(value, PyJson::Object(_))) {
398 set_value(entry, "source", PyJson::Object(Vec::new()));
399 }
400 set_nested(entry, "source", &role, PyJson::Str(source));
401 }
402 let calibration_key = (result.pos, role.clone());
403 if let Some(fingerprint) = source_fingerprint {
404 self.project
405 .measurement_calibration
406 .insert(calibration_key, fingerprint);
407 } else {
408 self.project
409 .measurement_calibration
410 .remove(&calibration_key);
411 }
412 if let Some(value) = result.snr_db {
413 self.snr_db.insert((result.pos, role), value);
414 }
415 self.save()?;
416 Ok(self
417 .entry_for(result.pos)
418 .expect("recorded entry has a valid position"))
419 }
420
421 pub fn clear_position(&mut self, pos: i64) -> bool {
423 self.remove_position(pos).is_some()
424 }
425
426 pub fn remove_position(&mut self, pos: i64) -> Option<RemovedPosition> {
430 let records = measurement_values_mut(&mut self.project);
431 let entries = records
432 .iter()
433 .enumerate()
434 .filter(|(_, entry)| pos_of_or_minus_one(entry) == pos)
435 .map(|(index, entry)| (index, entry.clone()))
436 .collect::<Vec<_>>();
437 if entries.is_empty() {
438 return None;
439 }
440 records.retain(|entry| pos_of_or_minus_one(entry) != pos);
441 let snr_db = self
442 .snr_db
443 .iter()
444 .filter(|((entry_pos, _), _)| *entry_pos == pos)
445 .map(|((_, role), value)| (role.clone(), *value))
446 .collect();
447 self.snr_db.retain(|(entry_pos, _), _| *entry_pos != pos);
448 Some(RemovedPosition {
449 pos,
450 entries,
451 snr_db,
452 })
453 }
454
455 pub fn restore_position(&mut self, removed: RemovedPosition) -> Result<(), ProjectError> {
459 let RemovedPosition {
460 pos,
461 entries,
462 snr_db,
463 } = removed;
464 if measurement_values(&self.project)
465 .iter()
466 .any(|entry| pos_of_or_minus_one(entry) == pos)
467 {
468 return Err(ProjectError::Invalid(format!(
469 "position {pos} was recorded again"
470 )));
471 }
472 let records = measurement_values_mut(&mut self.project);
473 for (index, entry) in entries {
474 records.insert(index.min(records.len()), entry);
475 }
476 self.snr_db
477 .extend(snr_db.into_iter().map(|(role, value)| ((pos, role), value)));
478 Ok(())
479 }
480
481 pub fn ir_path_of(&mut self, role: &str, pos: i64) -> Result<PathBuf, ProjectError> {
482 let raw = self
483 .entry_for(pos)
484 .and_then(|entry| entry.ir.get(role).cloned())
485 .filter(|path| !path.is_empty());
486 let candidate = raw.as_ref().map_or_else(
487 || ir_file(&self.path, role, pos),
488 |raw| {
489 let raw = PathBuf::from(raw);
490 if raw.is_absolute() {
491 raw
492 } else {
493 self.path
494 .parent()
495 .unwrap_or_else(|| Path::new("."))
496 .join(raw)
497 }
498 },
499 );
500 if confined_ir_path(&self.path, &candidate).is_err() {
501 let entry = format!("position {pos}, role {role}");
502 if !self.warnings.iter().any(|warning| warning.entry == entry) {
503 self.warnings.push(MigrationWarning {
504 code: "measurement.ir_path.outside",
505 entry: entry.clone(),
506 });
507 }
508 return Err(ProjectError::IrPath {
509 entry,
510 path: candidate,
511 });
512 }
513 Ok(candidate)
514 }
515
516 pub fn ir_of(&mut self, role: &str, pos: i64) -> Result<(Vec<f32>, u32), ProjectError> {
517 let path = self.ir_path_of(role, pos)?;
518 self.cache.load(&path)
519 }
520
521 pub fn source_of(&mut self, role: &str, pos: i64) -> Option<SourceInfo> {
524 let recorded = self
525 .entry_for(pos)
526 .and_then(|entry| entry.ir.get(role).cloned())
527 .is_some_and(|path| !path.is_empty());
528 if !recorded {
529 return None;
530 }
531 let path = self.ir_path_of(role, pos).ok()?;
532 if !path.is_file()
533 || confined_ir_path(&self.path, &path).is_err()
534 || confined_ir_path(&self.path, &source_path(&path)).is_err()
535 {
536 return None;
537 }
538 load_source(&path)
539 }
540
541 pub fn replace_source(
544 &mut self,
545 role: &str,
546 pos: i64,
547 info: Option<&SourceInfo>,
548 ) -> Result<(), ProjectError> {
549 let recorded = self
550 .entry_for(pos)
551 .and_then(|entry| entry.ir.get(role).cloned())
552 .is_some_and(|path| !path.is_empty());
553 if !recorded {
554 return Err(ProjectError::Invalid(format!(
555 "position {pos}, role {role} has no recorded IR"
556 )));
557 }
558 let path = self.ir_path_of(role, pos)?;
559 if !path.is_file() {
560 return Err(ProjectError::Invalid(format!(
561 "recorded IR does not exist: {}",
562 path.display()
563 )));
564 }
565 if confined_ir_path(&self.path, &source_path(&path)).is_err() {
566 return Err(ProjectError::IrPath {
567 entry: format!("position {pos}, role {role} source"),
568 path: source_path(&path),
569 });
570 }
571 save_source(&path, info)?;
572 let key = (pos, role.to_owned());
573 if let Some(fingerprint) = info.and_then(calibration_fingerprint) {
574 self.project
575 .measurement_calibration
576 .insert(key, fingerprint);
577 } else {
578 self.project.measurement_calibration.remove(&key);
579 }
580 Ok(())
581 }
582
583 pub fn t0_of(&self, role: &str, pos: i64) -> f64 {
584 self.entry_for(pos)
585 .and_then(|entry| entry.t0.get(role).copied())
586 .unwrap_or(0.0)
587 }
588
589 pub fn t0_by_role(&self, pos: i64) -> BTreeMap<String, f64> {
590 self.entry_for(pos).map_or_else(BTreeMap::new, |entry| {
591 entry
592 .ir
593 .iter()
594 .filter(|(_, path)| !path.is_empty())
595 .map(|(role, _)| (role.clone(), entry.t0.get(role).copied().unwrap_or(0.0)))
596 .collect()
597 })
598 }
599
600 pub fn irs_by_role(
601 &mut self,
602 pos: i64,
603 ) -> Result<BTreeMap<String, (Vec<f32>, u32)>, ProjectError> {
604 let roles = self.entry_for(pos).map_or_else(Vec::new, |entry| {
605 entry
606 .ir
607 .iter()
608 .filter(|(_, path)| !path.is_empty())
609 .map(|(role, _)| role.clone())
610 .collect()
611 });
612 roles
613 .into_iter()
614 .map(|role| self.ir_of(&role, pos).map(|value| (role, value)))
615 .collect()
616 }
617
618 pub fn warnings(&self) -> &[MigrationWarning] {
619 &self.warnings
620 }
621
622 pub fn save(&mut self) -> Result<(), ProjectError> {
623 atomic_write(&self.path, self.project.save_text().as_bytes())?;
624 self.saves += 1;
625 Ok(())
626 }
627
628 pub fn save_count(&self) -> u64 {
631 self.saves
632 }
633}
634
635#[derive(Debug, Clone, PartialEq)]
636pub enum LoadedIr {
637 Samples(Vec<f32>, u32),
638 Timed(Vec<f32>, u32, f64),
639}
640
641pub fn source_path(wav: &Path) -> PathBuf {
642 wav.with_extension("src.json")
643}
644
645pub fn save_source(wav: &Path, info: Option<&SourceInfo>) -> Result<(), ProjectError> {
646 let path = source_path(wav);
647 let Some(info) = info else {
648 match fs::remove_file(path) {
649 Ok(()) => {}
650 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
651 Err(error) => return Err(error.into()),
652 }
653 return Ok(());
654 };
655 atomic_write(&path, dumps_compact(&source_to_json(info)).as_bytes())
656}
657
658pub fn load_source(wav: &Path) -> Option<SourceInfo> {
659 let text = fs::read_to_string(source_path(wav)).ok()?;
660 let value = loads(&text).ok()?;
661 source_from_json(&value)
662}
663
664pub fn load_project_sources(
665 project: &Project,
666 project_path: &Path,
667) -> BTreeMap<u32, BTreeMap<String, SourceInfo>> {
668 let base = project_path.parent().unwrap_or_else(|| Path::new("."));
669 let mut out = BTreeMap::new();
670 for record in measurement_values(project)
671 .iter()
672 .filter_map(MeasurementRecord::from_json)
673 {
674 let Ok(pos) = u32::try_from(record.pos) else {
675 continue;
676 };
677 for (role, raw) in record.ir {
678 if raw.is_empty() {
679 continue;
680 }
681 let path = PathBuf::from(raw);
682 let path = if path.is_absolute() {
683 path
684 } else {
685 base.join(path)
686 };
687 if !path.is_file()
689 || confined_ir_path(project_path, &path).is_err()
690 || confined_ir_path(project_path, &source_path(&path)).is_err()
691 {
692 continue;
693 }
694 if let Some(info) = load_source(&path) {
695 out.entry(pos)
696 .or_insert_with(BTreeMap::new)
697 .insert(role, info);
698 }
699 }
700 }
701 out
702}
703
704pub fn load_project_irs(
705 project: &Project,
706 project_path: &Path,
707 cache: &mut IrCache,
708) -> Result<BTreeMap<i64, BTreeMap<String, LoadedIr>>, ProjectError> {
709 let mut store = MeasurementStore::new(project_path, project.clone());
710 std::mem::swap(&mut store.cache, cache);
711 let mut out = BTreeMap::new();
712 let positions = store
713 .records()
714 .iter()
715 .map(|record| record.pos)
716 .collect::<Vec<_>>();
717 for pos in positions {
718 let roles = store.entry_for(pos).map_or_else(Vec::new, |entry| {
719 entry
720 .ir
721 .iter()
722 .filter(|(_, path)| !path.is_empty())
723 .map(|(role, _)| role.clone())
724 .collect()
725 });
726 for role in roles {
727 let (samples, rate) = store.ir_of(&role, pos)?;
728 let t0 = store.t0_of(&role, pos);
729 let value = if t0 == 0.0 {
730 LoadedIr::Samples(samples, rate)
731 } else {
732 LoadedIr::Timed(samples, rate, t0)
733 };
734 out.entry(pos)
735 .or_insert_with(BTreeMap::new)
736 .insert(role, value);
737 }
738 }
739 std::mem::swap(&mut store.cache, cache);
740 Ok(out)
741}
742
743#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
744struct CacheKey {
745 path: PathBuf,
746 modified_ns: u128,
747 size: u64,
748}
749
750#[derive(Debug, Clone)]
751struct CacheValue {
752 samples: Vec<f32>,
753 sample_rate: u32,
754}
755
756#[derive(Debug)]
757pub struct IrCache {
758 entries: BTreeMap<CacheKey, CacheValue>,
759 order: VecDeque<CacheKey>,
760 bytes: usize,
761 cap: usize,
762}
763
764impl Default for IrCache {
765 fn default() -> Self {
766 Self::new(IR_CACHE_LIMIT_BYTES)
767 }
768}
769
770impl IrCache {
771 pub fn new(cap: usize) -> Self {
772 Self {
773 entries: BTreeMap::new(),
774 order: VecDeque::new(),
775 bytes: 0,
776 cap,
777 }
778 }
779
780 pub fn load(&mut self, path: &Path) -> Result<(Vec<f32>, u32), ProjectError> {
781 let key = cache_key(path)?;
782 self.remove_old_versions(&key.path, Some(&key));
783 if let Some(value) = self.entries.get(&key) {
784 let hit = (value.samples.clone(), value.sample_rate);
785 if let Some(index) = self.order.iter().position(|old_key| old_key == &key) {
786 let touched = self.order.remove(index).expect("index from position");
787 self.order.push_back(touched);
788 }
789 return Ok(hit);
790 }
791 let (samples, sample_rate) =
792 read_mono_float_wav(&crate::irs::read_bounded(path, MAX_IR_BYTES, "WAV")?)
793 .map_err(|error| ProjectError::Wav(format!("{}: {error}", path.display())))?;
794 self.insert(key, &samples, sample_rate);
795 Ok((samples, sample_rate))
796 }
797
798 pub fn remember(
799 &mut self,
800 path: &Path,
801 samples: &[f32],
802 sample_rate: u32,
803 ) -> Result<(), ProjectError> {
804 let key = cache_key(path)?;
805 self.remove_old_versions(&key.path, Some(&key));
806 self.insert(key, samples, sample_rate);
807 Ok(())
808 }
809
810 pub fn len(&self) -> usize {
811 self.entries.len()
812 }
813
814 pub fn is_empty(&self) -> bool {
815 self.entries.is_empty()
816 }
817
818 pub fn retained_bytes(&self) -> usize {
820 self.bytes
821 }
822
823 fn insert(&mut self, key: CacheKey, samples: &[f32], sample_rate: u32) {
826 let size = samples.len().saturating_mul(std::mem::size_of::<f32>());
827 if let Some(old) = self.entries.remove(&key) {
828 self.bytes = self
829 .bytes
830 .saturating_sub(old.samples.len().saturating_mul(4));
831 self.order.retain(|old_key| old_key != &key);
832 }
833 if size > self.cap {
834 return;
835 }
836 self.bytes = self.bytes.saturating_add(size);
837 self.order.push_back(key.clone());
838 self.entries.insert(
839 key,
840 CacheValue {
841 samples: samples.to_vec(),
842 sample_rate,
843 },
844 );
845 while self.bytes > self.cap
846 && let Some(old_key) = self.order.pop_front()
847 {
848 if let Some(old) = self.entries.remove(&old_key) {
849 self.bytes = self
850 .bytes
851 .saturating_sub(old.samples.len().saturating_mul(4));
852 }
853 }
854 }
855
856 fn remove_old_versions(&mut self, path: &Path, keep: Option<&CacheKey>) {
857 let old = self
858 .entries
859 .keys()
860 .filter(|key| key.path == path && keep != Some(*key))
861 .cloned()
862 .collect::<Vec<_>>();
863 for key in old {
864 if let Some(value) = self.entries.remove(&key) {
865 self.bytes = self
866 .bytes
867 .saturating_sub(value.samples.len().saturating_mul(4));
868 }
869 self.order.retain(|old_key| old_key != &key);
870 }
871 }
872}
873
874const SOURCE_STRING_FIELDS: [&str; 7] = [
875 "outputName",
876 "sourceFileName",
877 "timingRefName",
878 "osName",
879 "osVersion",
880 "jre",
881 "sigGenLevelSt",
882];
883const SOURCE_BOXED_FIELDS: [&str; 8] = [
884 "inputVolume",
885 "outputVolume",
886 "micSensitivity",
887 "splCalOffset",
888 "dBVInputOffset",
889 "dBVOutputOffset",
890 "usesEmbeddedCalData",
891 "calDataLimitApplied",
892];
893const SOURCE_CAL_FIELDS: [&str; 2] = ["meterCal", "scCal"];
894const SOURCE_CAL_BOXED: [&str; 4] = ["sensitivitydB", "sensitivitydBFS", "serialNum", "revision"];
895
896fn source_to_json(info: &SourceInfo) -> PyJson {
897 let mut fields = vec![
898 (
899 "strings".into(),
900 PyJson::Object(
901 info.strings
902 .iter()
903 .map(|(name, value)| (name.clone(), PyJson::Str(value.clone())))
904 .collect(),
905 ),
906 ),
907 (
908 "boxed".into(),
909 PyJson::Object(
910 info.boxed
911 .iter()
912 .filter(|(_, value)| boxed_is_serializable(*value))
913 .map(|(name, value)| (name.clone(), boxed_to_json(*value)))
914 .collect(),
915 ),
916 ),
917 (
918 "cal".into(),
919 PyJson::Object(
920 info.cal
921 .iter()
922 .map(|(name, cal)| (name.clone(), cal_to_json(cal)))
923 .collect(),
924 ),
925 ),
926 ];
927 if let Some(date) = info.date.and_then(serializable_date) {
928 fields.push(("date".into(), date));
929 }
930 if let Some(notes) = &info.notes {
931 fields.push(("notes".into(), PyJson::Str(notes.clone())));
932 }
933 if let Some(format) = &info.format {
934 fields.push(("format".into(), PyJson::Str(format.clone())));
935 }
936 PyJson::Object(fields)
937}
938
939fn boxed_is_serializable(value: Boxed) -> bool {
940 match value {
941 Boxed::Double(value) => value.is_finite(),
942 Boxed::Integer(value) => i32::try_from(value).is_ok(),
943 Boxed::Boolean(_) => true,
944 }
945}
946
947fn serializable_date(value: SourceDate) -> Option<PyJson> {
948 match value {
949 SourceDate::Int(value) => Some(PyJson::Int(value.to_string())),
950 SourceDate::Float(value) if value.is_finite() => Some(PyJson::Float(value)),
951 SourceDate::Float(_) => None,
952 }
953}
954
955fn boxed_to_json(value: Boxed) -> PyJson {
956 let (kind, value) = match value {
957 Boxed::Double(value) => ("java.lang.Double", PyJson::Float(value)),
958 Boxed::Integer(value) => ("java.lang.Integer", PyJson::Int(value.to_string())),
959 Boxed::Boolean(value) => ("java.lang.Boolean", PyJson::Bool(value)),
960 };
961 PyJson::Object(vec![
962 ("type".into(), PyJson::Str(kind.into())),
963 ("value".into(), value),
964 ])
965}
966
967fn cal_to_json(cal: &CalInfo) -> PyJson {
968 let mut fields = vec![
969 (
970 "inverseCSelected".into(),
971 PyJson::Bool(cal.inverse_c_selected),
972 ),
973 ("sourceName".into(), PyJson::Str(cal.source_name.clone())),
974 ("sourcePath".into(), PyJson::Str(cal.source_path.clone())),
975 ("freqArray".into(), float_array_to_json(cal.freq.as_deref())),
976 ("gainArray".into(), float_array_to_json(cal.gain.as_deref())),
977 (
978 "phaseArray".into(),
979 float_array_to_json(cal.phase.as_deref()),
980 ),
981 ];
982 fields.extend(
983 cal.boxed
984 .iter()
985 .filter(|(_, value)| boxed_is_serializable(*value))
986 .map(|(name, value)| (name.clone(), boxed_to_json(*value))),
987 );
988 PyJson::Object(fields)
989}
990
991fn float_array_to_json(values: Option<&[f32]>) -> PyJson {
992 values.map_or(PyJson::Null, |values| {
993 PyJson::Array(
994 values
995 .iter()
996 .map(|value| PyJson::Float(f64::from(*value)))
997 .collect(),
998 )
999 })
1000}
1001
1002fn source_from_json(value: &PyJson) -> Option<SourceInfo> {
1003 let object = value.object()?;
1004 let mut info = SourceInfo::default();
1005 if let Some(strings) = value_for(object, "strings").and_then(PyJson::object) {
1006 for (name, value) in strings {
1007 if SOURCE_STRING_FIELDS.contains(&name.as_str()) && !matches!(value, PyJson::Null) {
1008 info.strings.push((name.clone(), python_str(value)));
1009 }
1010 }
1011 }
1012 if let Some(boxed) = value_for(object, "boxed").and_then(PyJson::object) {
1013 for (name, value) in boxed {
1014 if SOURCE_BOXED_FIELDS.contains(&name.as_str())
1015 && let Some(value) = boxed_from_json(value)
1016 {
1017 info.boxed.push((name.clone(), value));
1018 }
1019 }
1020 }
1021 if let Some(calibrations) = value_for(object, "cal").and_then(PyJson::object) {
1022 for (name, value) in calibrations {
1023 if !SOURCE_CAL_FIELDS.contains(&name.as_str()) {
1024 continue;
1025 }
1026 let Some(cal) = value.object() else {
1027 continue;
1028 };
1029 let mut boxed = Vec::new();
1030 for key in SOURCE_CAL_BOXED {
1031 if let Some(value) = value_for(cal, key).and_then(boxed_from_json) {
1032 boxed.push((key.into(), value));
1033 }
1034 }
1035 info.cal.push((
1036 name.clone(),
1037 CalInfo {
1038 inverse_c_selected: value_for(cal, "inverseCSelected")
1039 .is_some_and(PyJson::is_truthy),
1040 source_name: value_for(cal, "sourceName")
1041 .filter(|value| value.is_truthy())
1042 .map_or_else(String::new, python_str),
1043 source_path: value_for(cal, "sourcePath")
1044 .filter(|value| value.is_truthy())
1045 .map_or_else(String::new, python_str),
1046 freq: value_for(cal, "freqArray").and_then(float_array_from_json),
1047 gain: value_for(cal, "gainArray").and_then(float_array_from_json),
1048 phase: value_for(cal, "phaseArray").and_then(float_array_from_json),
1049 boxed,
1050 },
1051 ));
1052 }
1053 }
1054 info.date = value_for(object, "date").and_then(date_from_json);
1055 info.notes = value_for(object, "notes")
1056 .filter(|value| value.is_truthy())
1057 .map(python_str);
1058 info.format = value_for(object, "format")
1059 .filter(|value| value.is_truthy())
1060 .map(python_str);
1061 Some(info)
1062}
1063
1064fn boxed_from_json(value: &PyJson) -> Option<Boxed> {
1065 let object = value.object()?;
1066 let kind = value_for(object, "type")?.str()?;
1067 let raw = value_for(object, "value").filter(|value| !matches!(value, PyJson::Null))?;
1068 match kind {
1069 "java.lang.Double" => python_float_value(raw)
1070 .filter(|value| value.is_finite())
1071 .map(Boxed::Double),
1072 "java.lang.Integer" => python_int(raw)
1073 .filter(|value| i32::try_from(*value).is_ok())
1074 .map(Boxed::Integer),
1075 "java.lang.Boolean" => Some(Boxed::Boolean(raw.is_truthy())),
1076 _ => None,
1077 }
1078}
1079
1080fn date_from_json(value: &PyJson) -> Option<SourceDate> {
1081 match value {
1082 PyJson::Bool(value) => Some(SourceDate::Int(i64::from(*value))),
1083 PyJson::Int(value) => value.parse().ok().map(SourceDate::Int),
1084 PyJson::Float(value) if value.is_finite() => Some(SourceDate::Float(*value)),
1085 _ => None,
1086 }
1087}
1088
1089fn float_array_from_json(value: &PyJson) -> Option<Vec<f32>> {
1090 value
1091 .array()?
1092 .iter()
1093 .map(|value| match value {
1094 PyJson::Bool(value) => Some(if *value { 1.0 } else { 0.0 }),
1095 PyJson::Int(value) => value.parse().ok(),
1096 PyJson::Float(value) => Some(*value),
1097 PyJson::Str(value) => value.trim().parse().ok(),
1098 _ => None,
1099 })
1100 .map(|value: Option<f64>| {
1101 value
1102 .filter(|value| value.is_finite())
1103 .map(|value| value as f32)
1104 .filter(|value| value.is_finite())
1105 })
1106 .collect()
1107}
1108
1109fn python_float_value(value: &PyJson) -> Option<f64> {
1110 match value {
1111 PyJson::Bool(value) => Some(if *value { 1.0 } else { 0.0 }),
1112 PyJson::Int(value) => value.parse().ok(),
1113 PyJson::Float(value) => Some(*value),
1114 PyJson::Str(value) => value.trim().parse().ok(),
1115 _ => None,
1116 }
1117}
1118
1119fn python_int(value: &PyJson) -> Option<i64> {
1120 match value {
1121 PyJson::Bool(value) => Some(i64::from(*value)),
1122 PyJson::Int(value) => value.parse().ok(),
1123 PyJson::Float(value) if value.is_finite() => {
1124 let value = value.trunc();
1125 (value >= i64::MIN as f64 && value < i64::MAX as f64).then_some(value as i64)
1126 }
1127 PyJson::Str(value) => value.trim().parse().ok(),
1128 _ => None,
1129 }
1130}
1131
1132fn python_str(value: &PyJson) -> String {
1133 match value {
1134 PyJson::Null => "None".into(),
1135 PyJson::Bool(value) => if *value { "True" } else { "False" }.into(),
1136 PyJson::Int(value) => value.clone(),
1137 PyJson::Float(value) => {
1138 let text = dumps_compact(&PyJson::Float(*value));
1139 if text == "Infinity" {
1140 "inf".into()
1141 } else if text == "-Infinity" {
1142 "-inf".into()
1143 } else if text == "NaN" {
1144 "nan".into()
1145 } else {
1146 text
1147 }
1148 }
1149 PyJson::Str(value) => value.clone(),
1150 PyJson::Array(values) => format!(
1151 "[{}]",
1152 values
1153 .iter()
1154 .map(python_repr)
1155 .collect::<Vec<_>>()
1156 .join(", ")
1157 ),
1158 PyJson::Object(values) => format!(
1159 "{{{}}}",
1160 values
1161 .iter()
1162 .map(|(key, value)| format!(
1163 "{}: {}",
1164 python_repr(&PyJson::Str(key.clone())),
1165 python_repr(value)
1166 ))
1167 .collect::<Vec<_>>()
1168 .join(", ")
1169 ),
1170 }
1171}
1172
1173fn python_repr(value: &PyJson) -> String {
1174 match value {
1175 PyJson::Str(value) => format!("'{}'", value.replace('\\', "\\\\").replace('\'', "\\'")),
1176 _ => python_str(value),
1177 }
1178}
1179
1180fn measurement_values(project: &Project) -> &[PyJson] {
1181 match &project.measurements {
1182 PyJson::Array(records) => records,
1183 _ => &[],
1184 }
1185}
1186
1187fn measurement_values_mut(project: &mut Project) -> &mut Vec<PyJson> {
1188 if !matches!(project.measurements, PyJson::Array(_)) {
1189 project.measurements = PyJson::Array(Vec::new());
1190 }
1191 match &mut project.measurements {
1192 PyJson::Array(records) => records,
1193 _ => unreachable!("measurements was replaced with an array"),
1194 }
1195}
1196
1197fn pos_of(value: &PyJson) -> Option<i64> {
1198 crate::project::integer(Some(value.get("pos")?), -1, "measurement.pos").ok()
1199}
1200
1201fn pos_of_or_minus_one(value: &PyJson) -> i64 {
1202 let Some(object) = value.object() else {
1203 return -1;
1204 };
1205 let Some(value) = value_for(object, "pos") else {
1206 return -1;
1207 };
1208 crate::project::integer(Some(value), -1, "measurement.pos").unwrap_or(-1)
1209}
1210
1211fn new_entry(pos: i64, label: String) -> PyJson {
1212 PyJson::Object(vec![
1213 ("pos".into(), PyJson::Int(pos.to_string())),
1214 ("label".into(), PyJson::Str(label)),
1215 ("uuid".into(), PyJson::Object(Vec::new())),
1216 ("mdat".into(), PyJson::Null),
1217 ("ir".into(), PyJson::Object(Vec::new())),
1218 ("spl_db".into(), PyJson::Object(Vec::new())),
1219 ("t0".into(), PyJson::Object(Vec::new())),
1220 ("done".into(), PyJson::Bool(false)),
1221 ("sample_rate".into(), PyJson::Null),
1222 ])
1223}
1224
1225fn repair_entry(object: &mut Vec<(String, PyJson)>, pos: i64, label: String) {
1226 set_value(object, "pos", PyJson::Int(pos.to_string()));
1227 setdefault_value(object, "label", PyJson::Str(label));
1228 setdefault_value(object, "mdat", PyJson::Null);
1229 for key in ["uuid", "ir", "spl_db", "t0"] {
1230 if !value_for(object, key).is_some_and(|value| matches!(value, PyJson::Object(_))) {
1231 set_value(object, key, PyJson::Object(Vec::new()));
1232 }
1233 }
1234 if value_for(object, "spl_offset").is_some_and(|value| !matches!(value, PyJson::Object(_))) {
1235 set_value(object, "spl_offset", PyJson::Object(Vec::new()));
1236 }
1237 setdefault_value(object, "done", PyJson::Bool(false));
1238 setdefault_value(object, "sample_rate", PyJson::Null);
1239}
1240
1241fn value_for<'a>(object: &'a [(String, PyJson)], key: &str) -> Option<&'a PyJson> {
1242 object
1243 .iter()
1244 .find(|(name, _)| name == key)
1245 .map(|(_, value)| value)
1246}
1247
1248fn value_for_mut<'a>(object: &'a mut [(String, PyJson)], key: &str) -> Option<&'a mut PyJson> {
1249 object
1250 .iter_mut()
1251 .find(|(name, _)| name == key)
1252 .map(|(_, value)| value)
1253}
1254
1255fn set_value(object: &mut Vec<(String, PyJson)>, key: &str, value: PyJson) {
1256 if let Some(old) = value_for_mut(object, key) {
1257 *old = value;
1258 } else {
1259 object.push((key.into(), value));
1260 }
1261}
1262
1263fn setdefault_value(object: &mut Vec<(String, PyJson)>, key: &str, value: PyJson) {
1264 if value_for(object, key).is_none() {
1265 object.push((key.into(), value));
1266 }
1267}
1268
1269fn nested_object<'a>(object: &'a [(String, PyJson)], key: &str) -> Option<&'a [(String, PyJson)]> {
1270 value_for(object, key).and_then(PyJson::object)
1271}
1272
1273fn set_nested(object: &mut [(String, PyJson)], key: &str, role: &str, value: PyJson) {
1274 let nested = value_for_mut(object, key)
1275 .and_then(PyJson::object_mut)
1276 .expect("repair_entry made the field an object");
1277 set_value(nested, role, value);
1278}
1279
1280fn setdefault_nested(object: &mut Vec<(String, PyJson)>, key: &str, role: &str, value: PyJson) {
1281 setdefault_value(object, key, PyJson::Object(Vec::new()));
1282 let nested = value_for_mut(object, key)
1283 .and_then(PyJson::object_mut)
1284 .expect("upstream setdefault requires an object");
1285 set_value(nested, role, value);
1286}
1287
1288fn cache_key(path: &Path) -> Result<CacheKey, ProjectError> {
1289 let metadata = fs::metadata(path)?;
1290 let modified_ns = metadata
1291 .modified()?
1292 .duration_since(UNIX_EPOCH)
1293 .unwrap_or_default()
1294 .as_nanos();
1295 Ok(CacheKey {
1296 path: path.canonicalize()?,
1297 modified_ns,
1298 size: metadata.len(),
1299 })
1300}
1301
1302pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), ProjectError> {
1303 let parent = path.parent().unwrap_or_else(|| Path::new("."));
1304 fs::create_dir_all(parent)?;
1305 secs_types::fs::write_atomic_bytes(path, data)?;
1306 Ok(())
1307}
1308
1309fn confined_ir_path(project: &Path, candidate: &Path) -> Result<(), ()> {
1310 if candidate
1311 .components()
1312 .any(|component| component == Component::ParentDir)
1313 {
1314 return Err(());
1315 }
1316 let root = ir_directory(project);
1317 let root_canonical = canonicalize_existing_ancestor(&root)?;
1318 let candidate_canonical = canonicalize_existing_ancestor(candidate)?;
1319 if candidate_canonical.starts_with(root_canonical) {
1320 Ok(())
1321 } else {
1322 Err(())
1323 }
1324}
1325
1326fn canonicalize_existing_ancestor(path: &Path) -> Result<PathBuf, ()> {
1327 let mut existing = path;
1328 let mut missing = Vec::new();
1329 while !existing.exists() {
1330 let name = existing.file_name().ok_or(())?;
1331 missing.push(name.to_owned());
1332 existing = existing.parent().ok_or(())?;
1333 }
1334 let mut canonical = existing.canonicalize().map_err(|_| ())?;
1335 for name in missing.into_iter().rev() {
1336 canonical.push(name);
1337 }
1338 Ok(canonical)
1339}
1340
1341fn relative_to_project(project: &Path, path: &Path) -> String {
1342 path.strip_prefix(project.parent().unwrap_or_else(|| Path::new(".")))
1343 .unwrap_or(path)
1344 .to_string_lossy()
1345 .replace('\\', "/")
1346}
1347
1348fn string_map(value: Option<&PyJson>) -> BTreeMap<String, String> {
1349 value
1350 .and_then(PyJson::object)
1351 .map_or_else(BTreeMap::new, |object| {
1352 object
1353 .iter()
1354 .filter_map(|(key, value)| value.str().map(|value| (key.clone(), value.into())))
1355 .collect()
1356 })
1357}
1358
1359fn optional_string_map(value: Option<&PyJson>) -> BTreeMap<String, Option<String>> {
1360 value
1361 .and_then(PyJson::object)
1362 .map_or_else(BTreeMap::new, |object| {
1363 object
1364 .iter()
1365 .map(|(key, value)| (key.clone(), value.str().map(str::to_owned)))
1366 .collect()
1367 })
1368}
1369
1370fn float_map(value: Option<&PyJson>) -> BTreeMap<String, f64> {
1371 value
1372 .and_then(PyJson::object)
1373 .map_or_else(BTreeMap::new, |object| {
1374 object
1375 .iter()
1376 .filter_map(|(key, value)| {
1377 crate::project::float(value)
1378 .ok()
1379 .map(|value| (key.clone(), value))
1380 })
1381 .collect()
1382 })
1383}
1384
1385fn optional_float_map(value: Option<&PyJson>) -> BTreeMap<String, Option<f64>> {
1386 value
1387 .and_then(PyJson::object)
1388 .map_or_else(BTreeMap::new, |object| {
1389 object
1390 .iter()
1391 .map(|(key, value)| {
1392 (
1393 key.clone(),
1394 if matches!(value, PyJson::Null) {
1395 None
1396 } else {
1397 crate::project::float(value).ok()
1398 },
1399 )
1400 })
1401 .collect()
1402 })
1403}