1use 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#[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 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 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
67fn model_float(v: Option<&Value>) -> Option<f64> {
69 v.and_then(Num::from_value).map(Num::f)
70}
71
72#[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 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 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#[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 #[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 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
136fn 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#[derive(Clone, Debug)]
233pub struct SfxEntry {
234 pub prompt: Option<String>,
235 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
265fn 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
331pub 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 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 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}