Skip to main content

secs_project/
signature.rs

1use crate::{Project, PyJson};
2use secs_types::role::{RoleKind, role_kind};
3use std::cmp::Ordering;
4
5#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
6pub struct MeasurementSignatureEntry {
7    pub pos: String,
8    pub roles: Vec<String>,
9    pub t0: Vec<(String, String)>,
10}
11
12#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
13pub struct FloatKey(pub f64);
14
15pub fn measured_positions(project: &Project) -> Vec<i64> {
16    let mut out = project
17        .measurements
18        .array()
19        .unwrap_or(&[])
20        .iter()
21        .filter_map(|record| {
22            let ir = record.get("ir")?.object()?;
23            if ir.is_empty() {
24                return None;
25            }
26            crate::project::integer(record.get("pos"), 0, "measurement.pos").ok()
27        })
28        .collect::<Vec<_>>();
29    out.sort_unstable();
30    out.dedup();
31    out
32}
33
34pub fn measurement_signature(project: &Project) -> Vec<MeasurementSignatureEntry> {
35    let mut out = project
36        .measurements
37        .array()
38        .unwrap_or(&[])
39        .iter()
40        .filter_map(|record| {
41            let object = record.object()?;
42            let pos = object
43                .iter()
44                .find(|(key, _)| key == "pos")
45                .map(|(_, value)| python_str(value))
46                .unwrap_or_else(|| "None".into());
47            let mut roles = record
48                .get("ir")
49                .and_then(PyJson::object)
50                .map_or_else(Vec::new, |value| {
51                    value.iter().map(|(role, _)| role.clone()).collect()
52                });
53            roles.sort();
54            let mut t0 = record
55                .get("t0")
56                .and_then(PyJson::object)
57                .map_or_else(Vec::new, |value| {
58                    value
59                        .iter()
60                        .map(|(role, value)| (role.clone(), python_repr(value)))
61                        .collect()
62                });
63            t0.sort();
64            Some(MeasurementSignatureEntry { pos, roles, t0 })
65        })
66        .collect::<Vec<_>>();
67    out.sort();
68    out
69}
70
71/// Median REW SPL offset in dB across every project measurement entry.
72pub fn project_spl_offset(project: &Project) -> Option<f64> {
73    let mut values: Vec<_> = project
74        .measurements
75        .array()
76        .unwrap_or(&[])
77        .iter()
78        .filter_map(PyJson::object)
79        .filter_map(|record| {
80            record
81                .iter()
82                .find(|(key, _)| key == "spl_offset")
83                .and_then(|(_, value)| value.object())
84        })
85        .flat_map(|offsets| {
86            offsets.iter().filter_map(|(_, value)| match value {
87                PyJson::Float(value) => Some(*value),
88                PyJson::Int(value) => value.parse().ok(),
89                _ => None,
90            })
91        })
92        .collect();
93    if values.is_empty() {
94        return None;
95    }
96    values.sort_by(f64::total_cmp);
97    let middle = values.len() / 2;
98    Some(if values.len().is_multiple_of(2) {
99        (values[middle - 1] + values[middle]) / 2.0
100    } else {
101        values[middle]
102    })
103}
104
105pub fn target_key(project: &Project) -> Vec<(FloatKey, FloatKey)> {
106    let mut points = project.target.points.clone();
107    points.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(Ordering::Equal));
108    points
109        .into_iter()
110        .map(|(frequency, level)| (FloatKey(frequency), FloatKey(level)))
111        .collect()
112}
113
114pub fn estimate_seconds(project: &Project, mode: &str) -> f64 {
115    let mains = project
116        .config
117        .speakers
118        .iter()
119        .filter(|speaker| role_kind(&speaker.role) != RoleKind::Sub)
120        .count();
121    let subs = project.config.speakers.len() - mains;
122    let positions = measured_positions(project).len().max(1);
123    let per = if mode == "zero" { 4.1 } else { 6.5 } * (0.4 + 0.6 * positions.min(9) as f64 / 9.0);
124    (per * mains.max(1) as f64 + if subs > 1 { 10.0 } else { 0.0 }).max(5.0)
125}
126
127pub fn estimate_subs_seconds(project: &Project) -> f64 {
128    let subs = project
129        .config
130        .speakers
131        .iter()
132        .filter(|speaker| role_kind(&speaker.role) == RoleKind::Sub)
133        .count();
134    let positions = measured_positions(project).len().max(1);
135    (18.0 * (0.4 + 0.6 * positions.min(9) as f64 / 9.0) * if subs > 1 { 1.0 } else { 0.3 }).max(3.0)
136}
137
138pub fn estimate_xo_seconds(project: &Project, mode: &str, candidates: usize) -> f64 {
139    let cpu_count = std::thread::available_parallelism().map_or(2, std::num::NonZero::get);
140    estimate_xo_seconds_on(project, mode, candidates, cpu_count)
141}
142
143/// [`estimate_xo_seconds`] on a machine with `cpu_count` logical cores. The
144/// candidates run on half the cores, as upstream's `mimo_iir._workers` does.
145pub fn estimate_xo_seconds_on(
146    project: &Project,
147    mode: &str,
148    candidates: usize,
149    cpu_count: usize,
150) -> f64 {
151    let workers = candidates.min(cpu_count / 2).max(1);
152    let rounds = candidates.div_ceil(workers);
153    estimate_seconds(project, mode) * rounds as f64 * if rounds < candidates { 1.1 } else { 1.0 }
154}
155
156fn python_str(value: &PyJson) -> String {
157    match value {
158        PyJson::Null => "None".into(),
159        PyJson::Bool(true) => "True".into(),
160        PyJson::Bool(false) => "False".into(),
161        PyJson::Int(value) | PyJson::Str(value) => value.clone(),
162        PyJson::Float(value) => python_float(*value),
163        PyJson::Array(_) | PyJson::Object(_) => crate::dumps(value),
164    }
165}
166
167fn python_repr(value: &PyJson) -> String {
168    match value {
169        PyJson::Null => "None".into(),
170        PyJson::Bool(true) => "True".into(),
171        PyJson::Bool(false) => "False".into(),
172        PyJson::Int(value) => value.clone(),
173        PyJson::Float(value) => python_float(*value),
174        PyJson::Str(value) => format!("'{value}'"),
175        PyJson::Array(_) | PyJson::Object(_) => crate::dumps(value),
176    }
177}
178
179fn python_float(value: f64) -> String {
180    if value.is_nan() {
181        "nan".into()
182    } else if value == f64::INFINITY {
183        "inf".into()
184    } else if value == f64::NEG_INFINITY {
185        "-inf".into()
186    } else {
187        let mut text = format!("{value:?}");
188        if let Some((mantissa, exponent)) = text.split_once('e') {
189            let sign = exponent.starts_with(['+', '-']);
190            let digits = exponent.trim_start_matches(['+', '-']);
191            if digits.len() == 1 {
192                text = format!(
193                    "{mantissa}e{}0{digits}",
194                    if sign { &exponent[..1] } else { "+" }
195                );
196            }
197        }
198        text
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    fn project(values: &[f64]) -> Project {
207        Project {
208            measurements: PyJson::Array(
209                values
210                    .iter()
211                    .enumerate()
212                    .map(|(index, value)| {
213                        PyJson::Object(vec![(
214                            "spl_offset".into(),
215                            PyJson::Object(vec![(format!("R{index}"), PyJson::Float(*value))]),
216                        )])
217                    })
218                    .collect(),
219            ),
220            ..Project::default()
221        }
222    }
223
224    #[test]
225    fn project_spl_offset_matches_numpy_median() {
226        assert_eq!(project_spl_offset(&project(&[4.0])), Some(4.0));
227        assert_eq!(project_spl_offset(&project(&[8.0, 2.0])), Some(5.0));
228        assert_eq!(project_spl_offset(&project(&[8.0, 2.0, 4.0])), Some(4.0));
229        assert_eq!(project_spl_offset(&project(&[])), None);
230    }
231}