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