Skip to main content

secs_project/
dsp_profile.rs

1use crate::pyjson::Shape;
2use crate::{ProjectError, PyJson, SpeakerConfig};
3use secs_types::ipc::OutputRateChoice;
4use std::collections::BTreeMap;
5
6/// The `dsp` object [`DspProfile::to_json`] writes (F16).
7pub(crate) static DSP_SHAPE: Shape = Shape::Record {
8    optional: &[],
9    nested: &[],
10};
11
12/// The output rate a preset runs at (ADR 0017): miniDSP 96 kHz, every other
13/// preset and any unknown profile 48 kHz.
14fn 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        // Written only when it differs from the preset's rate, so a project
241        // that keeps the default saves the same bytes as upstream (ADR 0004)
242        // and loads back to the same rate.
243        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}