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