Skip to main content

xil/mix/
mod.rs

1//! Shared multi-track mixing for `assemble` and `daw`. Port of
2//! `mix_common.py`.
3//!
4//! Stems are classified by the parsed script's `direction_type`, then built
5//! into a foreground (dialogue, SFX, beats — laid end to end with a gap)
6//! and background layers (ambience looped to the next cue, music at its
7//! cue, the vintage crackle between its markers). Every audio operation is
8//! a [`Segment`] call, so the arithmetic is pydub's.
9
10pub 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
27/// Direction types routed to the background layers.
28pub const BACKGROUND_DIRECTION_TYPES: [&str; 6] = [
29    "AMBIENCE",
30    "MUSIC",
31    "VINTAGE FILTER",
32    "FILM AUDIO",
33    "SPEAKERPHONE",
34    "PHONE FILTER",
35];
36
37/// Span markers and the treatment each engages, in stacking order.
38pub const SPAN_DIRECTION_TREATMENTS: [(&str, &str); 4] = [
39    ("VINTAGE FILTER", "vintage"),
40    ("FILM AUDIO", "film"),
41    ("SPEAKERPHONE", "speakerphone"),
42    ("PHONE FILTER", "phone"),
43];
44
45/// Which markers of each span type get a synthetic boundary plan.
46fn 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
54/// Treatments that must not stack with the same cast filter.
55const DEDUPED_TREATMENTS: [&str; 1] = ["phone"];
56
57pub const AMBIENCE_LEVEL_DB: f64 = -10.0;
58pub const MUSIC_LEVEL_DB: f64 = -6.0;
59
60/// The speaker keys `FILTER_REGISTRY` knows, for the unknown-filter warning.
61const FILTER_REGISTRY: [&str; 4] = ["phone", "vintage", "film", "speakerphone"];
62
63/// `os.path.splitext(os.path.basename(p))[0]`.
64pub 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
72/// `basename.rsplit("_", 1)[-1]`.
73fn 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
88/// Python `str.strip(chars)`.
89fn 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    // `find` on the upper-cased copy gives a *character* index in Python.
100    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/// Resolved metadata for one stem file (or a boundary sentinel).
112#[derive(Clone, Debug)]
113pub struct StemPlan {
114    pub seq: i64,
115    /// Empty for a sentinel with no audio.
116    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    /// The speaker key from the stem name (`…_<speaker>.mp3`).
174    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
186/// `int(prefix)` for a stem prefix — whitespace, sign and `_` separators
187/// between digits are all accepted by Python.
188fn 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
208/// `extract_seq(filepath)`; `None` where Python raises `ValueError`.
209pub 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
220/// `load_entries_index(parsed_path)` — insertion-ordered, last entry wins.
221pub 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
237/// `_volume_pct_to_db`.
238pub 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
245/// `_apply_clip_effects`.
246fn 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
275/// `_find_effect_entry`: exact key, then em-dash-normalised.
276pub 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
292/// `_resolve_audio_params`.
293fn 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
324/// `_mp3_duration_ms` — mutagen's header read, truncated to whole ms.
325pub fn mp3_duration_ms(path: &str) -> anyhow::Result<i64> {
326    Ok(xil_audio::mpeg::duration_ms(Path::new(path))?)
327}
328
329/// The `COMM::eng` text of a dialogue stem, only if mutagen could open it
330/// as an MP3 at all (it raises, and the Python swallows, otherwise).
331fn 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
342/// `glob.glob(os.path.join(dir, "*.mp3"))`, sorted.
343fn 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
352/// `os.path.join(stems_dir, name)` spelled the way glob returns it.
353pub fn join(dir: &Path, name: &str) -> String {
354    dir.join(name).to_string_lossy().into_owned()
355}
356
357/// `collect_stem_plans(stems_dir, entries_index, sfx_config)`.
358pub 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
512/// Stable sort by seq, as `sorted(plans, key=lambda p: p.seq)`.
513pub 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
521/// `_apply_named_filter`.
522fn 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
547/// `apply_vintage_filter` — mono collapse, pydub single-pole EQ, -3 dB.
548pub 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
558/// `_cast_filter_names`.
559pub 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
595/// `_span_treatments`: dialogue seq → treatments of the spans open there.
596pub 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
632/// `_warn_missing_treatment_codecs`.
633fn 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
660/// Load a stem, as `AudioSegment.from_file`. Decode failures are fatal in
661/// the builders that do not catch them, as they are in Python.
662fn load(path: &str) -> anyhow::Result<Segment> {
663    Ok(Segment::from_file(Path::new(path))?)
664}
665
666/// `segment[:max(1, int(len(segment) * pct / 100.0))]`.
667fn 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
672/// Dialogue treatment shared by the foreground and the dialogue layer.
673fn 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
705/// `build_foreground` → `(foreground, timeline)`.
706pub 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 += segment + silent`: the stem and its gap are synced
735        // and joined first, then that block is synced onto the foreground.
736        // Resampling the block as one piece is not the same as resampling
737        // the stem and the gap separately.
738        foreground = foreground.append(segment.append(Segment::silent(gap_ms as f64)));
739        current_ms += len + gap_ms;
740    }
741    Ok((foreground, timeline))
742}
743
744/// `_loop_clip(clip, duration_ms)`.
745fn 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/// A label / timeline span: the widest Python label tuple, unset fields `None`.
755#[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
777/// The end of a looped span: the next cue of the same kind after this one.
778fn 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
804/// What distinguishes the two looped layers: which plans, which skip rule,
805/// which fallback label, and how a corrupt stem is named in the warning.
806struct 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
846/// Shared loop for the ambience and vintage-filter layers. With `render`
847/// off it computes only the labels (`compute_*_labels`), without decoding.
848fn 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    // Ambience ends at any AMBIENCE cue in the episode; the crackle only at
863    // its own markers. Both lists are ordered by cue time.
864    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
1059/// `build_foreground_timeline_only` — header durations, no decoding.
1060pub 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
1076/// `compute_dialogue_labels`.
1077pub 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
1190/// `derive_structure_bands(entries_index, timeline, total_ms, key)`.
1191pub 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); // 10 ms
1284        let l = loop_clip(&clip, 25);
1285        assert_eq!(l.len_ms(), 25);
1286    }
1287}