Skip to main content

xil/mix/
config.rs

1//! The slices of the cast and SFX config models the mixing stages read.
2//! Port of the relevant parts of `CastConfiguration`, `VoiceConfig`,
3//! `SfxEntry` and `SfxConfiguration` in `models.py`.
4//!
5//! Numbers keep their Python type. Pydantic coerces a model field declared
6//! `float` to a float, but the `defaults` block is a plain dict, so a
7//! category default of `80` stays an int — and prints as `80`, not `80.0`,
8//! in the timeline's JSON.
9
10use std::fs;
11use std::path::Path;
12
13use anyhow::{anyhow, Context};
14use indexmap::IndexMap;
15use serde_json::{Map, Value};
16use unicode_normalization::UnicodeNormalization;
17use xil_core::pyjson::py_float;
18use xil_core::workspace::episode_tag;
19
20/// A JSON number with its Python type preserved.
21#[derive(Clone, Copy, Debug, PartialEq)]
22pub enum Num {
23    Int(i64),
24    Float(f64),
25}
26
27impl Num {
28    pub fn from_value(v: &Value) -> Option<Num> {
29        match v {
30            Value::Number(n) => Some(match n.as_i64() {
31                Some(i) if !n.is_f64() => Num::Int(i),
32                _ => Num::Float(n.as_f64()?),
33            }),
34            Value::Bool(b) => Some(Num::Int(*b as i64)),
35            _ => None,
36        }
37    }
38
39    pub fn f(self) -> f64 {
40        match self {
41            Num::Int(i) => i as f64,
42            Num::Float(x) => x,
43        }
44    }
45
46    /// `bool(x)`.
47    pub fn truthy(self) -> bool {
48        self.f() != 0.0
49    }
50
51    pub fn to_json(self) -> Value {
52        match self {
53            Num::Int(i) => Value::from(i),
54            Num::Float(x) => py_float(x),
55        }
56    }
57
58    /// `int(x * 1000)` — ms from seconds, truncating like Python.
59    pub fn to_ms(self) -> i64 {
60        match self {
61            Num::Int(i) => i * 1000,
62            Num::Float(x) => (x * 1000.0) as i64,
63        }
64    }
65}
66
67/// A float field of a pydantic model: an int in the JSON becomes a float.
68fn model_float(v: Option<&Value>) -> Option<f64> {
69    v.and_then(Num::from_value).map(Num::f)
70}
71
72/// The `filter` field: `str | bool | None`.
73#[derive(Clone, Debug, PartialEq)]
74pub enum Filter {
75    None,
76    Bool(bool),
77    Str(String),
78}
79
80impl Filter {
81    fn from_value(v: Option<&Value>) -> Filter {
82        match v {
83            Some(Value::Bool(b)) => Filter::Bool(*b),
84            Some(Value::String(s)) => Filter::Str(s.clone()),
85            _ => Filter::None,
86        }
87    }
88
89    /// `bool(filter)`.
90    pub fn truthy(&self) -> bool {
91        match self {
92            Filter::None => false,
93            Filter::Bool(b) => *b,
94            Filter::Str(s) => !s.is_empty(),
95        }
96    }
97
98    /// `str(filter)` for a truthy value, as f-strings print it.
99    pub fn py_str(&self) -> String {
100        match self {
101            Filter::None => "None".into(),
102            Filter::Bool(true) => "True".into(),
103            Filter::Bool(false) => "False".into(),
104            Filter::Str(s) => s.clone(),
105        }
106    }
107}
108
109/// `VoiceConfig(...).model_dump()` — what the mixers look a speaker up in.
110#[derive(Clone, Debug)]
111pub struct Voice {
112    pub pan: f64,
113    pub filter: Filter,
114    pub full_name: String,
115    pub voice_id: String,
116    /// The member's JSON object, for fields only one stage reads.
117    #[allow(dead_code)]
118    pub raw: Map<String, Value>,
119}
120
121#[derive(Clone, Debug)]
122pub struct CastConfig {
123    pub show: String,
124    pub title: Option<String>,
125    pub season_title: Option<String>,
126    pub artist: String,
127    pub tag: String,
128    /// Speaker key → voice settings, in file order.
129    pub cast: IndexMap<String, Voice>,
130}
131
132fn opt_str(o: &Map<String, Value>, k: &str) -> Option<String> {
133    o.get(k).and_then(Value::as_str).map(str::to_string)
134}
135
136/// Pydantic's checks on one `CastMember`, as the first error raised.
137fn validate_member(key: &str, v: &Value) -> anyhow::Result<()> {
138    let fail = |loc: &str, what: &str| {
139        anyhow!("1 validation error for CastConfiguration\ncast.{key}.{loc}\n  {what}")
140    };
141    let Some(o) = v.as_object() else {
142        return Err(fail(
143            "",
144            "Input should be a valid dictionary or instance of CastMember",
145        ));
146    };
147    for field in ["full_name", "voice_id", "role"] {
148        match o.get(field) {
149            None => return Err(fail(field, "Field required")),
150            Some(Value::String(_)) => {}
151            Some(_) => return Err(fail(field, "Input should be a valid string")),
152        }
153    }
154    if !o.contains_key("filter") {
155        return Err(fail("filter", "Field required"));
156    }
157    let ranged = |field: &str, lo: f64, hi: f64, required: bool| -> anyhow::Result<()> {
158        match o.get(field) {
159            None if required => Err(fail(field, "Field required")),
160            None | Some(Value::Null) if !required => Ok(()),
161            Some(val) => match Num::from_value(val).map(Num::f) {
162                Some(x) if x >= lo && x <= hi => Ok(()),
163                Some(_) => Err(fail(field, "Input should be within range")),
164                None => Err(fail(field, "Input should be a valid number")),
165            },
166            None => Ok(()),
167        }
168    };
169    ranged("pan", -1.0, 1.0, true)?;
170    ranged("stability", 0.0, 1.0, false)?;
171    ranged("similarity_boost", 0.0, 1.0, false)?;
172    ranged("style", 0.0, 1.0, false)?;
173    ranged("speed", 0.7, 1.5, false)?;
174    Ok(())
175}
176
177impl CastConfig {
178    pub fn load(path: &Path) -> anyhow::Result<CastConfig> {
179        let text =
180            fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
181        let v: Value =
182            serde_json::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
183        let o = v
184            .as_object()
185            .ok_or_else(|| anyhow!("1 validation error for CastConfiguration"))?;
186        let show = opt_str(o, "show").ok_or_else(|| {
187            anyhow!("1 validation error for CastConfiguration\nshow\n  Field required")
188        })?;
189        let tag = match opt_str(o, "tag_override").filter(|s| !s.is_empty()) {
190            Some(t) => t,
191            None => {
192                let episode = o.get("episode").and_then(Value::as_i64).ok_or_else(|| {
193                    anyhow!("CastConfiguration requires either tag_override or episode")
194                })?;
195                episode_tag(o.get("season").and_then(Value::as_i64), episode)
196            }
197        };
198        let Some(members_raw) = o.get("cast").and_then(Value::as_object) else {
199            anyhow::bail!("1 validation error for CastConfiguration\ncast\n  Field required");
200        };
201        for (key, m) in members_raw {
202            validate_member(key, m)?;
203        }
204        let mut cast = IndexMap::new();
205        if let Some(members) = Some(members_raw) {
206            for (key, m) in members {
207                let m = m.as_object().cloned().unwrap_or_default();
208                cast.insert(
209                    key.clone(),
210                    Voice {
211                        pan: model_float(m.get("pan")).unwrap_or(0.0),
212                        filter: Filter::from_value(m.get("filter")),
213                        full_name: opt_str(&m, "full_name").unwrap_or_default(),
214                        voice_id: opt_str(&m, "voice_id").unwrap_or_default(),
215                        raw: m.clone(),
216                    },
217                );
218            }
219        }
220        Ok(CastConfig {
221            show,
222            title: opt_str(o, "title"),
223            season_title: opt_str(o, "season_title"),
224            artist: opt_str(o, "artist").unwrap_or_else(|| "XIL Pipeline".into()),
225            tag,
226            cast,
227        })
228    }
229}
230
231/// One effect entry, with pydantic's defaults applied.
232#[derive(Clone, Debug)]
233pub struct SfxEntry {
234    pub prompt: Option<String>,
235    /// `"sfx"` or `"silence"`.
236    pub type_: String,
237    pub prompt_influence: Option<f64>,
238    pub volume_percentage: Option<f64>,
239    pub ramp_in_seconds: Option<f64>,
240    pub ramp_out_seconds: Option<f64>,
241    pub play_duration: Option<f64>,
242    pub loop_: bool,
243    pub source: Option<String>,
244    pub duration_seconds: f64,
245}
246
247impl SfxEntry {
248    fn from_value(v: &Value) -> SfxEntry {
249        let o = v.as_object().cloned().unwrap_or_default();
250        SfxEntry {
251            prompt: opt_str(&o, "prompt"),
252            type_: opt_str(&o, "type").unwrap_or_else(|| "sfx".into()),
253            prompt_influence: model_float(o.get("prompt_influence")),
254            volume_percentage: model_float(o.get("volume_percentage")),
255            ramp_in_seconds: model_float(o.get("ramp_in_seconds")),
256            ramp_out_seconds: model_float(o.get("ramp_out_seconds")),
257            play_duration: model_float(o.get("play_duration")),
258            loop_: o.get("loop").and_then(Value::as_bool).unwrap_or(false),
259            source: opt_str(&o, "source"),
260            duration_seconds: model_float(o.get("duration_seconds")).unwrap_or(5.0),
261        }
262    }
263}
264
265/// Pydantic's checks on one `SfxEntry`, as the first error it would raise.
266fn validate_entry(key: &str, v: &Value) -> anyhow::Result<()> {
267    let fail =
268        |what: String| anyhow!("1 validation error for SfxConfiguration\neffects.{key}\n  {what}");
269    let Some(o) = v.as_object() else {
270        return Err(fail(
271            "Input should be a valid dictionary or instance of SfxEntry".into(),
272        ));
273    };
274    let bad: Vec<&str> = o
275        .keys()
276        .map(String::as_str)
277        .filter(|k| {
278            matches!(
279                *k,
280                "ambience_volume_percentage"
281                    | "music_volume_percentage"
282                    | "sfx_volume_percentage"
283                    | "vintage_filter_volume_percentage"
284            )
285        })
286        .collect();
287    if !bad.is_empty() {
288        return Err(fail(format!(
289            "Value error, Unknown field(s) in SfxEntry: {bad:?}"
290        )));
291    }
292    let ty = o.get("type").and_then(Value::as_str).unwrap_or("sfx");
293    if o.contains_key("type") && !(ty == "sfx" || ty == "silence") {
294        return Err(fail("Input should be 'sfx' or 'silence'".into()));
295    }
296    let range = |k: &str, lo: f64, hi: Option<f64>| -> anyhow::Result<()> {
297        match o.get(k) {
298            None | Some(Value::Null) if k != "duration_seconds" => Ok(()),
299            None => Ok(()),
300            Some(val) => match Num::from_value(val).map(Num::f) {
301                Some(x) if x >= lo && hi.is_none_or_le(x) => Ok(()),
302                Some(_) => Err(fail(format!("{k}: Input should be within range"))),
303                None => Err(fail(format!("{k}: Input should be a valid number"))),
304            },
305        }
306    };
307    range("duration_seconds", 0.0, None)?;
308    range("prompt_influence", 0.0, Some(1.0))?;
309    range("volume_percentage", 0.0, Some(200.0))?;
310    range("ramp_in_seconds", 0.0, Some(30.0))?;
311    range("ramp_out_seconds", 0.0, Some(30.0))?;
312    range("play_duration", 0.0, Some(100.0))?;
313    let source_is_none = o.get("source").is_none_or_null();
314    let duration = model_float(o.get("duration_seconds")).unwrap_or(5.0);
315    if ty == "sfx" && source_is_none {
316        if duration == 0.0 {
317            return Err(fail(
318                "Value error, duration_seconds must be > 0 for API-generated effects; use type='silence' for stop markers".into(),
319            ));
320        }
321        if duration > 30.0 {
322            return Err(fail(format!(
323                "Value error, duration_seconds must be ≤ 30.0 for API-generated effects (got {}); set source= for pre-existing files",
324                crate::mix::config::py_float_str(duration)
325            )));
326        }
327    }
328    Ok(())
329}
330
331/// `str(float)` in Python.
332pub fn py_float_str(x: f64) -> String {
333    xil_core::pyjson::float_repr(x)
334}
335
336trait OptLe {
337    fn is_none_or_le(&self, x: f64) -> bool;
338}
339
340impl OptLe for Option<f64> {
341    fn is_none_or_le(&self, x: f64) -> bool {
342        self.map_or(true, |hi| x <= hi)
343    }
344}
345
346trait NullishExt {
347    fn is_none_or_null(&self) -> bool;
348}
349
350impl NullishExt for Option<&Value> {
351    fn is_none_or_null(&self) -> bool {
352        self.map_or(true, Value::is_null)
353    }
354}
355
356#[derive(Clone, Debug)]
357pub struct SfxConfig {
358    pub show: String,
359    pub defaults: Map<String, Value>,
360    /// NFC-normalised effect key → entry, in file order.
361    pub effects: IndexMap<String, SfxEntry>,
362    pub vintage_scenes: Vec<String>,
363}
364
365impl SfxConfig {
366    pub fn load(path: &Path) -> anyhow::Result<SfxConfig> {
367        let text =
368            fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
369        let v: Value =
370            serde_json::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
371        let o = v
372            .as_object()
373            .ok_or_else(|| anyhow!("1 validation error for SfxConfiguration"))?;
374        let show = match o.get("show") {
375            Some(Value::String(s)) => s.clone(),
376            _ => anyhow::bail!("1 validation error for SfxConfiguration\nshow\n  Field required"),
377        };
378        let Some(raw_effects) = o.get("effects").and_then(Value::as_object) else {
379            anyhow::bail!("1 validation error for SfxConfiguration\neffects\n  Field required");
380        };
381        for (k, entry) in raw_effects {
382            validate_entry(k, entry)?;
383        }
384        let mut effects = IndexMap::new();
385        if let Some(e) = Some(raw_effects) {
386            for (k, entry) in e {
387                let mut entry = SfxEntry::from_value(entry);
388                entry.source = entry.source.map(|s| s.nfc().collect());
389                effects.insert(k.nfc().collect::<String>(), entry);
390            }
391        }
392        Ok(SfxConfig {
393            show,
394            defaults: o
395                .get("defaults")
396                .and_then(Value::as_object)
397                .cloned()
398                .unwrap_or_default(),
399            effects,
400            vintage_scenes: o
401                .get("vintage_scenes")
402                .and_then(Value::as_array)
403                .map(|a| {
404                    a.iter()
405                        .filter_map(|s| s.as_str().map(str::to_string))
406                        .collect()
407                })
408                .unwrap_or_default(),
409        })
410    }
411
412    /// `defaults.get(key, defaults.get(fallback))` — a present `null` wins.
413    pub fn default_num(&self, key: &str, fallback: &str) -> Option<Num> {
414        match self.defaults.get(key) {
415            Some(v) => Num::from_value(v),
416            None => self.defaults.get(fallback).and_then(Num::from_value),
417        }
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424    use serde_json::json;
425
426    #[test]
427    fn numbers_keep_their_python_type() {
428        assert_eq!(Num::from_value(&json!(80)), Some(Num::Int(80)));
429        assert_eq!(Num::from_value(&json!(80.0)), Some(Num::Float(80.0)));
430        assert_eq!(Num::from_value(&json!(null)), None);
431        assert_eq!(Num::Float(1.5).to_ms(), 1500);
432        assert_eq!(Num::Float(0.0015).to_ms(), 1);
433    }
434
435    #[test]
436    fn filter_prints_like_python() {
437        assert_eq!(Filter::from_value(Some(&json!(true))).py_str(), "True");
438        assert!(!Filter::from_value(Some(&json!(""))).truthy());
439        assert!(!Filter::from_value(Some(&json!(null))).truthy());
440    }
441}