1pub mod config;
11pub mod timeline;
12
13use std::collections::HashSet;
14use std::path::Path;
15use std::sync::Mutex;
16
17use indexmap::IndexMap;
18use regex::Regex;
19use serde_json::{Map, Value};
20use xil_audio::fx::{self, AudioFxError};
21use xil_audio::segment::Segment;
22use xil_core::fsutil::basename;
23use xil_core::log;
24
25use config::{Filter, Num, SfxConfig, SfxEntry, Voice};
26
27pub const BACKGROUND_DIRECTION_TYPES: [&str; 6] = [
29 "AMBIENCE",
30 "MUSIC",
31 "VINTAGE FILTER",
32 "FILM AUDIO",
33 "SPEAKERPHONE",
34 "PHONE FILTER",
35];
36
37pub const SPAN_DIRECTION_TREATMENTS: [(&str, &str); 4] = [
39 ("VINTAGE FILTER", "vintage"),
40 ("FILM AUDIO", "film"),
41 ("SPEAKERPHONE", "speakerphone"),
42 ("PHONE FILTER", "phone"),
43];
44
45fn span_sentinel_markers(direction_type: &str) -> &'static [&'static str] {
47 match direction_type {
48 "VINTAGE FILTER" => &["DISENGAGES"],
49 "FILM AUDIO" | "SPEAKERPHONE" | "PHONE FILTER" => &["ENGAGES", "DISENGAGES"],
50 _ => &[],
51 }
52}
53
54const DEDUPED_TREATMENTS: [&str; 1] = ["phone"];
56
57pub const AMBIENCE_LEVEL_DB: f64 = -10.0;
58pub const MUSIC_LEVEL_DB: f64 = -6.0;
59
60const FILTER_REGISTRY: [&str; 4] = ["phone", "vintage", "film", "speakerphone"];
62
63pub fn stem_basename(p: &str) -> String {
65 let name = basename(Path::new(p));
66 match name.rfind('.') {
67 Some(dot) if dot > 0 && !name[..dot].chars().all(|c| c == '.') => name[..dot].to_string(),
68 _ => name,
69 }
70}
71
72fn last_underscore_part(s: &str) -> &str {
74 s.rsplit('_').next().unwrap_or(s)
75}
76
77fn span_marker(text: Option<&str>) -> Option<&'static str> {
78 let body = text.unwrap_or("");
79 if body.contains("DISENGAGES") {
80 Some("DISENGAGES")
81 } else if body.contains("ENGAGES") {
82 Some("ENGAGES")
83 } else {
84 None
85 }
86}
87
88fn strip_chars<'a>(s: &'a str, chars: &str) -> &'a str {
90 s.trim_matches(|c| chars.contains(c))
91}
92
93fn span_scope(text: Option<&str>, direction_type: &str) -> Option<String> {
94 let mut body: String = text.unwrap_or("").trim().to_string();
95 if body.to_uppercase().starts_with(direction_type) {
96 body = body.chars().skip(direction_type.chars().count()).collect();
97 }
98 let upper = body.to_uppercase();
99 let idx_bytes = upper.find("ENGAGES")?;
101 let idx_chars = upper[..idx_bytes].chars().count();
102 let rest: String = body.chars().skip(idx_chars + "ENGAGES".len()).collect();
103 let scope = strip_chars(&rest, " :,-").trim().to_lowercase();
104 if scope.is_empty() {
105 None
106 } else {
107 Some(scope)
108 }
109}
110
111#[derive(Clone, Debug)]
113pub struct StemPlan {
114 pub seq: i64,
115 pub filepath: String,
117 pub direction_type: Option<String>,
118 pub entry_type: Option<String>,
119 pub text: Option<String>,
120 pub scene: Option<String>,
121 pub foreground_override: bool,
122 pub volume_percentage: Option<Num>,
123 pub ramp_in_seconds: Option<Num>,
124 pub ramp_out_seconds: Option<Num>,
125 pub play_duration: Option<f64>,
126 pub tts_model: Option<String>,
127 pub pre_trimmed: bool,
128 pub loop_: bool,
129}
130
131impl StemPlan {
132 fn new(
133 seq: i64,
134 filepath: String,
135 direction_type: Option<String>,
136 entry_type: Option<String>,
137 text: Option<String>,
138 ) -> StemPlan {
139 StemPlan {
140 seq,
141 filepath,
142 direction_type,
143 entry_type,
144 text,
145 scene: None,
146 foreground_override: false,
147 volume_percentage: None,
148 ramp_in_seconds: None,
149 ramp_out_seconds: None,
150 play_duration: None,
151 tts_model: None,
152 pre_trimmed: false,
153 loop_: true,
154 }
155 }
156
157 pub fn is_background(&self) -> bool {
158 if self.foreground_override {
159 return false;
160 }
161 self.dt()
162 .is_some_and(|d| BACKGROUND_DIRECTION_TYPES.contains(&d))
163 }
164
165 pub fn dt(&self) -> Option<&str> {
166 self.direction_type.as_deref()
167 }
168
169 pub fn is_dialogue(&self) -> bool {
170 self.entry_type.as_deref() == Some("dialogue")
171 }
172
173 pub fn speaker_raw(&self) -> String {
175 last_underscore_part(&stem_basename(&self.filepath)).to_string()
176 }
177
178 fn speaker_lower(&self) -> String {
179 if self.filepath.is_empty() {
180 return String::new();
181 }
182 self.speaker_raw().to_lowercase()
183 }
184}
185
186fn py_int(s: &str) -> Option<i64> {
189 let t = s.trim();
190 let (neg, digits) = match t.strip_prefix('-') {
191 Some(r) => (true, r),
192 None => (false, t.strip_prefix('+').unwrap_or(t)),
193 };
194 if digits.is_empty()
195 || digits.starts_with('_')
196 || digits.ends_with('_')
197 || digits.contains("__")
198 {
199 return None;
200 }
201 let clean: String = digits.chars().filter(|&c| c != '_').collect();
202 if !clean.chars().all(|c| c.is_ascii_digit()) {
203 return None;
204 }
205 clean.parse::<i64>().ok().map(|n| if neg { -n } else { n })
206}
207
208pub fn extract_seq(filepath: &str) -> Option<i64> {
210 let base = stem_basename(filepath);
211 let prefix = base.split('_').next().unwrap_or("");
212 if let Some(d) = prefix.strip_prefix('n') {
213 if !d.is_empty() && d.chars().all(|c| c.is_ascii_digit()) {
214 return d.parse::<i64>().ok().map(|n| -n);
215 }
216 }
217 py_int(prefix)
218}
219
220pub fn load_entries_index(parsed_path: &Path) -> anyhow::Result<IndexMap<i64, Map<String, Value>>> {
222 let text = std::fs::read_to_string(parsed_path)?;
223 let data: Value = serde_json::from_str(&text)?;
224 let mut index = IndexMap::new();
225 for e in data["entries"].as_array().into_iter().flatten() {
226 if let (Some(seq), Some(o)) = (e.get("seq").and_then(Value::as_i64), e.as_object()) {
227 index.insert(seq, o.clone());
228 }
229 }
230 Ok(index)
231}
232
233fn get_str(o: &Map<String, Value>, k: &str) -> Option<String> {
234 o.get(k).and_then(Value::as_str).map(str::to_string)
235}
236
237pub fn volume_pct_to_db(pct: f64) -> f64 {
239 if pct <= 0.0 {
240 return f64::NEG_INFINITY;
241 }
242 20.0 * xil_audio::segment::libm::log10(pct / 100.0)
243}
244
245fn apply_clip_effects(
247 mut clip: Segment,
248 volume: Option<Num>,
249 ramp_in_ms: i64,
250 ramp_out_ms: i64,
251 level_db: f64,
252) -> Segment {
253 if let Some(v) = volume {
254 clip = clip.gain(volume_pct_to_db(v.f()));
255 }
256 if level_db != 0.0 {
257 clip = clip.gain(level_db);
258 }
259 if ramp_in_ms > 0 {
260 clip = clip.fade_in(ramp_in_ms as f64);
261 }
262 if ramp_out_ms > 0 {
263 clip = clip.fade_out(ramp_out_ms as f64);
264 }
265 clip
266}
267
268fn normalize_effect_key(text: &str) -> String {
269 static RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
270 RE.get_or_init(|| Regex::new(r"\s*\x{2014}\s*").unwrap())
271 .replace_all(text, " - ")
272 .into_owned()
273}
274
275pub fn find_effect_entry<'a>(cfg: &'a SfxConfig, text: &str) -> Option<&'a SfxEntry> {
277 if text.is_empty() {
278 return None;
279 }
280 if let Some(e) = cfg.effects.get(text) {
281 return Some(e);
282 }
283 let norm = normalize_effect_key(text);
284 cfg.effects
285 .iter()
286 .find(|(k, _)| normalize_effect_key(k) == norm)
287 .map(|(_, v)| v)
288}
289
290type AudioParams = (Option<Num>, Option<Num>, Option<Num>, Option<f64>);
291
292fn resolve_audio_params(plan: &StemPlan, cfg: Option<&SfxConfig>) -> AudioParams {
294 let Some(cfg) = cfg else {
295 return (None, None, None, None);
296 };
297 let entry = find_effect_entry(cfg, plan.text.as_deref().unwrap_or(""));
298 let prefix = match plan.dt() {
299 Some("MUSIC") => "music",
300 Some("AMBIENCE") => "ambience",
301 Some("SFX") | Some("BEAT") => "sfx",
302 Some("VINTAGE FILTER") => "vintage_filter",
303 _ => {
304 let vol = entry.and_then(|e| e.volume_percentage).map(Num::Float);
305 return (vol, None, None, None);
306 }
307 };
308 let pick = |own: Option<f64>, field: &str| -> Option<Num> {
309 match own {
310 Some(v) => Some(Num::Float(v)),
311 None => cfg.default_num(&format!("{prefix}_{field}"), field),
312 }
313 };
314 let vol = pick(entry.and_then(|e| e.volume_percentage), "volume_percentage");
315 let ri = pick(entry.and_then(|e| e.ramp_in_seconds), "ramp_in_seconds");
316 let ro = pick(entry.and_then(|e| e.ramp_out_seconds), "ramp_out_seconds");
317 let pd = match plan.dt() {
318 Some("MUSIC") | Some("SFX") | Some("BEAT") => entry.and_then(|e| e.play_duration),
319 _ => None,
320 };
321 (vol, ri, ro, pd)
322}
323
324pub fn mp3_duration_ms(path: &str) -> anyhow::Result<i64> {
326 Ok(xil_audio::mpeg::duration_ms(Path::new(path))?)
327}
328
329fn tts_model_of(path: &str) -> Option<String> {
332 let p = Path::new(path);
333 xil_audio::mpeg::info(p).ok()?;
334 let tag = id3::Tag::read_from_path(p).ok()?;
335 let comm = tag
336 .comments()
337 .find(|c| c.lang == "eng" && c.description.is_empty())?;
338 let first = comm.text.split('\0').next().unwrap_or("");
339 Some(first.to_string())
340}
341
342fn glob_mp3(dir: &Path) -> Vec<String> {
344 let mut out: Vec<String> = xil_core::fsutil::glob_children(dir, "", ".mp3")
345 .into_iter()
346 .map(|p| p.to_string_lossy().into_owned())
347 .collect();
348 out.sort();
349 out
350}
351
352pub fn join(dir: &Path, name: &str) -> String {
354 dir.join(name).to_string_lossy().into_owned()
355}
356
357pub fn collect_stem_plans(
359 stems_dir: &Path,
360 index: &IndexMap<i64, Map<String, Value>>,
361 cfg: Option<&SfxConfig>,
362) -> Vec<StemPlan> {
363 let mut plans = Vec::new();
364 for filepath in glob_mp3(stems_dir) {
365 let Some(seq) = extract_seq(&filepath) else {
366 continue;
367 };
368 let name = basename(Path::new(&filepath));
369 let Some(entry) = index.get(&seq).filter(|e| !e.is_empty()) else {
370 log::warning(&format!(
371 "Stale stem skipped: {name} (seq {seq} not in parsed JSON)"
372 ));
373 continue;
374 };
375 let entry_type = get_str(entry, "type");
376 let base = stem_basename(&filepath);
377 let is_sfx_stem = last_underscore_part(&base) == "sfx";
378 match entry_type.as_deref() {
379 Some("dialogue") | Some("direction") => {}
380 other => {
381 let shown = other.map(str::to_string).unwrap_or_else(|| "None".into());
382 log::warning(&format!(
383 "Stale stem skipped: {name} (seq {seq} is now a {shown} entry)"
384 ));
385 continue;
386 }
387 }
388 if is_sfx_stem && entry_type.as_deref() == Some("dialogue") {
389 log::warning(&format!(
390 "Stale stem skipped: {name} (seq {seq} is now a dialogue entry)"
391 ));
392 continue;
393 }
394 if !is_sfx_stem && entry_type.as_deref() == Some("direction") {
395 log::warning(&format!(
396 "Stale stem skipped: {name} (seq {seq} is now a direction entry)"
397 ));
398 continue;
399 }
400 if entry_type.as_deref() == Some("dialogue") {
401 if let Some(speaker) = entry
402 .get("speaker")
403 .and_then(Value::as_str)
404 .filter(|s| !s.is_empty())
405 {
406 if !base.ends_with(&format!("_{speaker}")) {
407 log::warning(&format!(
408 "Stale stem skipped: {name} (seq {seq} speaker is now {speaker})"
409 ));
410 continue;
411 }
412 }
413 }
414
415 let mut plan = StemPlan::new(
416 seq,
417 filepath.clone(),
418 get_str(entry, "direction_type"),
419 entry_type.clone(),
420 get_str(entry, "text"),
421 );
422 plan.scene = get_str(entry, "scene");
423 let section = get_str(entry, "section");
424 if plan.dt() == Some("MUSIC")
425 && (matches!(section.as_deref(), Some("preamble") | Some("postamble")) || plan.seq < 0)
426 {
427 plan.foreground_override = true;
428 }
429 let (vol, ri, ro, pd) = resolve_audio_params(&plan, cfg);
430 plan.volume_percentage = vol;
431 plan.ramp_in_seconds = ri;
432 plan.ramp_out_seconds = ro;
433 plan.play_duration = pd;
434 if plan.is_dialogue() {
435 if let Some(model) = tts_model_of(&filepath) {
436 plan.tts_model = Some(model);
437 }
438 }
439 let src_entry = cfg.and_then(|c| find_effect_entry(c, plan.text.as_deref().unwrap_or("")));
440 if let Some(src) = src_entry {
441 if !src.loop_ {
442 plan.loop_ = false;
443 }
444 if src.source.is_some() && plan.play_duration.is_none() && src.duration_seconds > 0.0 {
445 match mp3_duration_ms(&filepath) {
446 Ok(clip_ms) => {
447 if clip_ms > 0 {
448 let target_ms = src.duration_seconds * 1000.0;
449 let pct = target_ms / clip_ms as f64 * 100.0;
450 plan.play_duration = Some(if pct < 100.0 { pct } else { 100.0 });
451 }
452 }
453 Err(_) => log::debug(&format!(
454 "Could not read duration for {name} — duration_seconds ignored"
455 )),
456 }
457 }
458 }
459 plans.push(plan);
460 }
461
462 let mut seen = HashSet::new();
463 let mut deduped = Vec::new();
464 for plan in plans {
465 if !seen.insert(plan.seq) {
466 log::warning(&format!(
467 "Duplicate stem skipped: {} (seq {} already loaded)",
468 basename(Path::new(&plan.filepath)),
469 plan.seq
470 ));
471 continue;
472 }
473 deduped.push(plan);
474 }
475 let mut plans = deduped;
476
477 let seen: HashSet<i64> = plans.iter().map(|p| p.seq).collect();
478 for (&seq, entry) in index {
479 if seen.contains(&seq) {
480 continue;
481 }
482 let text = get_str(entry, "text").unwrap_or_default();
483 let dt = get_str(entry, "direction_type");
484 if dt.as_deref() == Some("AMBIENCE")
485 && (text == "AMBIENCE: STOP" || text.ends_with("FADES OUT"))
486 {
487 plans.push(StemPlan::new(
488 seq,
489 String::new(),
490 Some("AMBIENCE".into()),
491 get_str(entry, "type"),
492 Some(text.clone()),
493 ));
494 }
495 if let Some(d) = dt.as_deref() {
496 let markers = span_sentinel_markers(d);
497 if !markers.is_empty() && span_marker(Some(&text)).is_some_and(|m| markers.contains(&m))
498 {
499 plans.push(StemPlan::new(
500 seq,
501 String::new(),
502 dt.clone(),
503 get_str(entry, "type"),
504 Some(text.clone()),
505 ));
506 }
507 }
508 }
509 plans
510}
511
512pub fn by_seq(plans: &[StemPlan]) -> Vec<&StemPlan> {
514 let mut v: Vec<&StemPlan> = plans.iter().collect();
515 v.sort_by_key(|p| p.seq);
516 v
517}
518
519static WARNED_UNKNOWN_FILTERS: Mutex<Option<HashSet<String>>> = Mutex::new(None);
520
521fn apply_named_filter(segment: Segment, name: &str) -> Result<Segment, AudioFxError> {
523 match name {
524 "phone" | "film" | "speakerphone" => fx::apply_treatment(&segment, name),
525 "vintage" => Ok(apply_vintage_filter(&segment)),
526 _ => {
527 let mut guard = WARNED_UNKNOWN_FILTERS
528 .lock()
529 .unwrap_or_else(|p| p.into_inner());
530 if guard
531 .get_or_insert_with(HashSet::new)
532 .insert(name.to_string())
533 {
534 let mut known = FILTER_REGISTRY.to_vec();
535 known.sort();
536 log::warning(&format!(
537 "Unknown speaker filter {} — ignored. Known filters: {}",
538 fx::py_repr(name),
539 known.join(", ")
540 ));
541 }
542 Ok(segment)
543 }
544 }
545}
546
547pub fn apply_vintage_filter(segment: &Segment) -> Segment {
549 let mono = segment
550 .clone()
551 .set_channels(1)
552 .set_channels(segment.channels);
553 mono.low_pass_filter(5000.0)
554 .high_pass_filter(150.0)
555 .gain(-3.0)
556}
557
558pub fn cast_filter_names(filter: &Filter) -> Vec<String> {
560 if !filter.truthy() {
561 return Vec::new();
562 }
563 match filter {
564 Filter::Bool(true) => vec!["phone".into()],
565 other => other
566 .py_str()
567 .split(',')
568 .map(|n| n.trim().to_lowercase())
569 .filter(|n| !n.is_empty())
570 .collect(),
571 }
572}
573
574pub fn apply_speaker_filters(
575 mut segment: Segment,
576 filter: &Filter,
577) -> Result<Segment, AudioFxError> {
578 for name in cast_filter_names(filter) {
579 segment = apply_named_filter(segment, &name)?;
580 }
581 Ok(segment)
582}
583
584fn span_names_to_apply(span_names: &[&'static str], cast_names: &[String]) -> Vec<&'static str> {
585 if cast_names.is_empty() {
586 return span_names.to_vec();
587 }
588 span_names
589 .iter()
590 .copied()
591 .filter(|n| !(DEDUPED_TREATMENTS.contains(n) && cast_names.iter().any(|c| c == n)))
592 .collect()
593}
594
595pub fn span_treatments(plans: &[StemPlan]) -> IndexMap<i64, Vec<&'static str>> {
597 let mut active: IndexMap<&'static str, Option<String>> = IndexMap::new();
598 let mut engaged = IndexMap::new();
599 for plan in by_seq(plans) {
600 let span = SPAN_DIRECTION_TREATMENTS
601 .iter()
602 .find(|(d, _)| Some(*d) == plan.dt());
603 if let Some((direction, _)) = span {
604 match span_marker(plan.text.as_deref()) {
605 Some("DISENGAGES") => {
606 active.shift_remove(direction);
607 }
608 Some("ENGAGES") => {
609 active.insert(direction, span_scope(plan.text.as_deref(), direction));
610 }
611 _ => {}
612 }
613 } else if plan.is_dialogue() {
614 let speaker = plan.speaker_lower();
615 let names: Vec<&'static str> = SPAN_DIRECTION_TREATMENTS
616 .iter()
617 .filter(|(d, _)| match active.get(d) {
618 Some(None) => true,
619 Some(Some(scope)) => *scope == speaker,
620 None => false,
621 })
622 .map(|(_, t)| *t)
623 .collect();
624 if !names.is_empty() {
625 engaged.insert(plan.seq, names);
626 }
627 }
628 }
629 engaged
630}
631
632fn warn_missing_treatment_codecs(
634 cast: &IndexMap<String, Voice>,
635 span_map: &IndexMap<i64, Vec<&'static str>>,
636) {
637 let mut names: Vec<String> = Vec::new();
638 for v in cast.values() {
639 names.extend(cast_filter_names(&v.filter));
640 }
641 for t in span_map.values() {
642 names.extend(t.iter().map(|s| s.to_string()));
643 }
644 if names.is_empty() {
645 return;
646 }
647 for (treatment, codec) in fx::missing_codecs(names.iter().map(String::as_str)) {
648 fx::warn_once(
649 &treatment,
650 "codec-preflight",
651 &format!(
652 "This ffmpeg build has no {} encoder, so the {} treatment will render band-limited but without its codec character. The audio is usable, but it will NOT match a render from a machine that has the encoder — check before publishing a master.",
653 fx::py_repr(&codec),
654 fx::py_repr(&treatment)
655 ),
656 );
657 }
658}
659
660fn load(path: &str) -> anyhow::Result<Segment> {
663 Ok(Segment::from_file(Path::new(path))?)
664}
665
666fn trim_to_pct(segment: &Segment, pct: f64) -> Segment {
668 let n = ((segment.len_ms() as f64 * pct / 100.0) as i64).max(1);
669 segment.slice(None, Some(n as f64))
670}
671
672fn treat_dialogue(
674 mut segment: Segment,
675 plan: &StemPlan,
676 cast: &IndexMap<String, Voice>,
677 span_map: &IndexMap<i64, Vec<&'static str>>,
678 vintage_scenes: &[String],
679 dialogue_only: bool,
680) -> anyhow::Result<Segment> {
681 let speaker = plan.speaker_raw();
682 let mut cast_names = Vec::new();
683 if let Some(v) = cast.get(&speaker) {
684 cast_names = cast_filter_names(&v.filter);
685 segment = apply_speaker_filters(segment, &v.filter)?;
686 segment = segment.pan(v.pan);
687 }
688 if dialogue_only || plan.is_dialogue() {
689 if let Some(names) = span_map.get(&plan.seq).filter(|n| !n.is_empty()) {
690 for name in span_names_to_apply(names, &cast_names) {
691 segment = apply_named_filter(segment, name)?;
692 }
693 } else if !vintage_scenes.is_empty()
694 && plan
695 .scene
696 .as_ref()
697 .is_some_and(|s| vintage_scenes.contains(s))
698 {
699 segment = apply_vintage_filter(&segment);
700 }
701 }
702 Ok(segment)
703}
704
705pub fn build_foreground(
707 plans: &[StemPlan],
708 cast: &IndexMap<String, Voice>,
709 gap_ms: i64,
710 vintage_scenes: &[String],
711) -> anyhow::Result<(Segment, IndexMap<i64, i64>)> {
712 let mut foreground = Segment::empty();
713 let mut timeline = IndexMap::new();
714 let mut current_ms = 0i64;
715 let span_map = span_treatments(plans);
716 warn_missing_treatment_codecs(cast, &span_map);
717
718 for plan in by_seq(plans) {
719 timeline.insert(plan.seq, current_ms);
720 if plan.is_background() {
721 continue;
722 }
723 let mut segment = load(&plan.filepath)?;
724 if let (Some(pd), false) = (plan.play_duration, plan.pre_trimmed) {
725 segment = trim_to_pct(&segment, pd);
726 }
727 if matches!(plan.dt(), Some("SFX") | Some("BEAT")) {
728 if let Some(v) = plan.volume_percentage {
729 segment = segment.gain(volume_pct_to_db(v.f()));
730 }
731 }
732 segment = treat_dialogue(segment, plan, cast, &span_map, vintage_scenes, false)?;
733 let len = segment.len_ms();
734 foreground = foreground.append(segment.append(Segment::silent(gap_ms as f64)));
739 current_ms += len + gap_ms;
740 }
741 Ok((foreground, timeline))
742}
743
744fn loop_clip(clip: &Segment, duration_ms: i64) -> Segment {
746 let len = clip.len_ms();
747 if len == 0 || duration_ms <= 0 {
748 return Segment::silent(duration_ms.max(0) as f64);
749 }
750 let repeats = -((-duration_ms).div_euclid(len));
751 clip.repeat(repeats).slice(None, Some(duration_ms as f64))
752}
753
754#[derive(Clone, Debug, Default, PartialEq)]
756pub struct Label {
757 pub start_s: f64,
758 pub end_s: f64,
759 pub text: String,
760 pub ramp_in_s: Option<Num>,
761 pub ramp_out_s: Option<Num>,
762 pub play_duration: Option<f64>,
763 pub snippet: Option<String>,
764 pub volume_pct: Option<Num>,
765 pub seq: Option<i64>,
766 pub tts_model: Option<String>,
767}
768
769fn label_text(plan: &StemPlan, fallback: &str) -> String {
770 plan.text
771 .clone()
772 .filter(|t| !t.is_empty())
773 .or_else(|| plan.direction_type.clone().filter(|t| !t.is_empty()))
774 .unwrap_or_else(|| fallback.to_string())
775}
776
777fn loop_end(plan: &StemPlan, start_ms: i64, cues: &[(i64, i64)], total_ms: i64) -> i64 {
779 for &(cue_ms, cue_seq) in cues {
780 if cue_seq > plan.seq && cue_ms > start_ms {
781 return cue_ms.min(total_ms);
782 }
783 }
784 total_ms
785}
786
787fn cues_for(plans: &[StemPlan], timeline: &IndexMap<i64, i64>, dt: &str) -> Vec<(i64, i64)> {
788 let mut cues: Vec<(i64, i64)> = plans
789 .iter()
790 .filter(|p| p.dt() == Some(dt))
791 .map(|p| (*timeline.get(&p.seq).unwrap_or(&0), p.seq))
792 .collect();
793 cues.sort_by_key(|c| c.0);
794 cues
795}
796
797fn sorted_of<'a>(plans: &'a [StemPlan], dt: &str) -> Vec<&'a StemPlan> {
798 by_seq(plans)
799 .into_iter()
800 .filter(|p| p.dt() == Some(dt))
801 .collect()
802}
803
804struct LoopSpec {
807 dt: &'static str,
808 what: &'static str,
809 skip: fn(&StemPlan) -> bool,
810 text: fn(&StemPlan) -> String,
811}
812
813fn amb_skip(p: &StemPlan) -> bool {
814 p.filepath.is_empty()
815}
816
817fn amb_text(p: &StemPlan) -> String {
818 label_text(p, "AMBIENCE")
819}
820
821fn vf_skip(p: &StemPlan) -> bool {
822 p.filepath.is_empty() || p.text.as_deref().unwrap_or("").contains("DISENGAGES")
823}
824
825fn vf_text(p: &StemPlan) -> String {
826 p.text
827 .clone()
828 .filter(|t| !t.is_empty())
829 .unwrap_or_else(|| "VINTAGE FILTER".into())
830}
831
832const AMBIENCE_LOOP: LoopSpec = LoopSpec {
833 dt: "AMBIENCE",
834 what: "ambience",
835 skip: amb_skip,
836 text: amb_text,
837};
838
839const VINTAGE_LOOP: LoopSpec = LoopSpec {
840 dt: "VINTAGE FILTER",
841 what: "vintage filter",
842 skip: vf_skip,
843 text: vf_text,
844};
845
846fn build_looped_layer(
849 plans: &[StemPlan],
850 timeline: &IndexMap<i64, i64>,
851 total_ms: i64,
852 level_db: f64,
853 spec: &LoopSpec,
854 render: bool,
855) -> anyhow::Result<(Segment, Vec<Label>)> {
856 let mut layer = Segment::silent(total_ms as f64);
857 let mut labels = Vec::new();
858 let own = sorted_of(plans, spec.dt);
859 if own.is_empty() {
860 return Ok((layer, labels));
861 }
862 let cues: Vec<(i64, i64)> = if spec.dt == "AMBIENCE" {
865 cues_for(plans, timeline, spec.dt)
866 } else {
867 let mut c: Vec<(i64, i64)> = own
868 .iter()
869 .map(|p| (*timeline.get(&p.seq).unwrap_or(&0), p.seq))
870 .collect();
871 c.sort_by_key(|x| x.0);
872 c
873 };
874 for plan in own {
875 if (spec.skip)(plan) {
876 continue;
877 }
878 let start_ms = *timeline.get(&plan.seq).unwrap_or(&0);
879 if start_ms >= total_ms {
880 continue;
881 }
882 let end_ms = loop_end(plan, start_ms, &cues, total_ms);
883 let needed = end_ms - start_ms;
884 if needed <= 0 {
885 continue;
886 }
887 if render {
888 let clip = match Segment::from_file(Path::new(&plan.filepath)) {
889 Ok(c) => c,
890 Err(e) => {
891 log::warning(&format!(
892 "Skipping corrupt {} stem: {} ({e})",
893 spec.what, plan.filepath
894 ));
895 continue;
896 }
897 };
898 let ri = plan
899 .ramp_in_seconds
900 .filter(|n| n.truthy())
901 .map_or(0, Num::to_ms);
902 let ro = plan
903 .ramp_out_seconds
904 .filter(|n| n.truthy())
905 .map_or(0, Num::to_ms);
906 let looped = if plan.loop_ {
907 loop_clip(&clip, needed)
908 } else {
909 clip.slice(None, Some(needed as f64))
910 };
911 let looped = apply_clip_effects(looped, plan.volume_percentage, ri, ro, level_db);
912 layer = layer.overlay(looped, start_ms);
913 }
914 labels.push(Label {
915 start_s: start_ms as f64 / 1000.0,
916 end_s: end_ms as f64 / 1000.0,
917 text: (spec.text)(plan),
918 ramp_in_s: plan.ramp_in_seconds,
919 ramp_out_s: plan.ramp_out_seconds,
920 volume_pct: plan.volume_percentage,
921 seq: Some(plan.seq),
922 ..Label::default()
923 });
924 }
925 Ok((layer, labels))
926}
927
928pub fn build_ambience_layer(
929 plans: &[StemPlan],
930 timeline: &IndexMap<i64, i64>,
931 total_ms: i64,
932 level_db: f64,
933) -> anyhow::Result<(Segment, Vec<Label>)> {
934 build_looped_layer(plans, timeline, total_ms, level_db, &AMBIENCE_LOOP, true)
935}
936
937pub fn build_vintage_filter_layer(
938 plans: &[StemPlan],
939 timeline: &IndexMap<i64, i64>,
940 total_ms: i64,
941 level_db: f64,
942) -> anyhow::Result<(Segment, Vec<Label>)> {
943 build_looped_layer(plans, timeline, total_ms, level_db, &VINTAGE_LOOP, true)
944}
945
946pub fn build_music_layer(
947 plans: &[StemPlan],
948 timeline: &IndexMap<i64, i64>,
949 total_ms: i64,
950 level_db: f64,
951 include_foreground_override: bool,
952) -> anyhow::Result<(Segment, Vec<Label>)> {
953 let mut layer = Segment::silent(total_ms as f64);
954 let mut labels = Vec::new();
955 for plan in by_seq(plans) {
956 if plan.dt() != Some("MUSIC") || (plan.foreground_override && !include_foreground_override)
957 {
958 continue;
959 }
960 let start_ms = *timeline.get(&plan.seq).unwrap_or(&0);
961 if start_ms >= total_ms {
962 continue;
963 }
964 let mut clip = load(&plan.filepath)?;
965 if let (Some(pd), false) = (plan.play_duration, plan.pre_trimmed) {
966 clip = trim_to_pct(&clip, pd);
967 }
968 let ri = plan
969 .ramp_in_seconds
970 .filter(|n| n.truthy())
971 .map_or(0, Num::to_ms);
972 let ro = plan
973 .ramp_out_seconds
974 .filter(|n| n.truthy())
975 .map_or(0, Num::to_ms);
976 let clip = apply_clip_effects(clip, plan.volume_percentage, ri, ro, level_db);
977 let clip_len = clip.len_ms();
978 layer = layer.overlay(clip, start_ms);
979 labels.push(Label {
980 start_s: start_ms as f64 / 1000.0,
981 end_s: (start_ms + clip_len) as f64 / 1000.0,
982 text: label_text(plan, "MUSIC"),
983 ramp_in_s: plan.ramp_in_seconds,
984 ramp_out_s: plan.ramp_out_seconds,
985 play_duration: plan.play_duration,
986 volume_pct: plan.volume_percentage,
987 seq: Some(plan.seq),
988 ..Label::default()
989 });
990 }
991 Ok((layer, labels))
992}
993
994pub fn build_dialogue_layer(
995 plans: &[StemPlan],
996 timeline: &IndexMap<i64, i64>,
997 total_ms: i64,
998 cast: &IndexMap<String, Voice>,
999 vintage_scenes: &[String],
1000) -> anyhow::Result<(Segment, Vec<Label>)> {
1001 let mut layer = Segment::silent(total_ms as f64);
1002 let mut labels = Vec::new();
1003 let span_map = span_treatments(plans);
1004 warn_missing_treatment_codecs(cast, &span_map);
1005 for plan in by_seq(plans) {
1006 if !plan.is_dialogue() {
1007 continue;
1008 }
1009 let start_ms = *timeline.get(&plan.seq).unwrap_or(&0);
1010 let segment = load(&plan.filepath)?;
1011 let segment = treat_dialogue(segment, plan, cast, &span_map, vintage_scenes, true)?;
1012 let end_ms = start_ms + segment.len_ms();
1013 labels.push(Label {
1014 start_s: start_ms as f64 / 1000.0,
1015 end_s: end_ms as f64 / 1000.0,
1016 text: plan.speaker_raw(),
1017 seq: Some(plan.seq),
1018 ..Label::default()
1019 });
1020 layer = layer.overlay(segment, start_ms);
1021 }
1022 Ok((layer, labels))
1023}
1024
1025pub fn build_sfx_layer(
1026 plans: &[StemPlan],
1027 timeline: &IndexMap<i64, i64>,
1028 total_ms: i64,
1029) -> anyhow::Result<(Segment, Vec<Label>)> {
1030 let mut layer = Segment::silent(total_ms as f64);
1031 let mut labels = Vec::new();
1032 for plan in by_seq(plans) {
1033 if !matches!(plan.dt(), Some("SFX") | Some("BEAT")) {
1034 continue;
1035 }
1036 let start_ms = *timeline.get(&plan.seq).unwrap_or(&0);
1037 let mut segment = load(&plan.filepath)?;
1038 if let (Some(pd), false) = (plan.play_duration, plan.pre_trimmed) {
1039 segment = trim_to_pct(&segment, pd);
1040 }
1041 if let Some(v) = plan.volume_percentage {
1042 segment = segment.gain(volume_pct_to_db(v.f()));
1043 }
1044 let len = segment.len_ms();
1045 layer = layer.overlay(segment, start_ms);
1046 labels.push(Label {
1047 start_s: start_ms as f64 / 1000.0,
1048 end_s: (start_ms + len) as f64 / 1000.0,
1049 text: label_text(plan, "SFX"),
1050 play_duration: plan.play_duration,
1051 volume_pct: plan.volume_percentage,
1052 seq: Some(plan.seq),
1053 ..Label::default()
1054 });
1055 }
1056 Ok((layer, labels))
1057}
1058
1059pub fn build_foreground_timeline_only(
1061 plans: &[StemPlan],
1062 gap_ms: i64,
1063) -> anyhow::Result<(i64, IndexMap<i64, i64>)> {
1064 let mut timeline = IndexMap::new();
1065 let mut current = 0i64;
1066 for plan in by_seq(plans) {
1067 timeline.insert(plan.seq, current);
1068 if plan.is_background() {
1069 continue;
1070 }
1071 current += mp3_duration_ms(&plan.filepath)? + gap_ms;
1072 }
1073 Ok((current, timeline))
1074}
1075
1076pub fn compute_dialogue_labels(
1078 plans: &[StemPlan],
1079 timeline: &IndexMap<i64, i64>,
1080) -> anyhow::Result<Vec<Label>> {
1081 let mut labels = Vec::new();
1082 for plan in by_seq(plans) {
1083 if !plan.is_dialogue() {
1084 continue;
1085 }
1086 let start_ms = *timeline.get(&plan.seq).unwrap_or(&0);
1087 let end_ms = start_ms + mp3_duration_ms(&plan.filepath)?;
1088 let text = plan.text.clone().unwrap_or_default();
1089 let words: Vec<&str> = text.split_whitespace().collect();
1090 let snippet = if words.is_empty() {
1091 None
1092 } else {
1093 Some(words.iter().take(5).copied().collect::<Vec<_>>().join(" "))
1094 };
1095 labels.push(Label {
1096 start_s: start_ms as f64 / 1000.0,
1097 end_s: end_ms as f64 / 1000.0,
1098 text: plan.speaker_raw(),
1099 snippet,
1100 seq: Some(plan.seq),
1101 tts_model: plan.tts_model.clone(),
1102 ..Label::default()
1103 });
1104 }
1105 Ok(labels)
1106}
1107
1108pub fn compute_ambience_labels(
1109 plans: &[StemPlan],
1110 timeline: &IndexMap<i64, i64>,
1111 total_ms: i64,
1112) -> Vec<Label> {
1113 build_looped_layer(plans, timeline, total_ms, 0.0, &AMBIENCE_LOOP, false)
1114 .map(|(_, l)| l)
1115 .unwrap_or_default()
1116}
1117
1118pub fn compute_vintage_filter_labels(
1119 plans: &[StemPlan],
1120 timeline: &IndexMap<i64, i64>,
1121 total_ms: i64,
1122) -> Vec<Label> {
1123 build_looped_layer(plans, timeline, total_ms, 0.0, &VINTAGE_LOOP, false)
1124 .map(|(_, l)| l)
1125 .unwrap_or_default()
1126}
1127
1128pub fn compute_music_labels(
1129 plans: &[StemPlan],
1130 timeline: &IndexMap<i64, i64>,
1131 total_ms: i64,
1132 include_foreground_override: bool,
1133) -> anyhow::Result<Vec<Label>> {
1134 let mut labels = Vec::new();
1135 for plan in by_seq(plans) {
1136 if plan.dt() != Some("MUSIC") || (plan.foreground_override && !include_foreground_override)
1137 {
1138 continue;
1139 }
1140 let start_ms = *timeline.get(&plan.seq).unwrap_or(&0);
1141 if start_ms >= total_ms {
1142 continue;
1143 }
1144 let mut duration = mp3_duration_ms(&plan.filepath)?;
1145 if let (Some(pd), false) = (plan.play_duration, plan.pre_trimmed) {
1146 duration = ((duration as f64 * pd / 100.0) as i64).max(1);
1147 }
1148 labels.push(Label {
1149 start_s: start_ms as f64 / 1000.0,
1150 end_s: (start_ms + duration) as f64 / 1000.0,
1151 text: label_text(plan, "MUSIC"),
1152 ramp_in_s: plan.ramp_in_seconds,
1153 ramp_out_s: plan.ramp_out_seconds,
1154 play_duration: plan.play_duration,
1155 volume_pct: plan.volume_percentage,
1156 seq: Some(plan.seq),
1157 ..Label::default()
1158 });
1159 }
1160 Ok(labels)
1161}
1162
1163pub fn compute_sfx_labels(
1164 plans: &[StemPlan],
1165 timeline: &IndexMap<i64, i64>,
1166) -> anyhow::Result<Vec<Label>> {
1167 let mut labels = Vec::new();
1168 for plan in by_seq(plans) {
1169 if !matches!(plan.dt(), Some("SFX") | Some("BEAT")) {
1170 continue;
1171 }
1172 let start_ms = *timeline.get(&plan.seq).unwrap_or(&0);
1173 let mut duration = mp3_duration_ms(&plan.filepath)?;
1174 if let (Some(pd), false) = (plan.play_duration, plan.pre_trimmed) {
1175 duration = ((duration as f64 * pd / 100.0) as i64).max(1);
1176 }
1177 labels.push(Label {
1178 start_s: start_ms as f64 / 1000.0,
1179 end_s: (start_ms + duration) as f64 / 1000.0,
1180 text: label_text(plan, "SFX"),
1181 play_duration: plan.play_duration,
1182 volume_pct: plan.volume_percentage,
1183 seq: Some(plan.seq),
1184 ..Label::default()
1185 });
1186 }
1187 Ok(labels)
1188}
1189
1190pub fn derive_structure_bands(
1192 index: &IndexMap<i64, Map<String, Value>>,
1193 timeline: &IndexMap<i64, i64>,
1194 total_ms: i64,
1195 key: &str,
1196) -> Vec<Label> {
1197 let mut seqs: Vec<i64> = index.keys().copied().collect();
1198 seqs.sort();
1199 let mut groups: Vec<(String, i64)> = Vec::new();
1200 let mut current: Option<Value> = None;
1201 for seq in seqs {
1202 let value = index[&seq].get(key).cloned().unwrap_or(Value::Null);
1203 let value_opt = if value.is_null() {
1204 None
1205 } else {
1206 Some(value.clone())
1207 };
1208 if value_opt != current {
1209 current = value_opt.clone();
1210 if let Some(Value::String(s)) = &value_opt {
1211 if !s.is_empty() {
1212 groups.push((s.clone(), -1));
1213 }
1214 }
1215 }
1216 let truthy = matches!(&value_opt, Some(Value::String(s)) if !s.is_empty());
1217 if truthy {
1218 if let Some(last) = groups.last_mut() {
1219 if last.1 < 0 {
1220 if let Some(&t) = timeline.get(&seq) {
1221 last.1 = t;
1222 }
1223 }
1224 }
1225 }
1226 }
1227 let placed: Vec<(String, i64)> = groups.into_iter().filter(|g| g.1 >= 0).collect();
1228 let mut bands = Vec::new();
1229 for (i, (label, start)) in placed.iter().enumerate() {
1230 let end = placed.get(i + 1).map(|p| p.1).unwrap_or(total_ms);
1231 if end > *start {
1232 bands.push(Label {
1233 start_s: *start as f64 / 1000.0,
1234 end_s: end as f64 / 1000.0,
1235 text: label.clone(),
1236 ..Label::default()
1237 });
1238 }
1239 }
1240 bands
1241}
1242
1243#[cfg(test)]
1244mod tests {
1245 use super::*;
1246
1247 #[test]
1248 fn seq_extraction() {
1249 assert_eq!(extract_seq("stems/S01E01/003_cold-open_adam.mp3"), Some(3));
1250 assert_eq!(extract_seq("n002_preamble_tina.mp3"), Some(-2));
1251 assert_eq!(extract_seq("preamble_tina.mp3"), None);
1252 assert_eq!(extract_seq("1_0_x.mp3"), Some(1));
1253 }
1254
1255 #[test]
1256 fn span_scope_reads_the_speaker() {
1257 assert_eq!(
1258 span_scope(Some("PHONE FILTER: ENGAGES DEZ"), "PHONE FILTER"),
1259 Some("dez".into())
1260 );
1261 assert_eq!(
1262 span_scope(Some("PHONE FILTER: ENGAGES"), "PHONE FILTER"),
1263 None
1264 );
1265 assert_eq!(
1266 span_marker(Some("VINTAGE FILTER DISENGAGES")),
1267 Some("DISENGAGES")
1268 );
1269 }
1270
1271 #[test]
1272 fn cast_filter_names_normalise() {
1273 assert_eq!(cast_filter_names(&Filter::Bool(true)), vec!["phone"]);
1274 assert_eq!(
1275 cast_filter_names(&Filter::Str(" Vintage, PHONE ,".into())),
1276 vec!["vintage", "phone"]
1277 );
1278 assert!(cast_filter_names(&Filter::Bool(false)).is_empty());
1279 }
1280
1281 #[test]
1282 fn loop_clip_fills_exactly() {
1283 let clip = Segment::new([1, 0].repeat(441), 2, 44100, 1); let l = loop_clip(&clip, 25);
1285 assert_eq!(l.len_ms(), 25);
1286 }
1287}