1use crate::{Project, PyJson, ordered_speakers};
2use std::collections::{BTreeMap, BTreeSet};
3
4pub use secs_types::rules::SWEEP_LENGTHS;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum TimingReference {
8 Acoustic,
9 Loopback,
10 None,
11}
12
13impl TimingReference {
14 pub fn as_disk_str(self) -> &'static str {
15 match self {
16 Self::Acoustic => "acoustic",
17 Self::Loopback => "loopback",
18 Self::None => "none",
19 }
20 }
21
22 fn from_disk(value: &str) -> Self {
23 match value.to_ascii_lowercase().as_str() {
24 "loopback" => Self::Loopback,
25 "none" => Self::None,
26 _ => Self::Acoustic,
27 }
28 }
29}
30
31#[derive(Debug, Clone, PartialEq)]
32pub struct MeasurementPlan {
33 pub roles: Vec<String>,
34 pub outputs: BTreeMap<String, i64>,
35 pub positions: Vec<i64>,
36 pub output_device: String,
37 pub input_device: String,
38 pub input_channel: i64,
39 pub sweep_level_db: f64,
40 pub sweep_length: String,
41 pub repetitions: i64,
42 pub timing_reference: TimingReference,
43 pub timing_ref_output: Option<i64>,
44 pub naming: String,
45 pub start_frequency: f64,
46 pub end_frequency: f64,
47 pub sample_rate: i64,
48}
49
50impl Default for MeasurementPlan {
51 fn default() -> Self {
52 Self {
53 roles: Vec::new(),
54 outputs: BTreeMap::new(),
55 positions: Vec::new(),
56 output_device: String::new(),
57 input_device: String::new(),
58 input_channel: 1,
59 sweep_level_db: -20.0,
60 sweep_length: "256k".into(),
61 repetitions: 1,
62 timing_reference: TimingReference::Acoustic,
63 timing_ref_output: None,
64 naming: "{role} P{pos}".into(),
65 start_frequency: 20.0,
66 end_frequency: 20_000.0,
67 sample_rate: 48_000,
68 }
69 }
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct PlanValidationError {
74 pub code: &'static str,
75 pub params: BTreeMap<String, String>,
76}
77
78impl PlanValidationError {
79 fn new(code: &'static str) -> Self {
80 Self {
81 code,
82 params: BTreeMap::new(),
83 }
84 }
85
86 fn role(code: &'static str, role: &str) -> Self {
87 Self {
88 code,
89 params: BTreeMap::from([("role".into(), role.into())]),
90 }
91 }
92}
93
94impl MeasurementPlan {
95 pub fn name_for(&self, role: &str, pos: i64) -> String {
96 let (base, band) = split_role(role);
97 let mut out = String::new();
98 let mut rest = self.naming.as_str();
99 while let Some(open) = rest.find('{') {
100 out.push_str(&rest[..open]);
101 let Some(close) = rest[open + 1..].find('}') else {
102 return format!("{role} P{pos}");
103 };
104 let key = &rest[open + 1..open + 1 + close];
105 match key {
106 "role" => out.push_str(role),
107 "pos" => out.push_str(&pos.to_string()),
108 "base" => out.push_str(&base),
109 "band" => out.push_str(band.as_deref().unwrap_or("")),
110 _ => return format!("{role} P{pos}"),
111 }
112 rest = &rest[open + close + 2..];
113 }
114 out.push_str(rest);
115 out
116 }
117
118 pub fn output_for(&self, role: &str) -> i64 {
119 self.outputs.get(role).copied().unwrap_or(1)
120 }
121
122 pub fn validate(&self) -> Vec<PlanValidationError> {
123 let mut errors = Vec::new();
124 if self.roles.is_empty() {
125 errors.push(PlanValidationError::new("measurement.roles.empty"));
126 }
127 if self.roles.iter().collect::<BTreeSet<_>>().len() != self.roles.len() {
128 errors.push(PlanValidationError::new("measurement.roles.duplicate"));
129 }
130 if self.positions.is_empty() {
131 errors.push(PlanValidationError::new("measurement.positions.empty"));
132 }
133 if self.positions.iter().collect::<BTreeSet<_>>().len() != self.positions.len() {
134 errors.push(PlanValidationError::new("measurement.positions.duplicate"));
135 }
136 for role in &self.roles {
137 if !self.outputs.contains_key(role) {
138 errors.push(PlanValidationError::role(
139 "measurement.output.missing",
140 role,
141 ));
142 }
143 }
144 if self.repetitions != 1 && self.timing_reference == TimingReference::Acoustic {
145 errors.push(PlanValidationError::new(
146 "measurement.acoustic_repetitions.invalid",
147 ));
148 }
149 if !SWEEP_LENGTHS.contains(&self.sweep_length.as_str()) {
150 let mut error = PlanValidationError::new("measurement.sweep_length.invalid");
151 error
152 .params
153 .insert("allowed".into(), SWEEP_LENGTHS.join(" / "));
154 errors.push(error);
155 }
156 errors
157 }
158
159 pub fn total_steps(&self) -> usize {
160 self.roles.len() * self.positions.len()
161 }
162}
163
164pub fn split_role(role: &str) -> (String, Option<String>) {
165 if let Some((base, band)) = role.split_once(':') {
166 let band = band.trim();
167 (base.trim().into(), (!band.is_empty()).then(|| band.into()))
168 } else {
169 (role.trim().into(), None)
170 }
171}
172
173pub fn sweep_length_samples(length: &str) -> Result<i64, std::num::ParseFloatError> {
174 let text = length.trim().to_ascii_lowercase();
175 let (number, scale) = if let Some(value) = text.strip_suffix('k') {
176 (value, 1024.0)
177 } else if let Some(value) = text.strip_suffix('m') {
178 (value, 1024.0 * 1024.0)
179 } else {
180 (text.as_str(), 1.0)
181 };
182 Ok((number.parse::<f64>()? * scale) as i64)
183}
184
185pub fn plan_from_project(
186 project: &Project,
187 positions: impl IntoIterator<Item = i64>,
188) -> MeasurementPlan {
189 let mut plan = MeasurementPlan {
190 positions: positions.into_iter().collect(),
191 output_device: project.config.device.clone(),
192 ..MeasurementPlan::default()
193 };
194 for speaker in ordered_speakers(&project.config.speakers, true) {
195 for band in &speaker.bands {
196 let role = if speaker.bands.len() <= 1 {
197 speaker.role.clone()
198 } else {
199 format!("{}:{}", speaker.role, band.band)
200 };
201 plan.outputs.insert(role.clone(), band.out);
202 plan.roles.push(role);
203 }
204 }
205 plan
206}
207
208pub fn measurement_roles(project: &Project) -> Vec<(String, i64)> {
209 let mut result = Vec::new();
210 for speaker in ordered_speakers(&project.config.speakers, false) {
211 if speaker.bands.len() <= 1 {
212 result.push((
213 speaker.role.clone(),
214 speaker.bands.first().map_or(1, |band| band.out),
215 ));
216 } else {
217 result.extend(
218 speaker
219 .bands
220 .iter()
221 .map(|band| (format!("{}:{}", speaker.role, band.band), band.out)),
222 );
223 }
224 }
225 result
226}
227
228pub fn default_ref_role(project: &Project) -> String {
229 let roles = measurement_roles(project);
230 let mains = roles
231 .iter()
232 .filter(|(role, _)| !role.to_ascii_uppercase().starts_with("SW"))
233 .collect::<Vec<_>>();
234 let mut wanted = mains
235 .iter()
236 .find(|(role, _)| role.split(':').next() == Some("R"))
237 .copied()
238 .or_else(|| mains.first().copied())
239 .map(|(role, _)| role.clone())
240 .or_else(|| roles.first().map(|(role, _)| role.clone()))
241 .unwrap_or_default();
242 if let Some((base, _)) = wanted.split_once(':')
243 && let Some((role, _)) = roles
244 .iter()
245 .find(|(role, _)| role.starts_with(&format!("{base}:")) && role.ends_with(":HF"))
246 {
247 wanted = role.clone();
248 }
249 wanted
250}
251
252pub fn timing_settings(project: &Project) -> (TimingReference, Option<i64>) {
253 let devices = devices(project);
254 let kind = TimingReference::from_disk(&py_string(
255 devices.and_then(|value| value.get("timing_reference")),
256 "acoustic",
257 ));
258 match kind {
259 TimingReference::Acoustic => {
260 let table = measurement_roles(project)
261 .into_iter()
262 .collect::<BTreeMap<_, _>>();
263 let stored = py_string(devices.and_then(|value| value.get("timing_ref_role")), "");
264 let role = if table.contains_key(&stored) {
265 stored
266 } else {
267 default_ref_role(project)
268 };
269 (kind, table.get(&role).copied())
270 }
271 TimingReference::Loopback => {
272 let channel = devices.and_then(|value| value.get("timing_ref_output"));
273 let output = channel
274 .filter(|value| value.is_truthy())
275 .map(|value| py_i64(Some(value), 0));
276 (kind, output)
277 }
278 TimingReference::None => (kind, None),
279 }
280}
281
282pub fn start_delay_seconds(text: &str) -> i64 {
283 let digits = text
284 .chars()
285 .filter(char::is_ascii_digit)
286 .collect::<String>();
287 digits.parse().unwrap_or(0)
288}
289
290pub fn sweep_seconds(project: &Project) -> f64 {
291 let devices = devices(project);
292 let rate = py_f64(devices.and_then(|value| value.get("sample_rate")), 48_000.0).max(1.0);
293 let length = py_string(devices.and_then(|value| value.get("sweep_length")), "256k");
294 let samples = sweep_length_samples(&length).unwrap_or(262_144) as f64;
295 let sweep = samples / rate;
296 if py_string(
297 devices.and_then(|value| value.get("timing_reference")),
298 "acoustic",
299 )
300 .to_ascii_lowercase()
301 .starts_with("acoustic")
302 {
303 2.0 * sweep + 0.7
304 } else {
305 sweep + 2.5
306 }
307}
308
309pub fn position_seconds(project: &Project) -> f64 {
310 let bands = project
311 .config
312 .speakers
313 .iter()
314 .map(|speaker| speaker.bands.len())
315 .sum::<usize>() as f64;
316 let delay = project
317 .extra
318 .iter()
319 .find(|(key, _)| key == "start_delay")
320 .map(|(_, value)| py_string(Some(value), ""))
321 .map_or(0, |value| start_delay_seconds(&value));
322 bands * sweep_seconds(project) * normalized_repetitions(project) as f64 + delay as f64
323}
324
325fn normalized_repetitions(project: &Project) -> i64 {
326 let devices = devices(project);
327 let mut repetitions = py_i64(devices.and_then(|value| value.get("repetitions")), 1);
328 if ![1, 2, 4, 8].contains(&repetitions) {
329 repetitions = 1;
330 }
331 if py_string(
332 devices.and_then(|value| value.get("timing_reference")),
333 "acoustic",
334 )
335 .to_ascii_lowercase()
336 .starts_with("acoustic")
337 {
338 repetitions = 1;
339 }
340 repetitions
341}
342
343fn devices(project: &Project) -> Option<&PyJson> {
344 project
345 .extra
346 .iter()
347 .find(|(key, _)| key == "devices")
348 .map(|(_, value)| value)
349 .filter(|value| value.object().is_some())
350}
351
352fn py_string(value: Option<&PyJson>, default: &str) -> String {
353 crate::project::string(value, default)
354}
355
356fn py_i64(value: Option<&PyJson>, default: i64) -> i64 {
357 crate::project::integer(value, default, "measurement option").unwrap_or(default)
358}
359
360fn py_f64(value: Option<&PyJson>, default: f64) -> f64 {
361 value
362 .and_then(|value| crate::project::float(value).ok())
363 .unwrap_or(default)
364}