Skip to main content

secs_project/
record.rs

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/// Calibration curves a measurement source sidecar attaches (ADR 0015).
22/// `meterCal` precedes every `scCal`; only entries with equal `freq` and
23/// `gain` lengths of at least two points are attached.
24#[derive(Debug, Clone, PartialEq)]
25pub struct AttachedCalibration<'a> {
26    /// The valid `meterCal` entry, when present.
27    pub meter: Option<&'a CalInfo>,
28    /// Valid `scCal` entries in sidecar order.
29    pub others: Vec<&'a CalInfo>,
30    /// Whether the `meterCal` identifies a C-weighted SPL meter.
31    pub c_weighted: bool,
32}
33
34impl AttachedCalibration<'_> {
35    /// Whether neither a curve nor C weighting is attached.
36    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
48/// Selects the sidecar calibration that ADR 0015 applies.
49pub 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
102/// FNV-1a 64 fingerprint of attached curves and the C-weighting flag.
103/// Returns `None` when the sidecar attaches no response correction.
104pub 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/// A removed position: its measurement entries (with their list indices)
197/// and the SNR values the store held for it.
198#[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    /// The measurement position that was removed.
207    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    /// Writes the IR and its source sidecar, enters the result into its
331    /// position (upstream `session.record`), and saves the project once.
332    ///
333    /// A result with a `source` also sets `source[role]` on the entry, where
334    /// upstream's importer (`step5.load_mdat`) sets it after recording: the
335    /// key goes last when the entry has none, and a value that is not an
336    /// object is replaced by one.
337    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    /// Removes a position's entries and SNR values; `false` if it had none.
429    pub fn clear_position(&mut self, pos: i64) -> bool {
430        self.remove_position(pos).is_some()
431    }
432
433    /// Takes a position's entries and SNR values out of the store, keeping
434    /// them for [`Self::restore_position`]. `None` if the position has no
435    /// entry. The project file is not written.
436    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    /// Puts a removed position back; fails if that position was recorded
463    /// again meanwhile. Each entry returns to its old list index (or the
464    /// end, if the list is shorter now). The project file is not written.
465    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    /// Returns a recorded IR's source sidecar when both files are readable and
529    /// confined to the project's `.irs` directory.
530    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    /// Rewrites a recorded IR's source sidecar and updates its in-memory
549    /// calibration fingerprint. The IR and measurement entry are unchanged.
550    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    /// How many times this store has written its project file. A test hook:
636    /// tests count saves with it.
637    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            // F5: neither the IR nor its sidecar may lead outside the `.irs`.
768            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                    // The cache slot and its key spend the budget too, so
779                    // sidecars that hold no source info cannot grow the cache
780                    // past it (audit #155 TD-112).
781                    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    /// Sample bytes the cache holds; never more than its cap.
933    pub fn retained_bytes(&self) -> usize {
934        self.bytes
935    }
936
937    /// Stores a copy unless it alone exceeds the cap, then evicts from the
938    /// least recently used end until the cache fits.
939    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}