1use crate::pyjson::Shape;
2use crate::{ProjectError, PyJson, SpeakerConfig};
3use secs_types::ipc::OutputRateChoice;
4use std::collections::BTreeMap;
5
6pub(crate) static DSP_SHAPE: Shape = Shape::Record {
8 optional: &[],
9 nested: &[],
10};
11
12fn preset_output_rate(profile: &str) -> OutputRateChoice {
15 OutputRateChoice::Fixed(if profile == "minidsp" { 96_000 } else { 48_000 })
16}
17
18fn output_rate(
19 value: Option<&PyJson>,
20 fallback: OutputRateChoice,
21) -> Result<OutputRateChoice, ProjectError> {
22 let choice = match value {
23 None => fallback,
24 Some(PyJson::Str(value)) if value == "auto" => OutputRateChoice::Auto,
25 Some(PyJson::Int(value)) => {
26 value
27 .parse::<u32>()
28 .map(OutputRateChoice::Fixed)
29 .map_err(|_| {
30 ProjectError::Invalid("dsp.output_rate must be 'auto' or an integer".into())
31 })?
32 }
33 Some(_) => {
34 return Err(ProjectError::Invalid(
35 "dsp.output_rate must be 'auto' or an integer".into(),
36 ));
37 }
38 };
39 if let OutputRateChoice::Fixed(rate) = choice
40 && ![44_100, 48_000, 88_200, 96_000, 176_400, 192_000].contains(&rate)
41 {
42 return Err(ProjectError::Invalid(format!(
43 "dsp.output_rate {rate} is not supported; expected 44100, 48000, 88200, 96000, 176400, or 192000"
44 )));
45 }
46 Ok(choice)
47}
48
49#[derive(Debug, Clone, PartialEq)]
50pub struct DspProfile {
51 pub profile: String,
52 pub output_rate: OutputRateChoice,
53 pub fir_taps: i64,
54 pub fir_budget: i64,
55 pub iir_bands: i64,
56 pub support_mode: String,
57 pub support_taps: i64,
58 pub allpass_bands: i64,
59 pub bm_path_delays: i64,
60 pub bm_path_polarity: bool,
61 pub bm_path_allpass: i64,
62 pub path_peq_bands: i64,
63 pub path_allpass_bands: i64,
64 pub per_output_lp: bool,
65 pub fir_taps_max: i64,
66 pub fir_taps_auto: Option<bool>,
67 pub support_taps_auto: Option<bool>,
68 pub name: String,
69}
70impl Default for DspProfile {
71 fn default() -> Self {
72 Self {
73 profile: "tesira".into(),
74 output_rate: OutputRateChoice::Fixed(48_000),
75 fir_taps: 2048,
76 fir_budget: 32768,
77 iir_bands: 16,
78 support_mode: "iir".into(),
79 support_taps: 1024,
80 allpass_bands: 16,
81 bm_path_delays: 16,
82 bm_path_polarity: false,
83 bm_path_allpass: 0,
84 path_peq_bands: 0,
85 path_allpass_bands: 0,
86 per_output_lp: false,
87 fir_taps_max: 2048,
88 fir_taps_auto: Some(true),
89 support_taps_auto: Some(true),
90 name: String::new(),
91 }
92 }
93}
94impl DspProfile {
95 pub fn presets() -> BTreeMap<&'static str, Self> {
96 let rows = [
97 (
98 "camilla",
99 "PC · CamillaDSP",
100 65536,
101 65536 * 32,
102 64,
103 64,
104 64,
105 true,
106 4,
107 16,
108 8,
109 true,
110 ),
111 (
112 "tesira",
113 "Biamp Tesira SERVER (DSP-2 ×3)",
114 2048,
115 51200,
116 16,
117 16,
118 16,
119 false,
120 0,
121 0,
122 0,
123 false,
124 ),
125 (
126 "qsys",
127 "Q-SYS Core 8 Flex / 110f",
128 16384,
129 65536,
130 24,
131 16,
132 16,
133 true,
134 2,
135 4,
136 2,
137 true,
138 ),
139 (
140 "minidsp",
141 "miniDSP Flex / 2x4 HD",
142 2048,
143 4096,
144 10,
145 0,
146 0,
147 false,
148 0,
149 0,
150 0,
151 false,
152 ),
153 (
154 "custom",
155 "사용자 정의",
156 2048,
157 32768,
158 16,
159 16,
160 16,
161 false,
162 0,
163 0,
164 0,
165 false,
166 ),
167 ];
168 rows.into_iter()
169 .map(
170 |(key, name, taps, budget, iir, ap, bmd, pol, bmap, ppeq, pap, lp)| {
171 (
172 key,
173 Self {
174 profile: key.into(),
175 output_rate: preset_output_rate(key),
176 fir_taps: taps,
177 fir_taps_max: taps,
178 fir_budget: budget,
179 iir_bands: iir,
180 support_mode: "iir".into(),
181 support_taps: 1024,
182 allpass_bands: ap,
183 bm_path_delays: bmd,
184 bm_path_polarity: pol,
185 bm_path_allpass: bmap,
186 path_peq_bands: ppeq,
187 path_allpass_bands: pap,
188 per_output_lp: lp,
189 fir_taps_auto: Some(true),
190 support_taps_auto: Some(true),
191 name: name.into(),
192 },
193 )
194 },
195 )
196 .collect()
197 }
198 pub fn allocate_taps(
199 &self,
200 config: &SpeakerConfig,
201 phase: &str,
202 path_count: i64,
203 min_taps: i64,
204 ) -> (i64, i64) {
205 let bands = config
206 .speakers
207 .iter()
208 .map(|s| s.bands.len() as i64)
209 .sum::<i64>()
210 .max(1);
211 let chan = if phase == "mixed" { bands } else { 0 };
212 let sup = if ["hybrid", "fir"].contains(&self.support_mode.as_str()) {
213 path_count
214 } else {
215 0
216 };
217 let consumers = chan + sup;
218 if consumers <= 0 {
219 return (self.fir_taps, self.support_taps);
220 }
221 let ceiling = self.fir_taps_max.max(min_taps);
222 let ceiling_pow = 1_i64 << (63 - ceiling.leading_zeros());
223 let target = self.fir_budget / consumers;
224 let mut taps = if target >= 1 {
225 1_i64 << (63 - target.leading_zeros())
226 } else {
227 min_taps
228 };
229 taps = taps.max(min_taps).min(ceiling_pow);
230 while taps > min_taps && taps * consumers > self.fir_budget {
231 taps /= 2;
232 }
233 (
234 if chan > 0 { taps } else { self.fir_taps },
235 if sup > 0 { taps } else { self.support_taps },
236 )
237 }
238 pub fn to_json(&self) -> PyJson {
239 let mut fields = vec![("profile".into(), PyJson::Str(self.profile.clone()))];
240 if self.output_rate != preset_output_rate(&self.profile) {
244 fields.push((
245 "output_rate".into(),
246 match self.output_rate {
247 OutputRateChoice::Auto => PyJson::Str("auto".into()),
248 OutputRateChoice::Fixed(rate) => PyJson::Int(rate.to_string()),
249 },
250 ));
251 }
252 fields.extend(vec![
253 ("fir_taps".into(), PyJson::Int(self.fir_taps.to_string())),
254 (
255 "fir_taps_max".into(),
256 PyJson::Int(self.fir_taps_max.to_string()),
257 ),
258 (
259 "fir_budget".into(),
260 PyJson::Int(self.fir_budget.to_string()),
261 ),
262 ("iir_bands".into(), PyJson::Int(self.iir_bands.to_string())),
263 (
264 "support_mode".into(),
265 PyJson::Str(self.support_mode.clone()),
266 ),
267 (
268 "support_taps".into(),
269 PyJson::Int(self.support_taps.to_string()),
270 ),
271 (
272 "allpass_bands".into(),
273 PyJson::Int(self.allpass_bands.to_string()),
274 ),
275 (
276 "bm_path_delays".into(),
277 PyJson::Int(self.bm_path_delays.to_string()),
278 ),
279 (
280 "bm_path_polarity".into(),
281 PyJson::Bool(self.bm_path_polarity),
282 ),
283 (
284 "bm_path_allpass".into(),
285 PyJson::Int(self.bm_path_allpass.to_string()),
286 ),
287 (
288 "path_peq_bands".into(),
289 PyJson::Int(self.path_peq_bands.to_string()),
290 ),
291 (
292 "path_allpass_bands".into(),
293 PyJson::Int(self.path_allpass_bands.to_string()),
294 ),
295 ("per_output_lp".into(), PyJson::Bool(self.per_output_lp)),
296 (
297 "fir_taps_auto".into(),
298 PyJson::Bool(self.fir_taps_auto.unwrap_or(false)),
299 ),
300 (
301 "support_taps_auto".into(),
302 PyJson::Bool(self.support_taps_auto.unwrap_or(false)),
303 ),
304 ("name".into(), PyJson::Str(self.name.clone())),
305 ]);
306 PyJson::Object(fields)
307 }
308 pub fn from_json(value: PyJson) -> Result<Self, ProjectError> {
309 let object = value
310 .object()
311 .ok_or_else(|| ProjectError::Invalid("dsp must be an object".into()))?;
312 let get = |key: &str| {
313 object
314 .iter()
315 .find(|(name, _)| name == key)
316 .map(|(_, value)| value)
317 };
318 let profile = crate::project::string(get("profile"), "tesira");
319 let preset = Self::presets().remove(profile.as_str());
320 let fir_taps = crate::project::integer(get("fir_taps"), 2048, "dsp.fir_taps")?;
321 let fallback = preset.as_ref().cloned().unwrap_or_default();
322 Ok(Self {
323 profile,
324 output_rate: output_rate(get("output_rate"), fallback.output_rate)?,
325 fir_taps,
326 fir_taps_max: crate::project::integer(
327 get("fir_taps_max"),
328 preset.as_ref().map_or(fir_taps, |v| v.fir_taps_max),
329 "dsp.fir_taps_max",
330 )?,
331 fir_budget: crate::project::integer(get("fir_budget"), 32768, "dsp.fir_budget")?,
332 iir_bands: crate::project::integer(get("iir_bands"), 16, "dsp.iir_bands")?,
333 support_mode: crate::project::string(get("support_mode"), "iir"),
334 support_taps: crate::project::integer(get("support_taps"), 1024, "dsp.support_taps")?,
335 allpass_bands: crate::project::integer(
336 get("allpass_bands"),
337 fallback.allpass_bands,
338 "dsp.allpass_bands",
339 )?,
340 bm_path_delays: crate::project::integer(
341 get("bm_path_delays"),
342 fallback.bm_path_delays,
343 "dsp.bm_path_delays",
344 )?,
345 fir_taps_auto: crate::project::optional_bool(get("fir_taps_auto")),
346 support_taps_auto: crate::project::optional_bool(get("support_taps_auto")),
347 bm_path_polarity: get("bm_path_polarity")
348 .and_then(PyJson::bool)
349 .unwrap_or(fallback.bm_path_polarity),
350 bm_path_allpass: crate::project::integer(
351 get("bm_path_allpass"),
352 fallback.bm_path_allpass,
353 "dsp.bm_path_allpass",
354 )?,
355 path_peq_bands: crate::project::integer(
356 get("path_peq_bands"),
357 fallback.path_peq_bands,
358 "dsp.path_peq_bands",
359 )?,
360 path_allpass_bands: crate::project::integer(
361 get("path_allpass_bands"),
362 fallback.path_allpass_bands,
363 "dsp.path_allpass_bands",
364 )?,
365 per_output_lp: get("per_output_lp")
366 .and_then(PyJson::bool)
367 .unwrap_or(fallback.per_output_lp),
368 name: crate::project::string(get("name"), ""),
369 })
370 }
371
372 pub fn fir_used(
373 &self,
374 config: &SpeakerConfig,
375 phase: &str,
376 path_count: i64,
377 ) -> BTreeMap<&'static str, i64> {
378 let bands = config
379 .speakers
380 .iter()
381 .map(|s| s.bands.len() as i64)
382 .sum::<i64>();
383 let chan = if phase == "minphase" {
384 0
385 } else {
386 bands * self.fir_taps
387 };
388 let sup = if self.support_mode == "iir" {
389 0
390 } else {
391 path_count * self.support_taps
392 };
393 [("chan", chan), ("sup", sup), ("total", chan + sup)]
394 .into_iter()
395 .collect()
396 }
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402
403 fn object(fields: Vec<(&str, PyJson)>) -> PyJson {
404 PyJson::Object(
405 fields
406 .into_iter()
407 .map(|(name, value)| (name.to_owned(), value))
408 .collect(),
409 )
410 }
411
412 #[test]
413 fn output_rate_defaults_follow_presets_and_unknown_profiles_use_48k() {
414 for (profile, expected) in [
415 ("camilla", 48_000),
416 ("tesira", 48_000),
417 ("qsys", 48_000),
418 ("minidsp", 96_000),
419 ("custom", 48_000),
420 ("future", 48_000),
421 ] {
422 let dsp =
423 DspProfile::from_json(object(vec![("profile", PyJson::Str(profile.to_owned()))]))
424 .unwrap();
425 assert_eq!(
426 dsp.output_rate,
427 OutputRateChoice::Fixed(expected),
428 "{profile}"
429 );
430 }
431 }
432
433 #[test]
434 fn output_rate_round_trips_auto_and_every_fixed_rate() {
435 let choices = std::iter::once(OutputRateChoice::Auto).chain(
436 [44_100, 48_000, 88_200, 96_000, 176_400, 192_000]
437 .into_iter()
438 .map(OutputRateChoice::Fixed),
439 );
440 for choice in choices {
441 let dsp = DspProfile {
442 output_rate: choice,
443 ..DspProfile::default()
444 };
445 let restored = DspProfile::from_json(dsp.to_json()).unwrap();
446 assert_eq!(restored.output_rate, choice);
447 }
448 }
449
450 #[test]
451 fn output_rate_rejects_unsupported_values() {
452 let error = DspProfile::from_json(object(vec![
453 ("profile", PyJson::Str("camilla".into())),
454 ("output_rate", PyJson::Int("32000".into())),
455 ]))
456 .unwrap_err();
457 assert!(
458 error
459 .to_string()
460 .contains("dsp.output_rate 32000 is not supported")
461 );
462 }
463}