Skip to main content

xil_core/script/
speakers.rs

1//! Speaker recognition: the CAST block, `speakers.json`, and the built-in
2//! fallback list. Port of the speaker half of `XILP001_script_parser.py`.
3
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::sync::LazyLock;
7
8use indexmap::IndexMap;
9use regex::Regex;
10use serde_json::{Map, Value};
11
12use crate::workspace::{show_slug, workspace_root};
13
14static PAREN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s*\([^)]*?\)\s*").unwrap());
15static NON_WORD: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[^\w\s]").unwrap());
16static SPACES: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s+").unwrap());
17static ROLE_DASH: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s*[—–]\s*").unwrap());
18
19/// Built-in display names, already longest-first as the Python literal is.
20const BUILTIN: [(&str, &str); 21] = [
21    ("FILM AUDIO (MARGARET'S VOICE)", "film_audio"),
22    ("STRANGER (MALE VOICE, FLAT)", "stranger"),
23    ("MARGARET (V.O.)", "margaret"),
24    ("MR. PATTERSON", "mr_patterson"),
25    ("FILM AUDIO", "film_audio"),
26    ("STRANGER", "stranger"),
27    ("MARGARET", "margaret"),
28    ("MARTHA", "martha"),
29    ("GERALD", "gerald"),
30    ("KAREN", "karen"),
31    ("SARAH", "sarah"),
32    ("ELENA", "elena"),
33    ("CLERK", "clerk"),
34    ("ADAM", "adam"),
35    ("DEZ", "dez"),
36    ("MAYA", "maya"),
37    ("AVA", "ava"),
38    ("RÍAN", "rian"),
39    ("RÍÁN", "rian"),
40    ("FRANK", "frank"),
41    ("TINA", "tina"),
42];
43
44/// Display names (match order) plus the display → key map.
45#[derive(Clone, Debug, Default)]
46pub struct Speakers {
47    pub known: Vec<String>,
48    pub keys: IndexMap<String, String>,
49}
50
51impl Speakers {
52    fn builtin() -> Speakers {
53        Speakers {
54            known: BUILTIN.iter().map(|(d, _)| d.to_string()).collect(),
55            // The Python key dict lists MR. PATTERSON before FILM AUDIO and
56            // MARGARET (V.O.) after MARGARET; only insertion order differs
57            // from the list, and generate_cast_config depends on it.
58            keys: builtin_key_order(),
59        }
60    }
61}
62
63fn builtin_key_order() -> IndexMap<String, String> {
64    const ORDER: [(&str, &str); 21] = [
65        ("FILM AUDIO (MARGARET'S VOICE)", "film_audio"),
66        ("STRANGER (MALE VOICE, FLAT)", "stranger"),
67        ("MR. PATTERSON", "mr_patterson"),
68        ("FILM AUDIO", "film_audio"),
69        ("STRANGER", "stranger"),
70        ("MARGARET (V.O.)", "margaret"),
71        ("MARGARET", "margaret"),
72        ("MARTHA", "martha"),
73        ("GERALD", "gerald"),
74        ("CLERK", "clerk"),
75        ("KAREN", "karen"),
76        ("SARAH", "sarah"),
77        ("ELENA", "elena"),
78        ("ADAM", "adam"),
79        ("DEZ", "dez"),
80        ("MAYA", "maya"),
81        ("AVA", "ava"),
82        ("RÍAN", "rian"),
83        ("RÍÁN", "rian"),
84        ("FRANK", "frank"),
85        ("TINA", "tina"),
86    ];
87    ORDER
88        .iter()
89        .map(|(d, k)| (d.to_string(), k.to_string()))
90        .collect()
91}
92
93/// Display name → normalized key: drop parentheticals and punctuation,
94/// lowercase, collapse whitespace to underscores.
95pub fn display_to_key(display: &str) -> String {
96    let name = PAREN.replace_all(display, " ");
97    let name = NON_WORD.replace_all(name.trim(), "");
98    let name = name.to_lowercase();
99    let name = name.trim();
100    SPACES.replace_all(name, "_").trim_matches('_').to_string()
101}
102
103/// One CAST-block entry.
104#[derive(Clone, Debug, PartialEq)]
105pub struct CastEntry {
106    pub display: String,
107    pub key: String,
108}
109
110/// Parse the `CAST:` bullet block from the script header.
111pub fn extract_cast_from_script(lines: &[String]) -> Vec<CastEntry> {
112    let mut entries = Vec::new();
113    let mut seen = Vec::new();
114    let mut in_cast = false;
115    for line in lines {
116        let stripped = line.trim();
117        if stripped == "CAST:" {
118            in_cast = true;
119            continue;
120        }
121        if !in_cast {
122            continue;
123        }
124        if let Some(raw) = stripped.strip_prefix('*') {
125            // Only an em/en dash ends a name: plain hyphens appear in names
126            // (T-BONE) and parentheses can be part of the label.
127            let name = ROLE_DASH.split(raw.trim()).next().unwrap_or("").trim();
128            if name.is_empty() {
129                continue;
130            }
131            let key = display_to_key(name);
132            if !key.is_empty() && !seen.contains(&key) {
133                entries.push(CastEntry {
134                    display: name.to_string(),
135                    key: key.clone(),
136                });
137                seen.push(key);
138            }
139        } else if stripped == "===" || stripped == "---" || !stripped.is_empty() {
140            break;
141        }
142    }
143    entries
144}
145
146/// Which `speakers.json` to load, without reading it.
147pub fn resolve_speakers_file(path: Option<&Path>) -> Option<PathBuf> {
148    if let Some(p) = path {
149        return Some(p.to_path_buf());
150    }
151    let root = workspace_root();
152    let show = crate::workspace::read_project("project.json")
153        .get("show")
154        .and_then(Value::as_str)
155        .unwrap_or("Sample Show")
156        .to_string();
157    let normalized = root
158        .join("configs")
159        .join(show_slug(&show))
160        .join("speakers.json");
161    if normalized.exists() {
162        return Some(normalized);
163    }
164    let legacy = root.join("speakers.json");
165    legacy.exists().then_some(legacy)
166}
167
168fn read_speakers_json(path: &Path) -> Vec<Map<String, Value>> {
169    fs::read_to_string(path)
170        .ok()
171        .and_then(|t| serde_json::from_str::<Value>(&t).ok())
172        .and_then(|v| v.as_array().cloned())
173        .map(|a| {
174            a.into_iter()
175                .filter_map(|e| e.as_object().cloned())
176                .collect()
177        })
178        .unwrap_or_default()
179}
180
181/// Merge CAST-block entries with `speakers.json`, falling back to the
182/// built-ins only when neither exists.
183///
184/// JSON keys always win over auto-derived ones, and a multi-word display
185/// name also registers its underscore form. The result is sorted
186/// longest-first so compound names match before short ones.
187pub fn load_speakers(path: Option<&Path>, cast_entries: &[CastEntry]) -> Speakers {
188    let speakers_file = resolve_speakers_file(path).filter(|p| p.exists());
189    if cast_entries.is_empty() && speakers_file.is_none() {
190        return Speakers::builtin();
191    }
192
193    let mut known: Vec<String> = Vec::new();
194    let mut keys: IndexMap<String, String> = IndexMap::new();
195
196    for entry in cast_entries {
197        if !keys.contains_key(&entry.display) {
198            known.push(entry.display.clone());
199            keys.insert(entry.display.clone(), entry.key.clone());
200        }
201        if entry.display.contains(' ') {
202            let underscore = entry.display.replace(' ', "_");
203            if !keys.contains_key(&underscore) {
204                known.push(underscore.clone());
205            }
206            keys.insert(underscore, entry.key.clone());
207        }
208    }
209
210    if let Some(f) = &speakers_file {
211        for entry in read_speakers_json(f) {
212            let (Some(display), Some(key)) = (
213                entry.get("display").and_then(Value::as_str),
214                entry.get("key").and_then(Value::as_str),
215            ) else {
216                continue;
217            };
218            if !keys.contains_key(display) {
219                known.push(display.to_string());
220            }
221            keys.insert(display.to_string(), key.to_string());
222            if display.contains(' ') {
223                let underscore = display.replace(' ', "_");
224                if !keys.contains_key(&underscore) {
225                    known.push(underscore.clone());
226                }
227                keys.insert(underscore, key.to_string());
228            }
229        }
230    }
231
232    // Python's list.sort is stable and keys on len() alone, so equal-length
233    // names keep insertion order.
234    known.sort_by_key(|a| std::cmp::Reverse(a.chars().count()));
235    Speakers { known, keys }
236}
237
238/// Full `speakers.json` entries keyed by speaker key, for cast skeletons.
239pub fn load_speakers_registry(path: Option<&Path>) -> IndexMap<String, Map<String, Value>> {
240    let Some(f) = resolve_speakers_file(path).filter(|p| p.exists()) else {
241        return IndexMap::new();
242    };
243    read_speakers_json(&f)
244        .into_iter()
245        .filter_map(|e| {
246            e.get("key")
247                .and_then(Value::as_str)
248                .map(|k| (k.to_string(), e.clone()))
249        })
250        .collect()
251}
252
253/// A speaker matched at the start of a line.
254pub struct SpeakerMatch {
255    pub key: String,
256    pub direction: Option<String>,
257    pub text: String,
258}
259
260/// Match a known speaker prefix, splitting off any `(direction)`.
261pub fn try_match_speaker(line: &str, speakers: &Speakers) -> Option<SpeakerMatch> {
262    for speaker in &speakers.known {
263        let Some(rest) = line.strip_prefix(speaker.as_str()) else {
264            continue;
265        };
266        // Must be followed by a space, '(' or end of line.
267        match rest.chars().next() {
268            Some(c) if c != ' ' && c != '(' => continue,
269            _ => {}
270        }
271        let mut rest = rest.trim_start();
272        let mut direction = None;
273        if rest.starts_with('(') {
274            if let Some(end) = rest.find(')') {
275                direction = Some(rest[1..end].trim().to_string());
276                rest = rest[end + 1..].trim();
277            }
278        }
279        return Some(SpeakerMatch {
280            key: speakers.keys[speaker].clone(),
281            direction,
282            text: rest.to_string(),
283        });
284    }
285    None
286}
287
288/// Reverse map key → first display name, as `generate_cast_config` builds it
289/// from the module-level `SPEAKER_KEYS`.
290pub fn key_to_display(speakers: &Speakers) -> IndexMap<String, String> {
291    let mut out: IndexMap<String, String> = IndexMap::new();
292    for (display, key) in &speakers.keys {
293        out.entry(key.clone()).or_insert_with(|| display.clone());
294    }
295    out
296}
297
298/// The built-in table, for callers that need it without any script context.
299pub fn builtin_speakers() -> Speakers {
300    Speakers::builtin()
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    fn lines(s: &str) -> Vec<String> {
308        s.split('\n').map(str::to_string).collect()
309    }
310
311    #[test]
312    fn key_derivation_matches_docstring() {
313        assert_eq!(display_to_key("ADAM"), "adam");
314        assert_eq!(display_to_key("MR. PATTERSON"), "mr_patterson");
315        assert_eq!(
316            display_to_key("FILM AUDIO (MARGARET'S VOICE)"),
317            "film_audio"
318        );
319        assert_eq!(
320            display_to_key("DETECTIVE NORA WALSH"),
321            "detective_nora_walsh"
322        );
323        assert_eq!(display_to_key("RÍAN"), "rían", "word chars include accents");
324    }
325
326    #[test]
327    fn cast_block_stops_at_the_first_non_bullet() {
328        let c = extract_cast_from_script(&lines(
329            "HEADER\nCAST:\n* ADAM — Host\n* MR. PATTERSON — Caller\n* T-BONE\n* ADAM — dupe\n\n===\nCOLD OPEN\n* LATE",
330        ));
331        assert_eq!(
332            c,
333            vec![
334                CastEntry {
335                    display: "ADAM".into(),
336                    key: "adam".into()
337                },
338                CastEntry {
339                    display: "MR. PATTERSON".into(),
340                    key: "mr_patterson".into()
341                },
342                CastEntry {
343                    display: "T-BONE".into(),
344                    key: "tbone".into()
345                },
346            ]
347        );
348    }
349
350    #[test]
351    fn cast_seeds_speakers_and_registers_underscore_forms() {
352        let cast = vec![CastEntry {
353            display: "NORA WALSH".into(),
354            key: "nora_walsh".into(),
355        }];
356        let s = load_speakers(Some(Path::new("/nonexistent")), &cast);
357        // Equal lengths, so the stable sort keeps insertion order: the
358        // display name was registered before its underscore form.
359        assert_eq!(s.known, vec!["NORA WALSH", "NORA_WALSH"]);
360        assert_eq!(s.keys["NORA WALSH"], "nora_walsh");
361        assert_eq!(s.keys["NORA_WALSH"], "nora_walsh");
362    }
363
364    #[test]
365    fn builtins_only_when_nothing_else_exists() {
366        let s = load_speakers(Some(Path::new("/nonexistent")), &[]);
367        assert_eq!(s.known.len(), 21);
368        assert_eq!(s.known[0], "FILM AUDIO (MARGARET'S VOICE)");
369        assert_eq!(s.keys["MARGARET (V.O.)"], "margaret");
370    }
371
372    #[test]
373    fn json_key_overrides_the_cast_derived_one() {
374        let tmp = tempfile::tempdir().unwrap();
375        let f = tmp.path().join("speakers.json");
376        fs::write(&f, r#"[{"display": "NORA WALSH", "key": "nora"}, {"display": "NEW GUY", "key": "newguy"}]"#).unwrap();
377        let cast = vec![CastEntry {
378            display: "NORA WALSH".into(),
379            key: "nora_walsh".into(),
380        }];
381        let s = load_speakers(Some(&f), &cast);
382        assert_eq!(s.keys["NORA WALSH"], "nora");
383        assert_eq!(s.keys["NORA_WALSH"], "nora");
384        assert_eq!(s.keys["NEW GUY"], "newguy");
385    }
386
387    #[test]
388    fn speaker_match_splits_direction_and_text() {
389        let s = load_speakers(Some(Path::new("/nonexistent")), &[]);
390        let m = try_match_speaker("ADAM (quietly) Hello there.", &s).unwrap();
391        assert_eq!(
392            (m.key.as_str(), m.direction.as_deref(), m.text.as_str()),
393            ("adam", Some("quietly"), "Hello there.")
394        );
395        let bare = try_match_speaker("ADAM", &s).unwrap();
396        assert_eq!(
397            (bare.key.as_str(), bare.direction, bare.text.as_str()),
398            ("adam", None, "")
399        );
400        assert!(
401            try_match_speaker("ADAMANT sounds", &s).is_none(),
402            "must end on space, ( or EOL"
403        );
404        assert!(try_match_speaker("Adam lowercase", &s).is_none());
405    }
406
407    #[test]
408    fn longest_name_wins_over_its_prefix() {
409        let s = load_speakers(Some(Path::new("/nonexistent")), &[]);
410        let m = try_match_speaker("MARGARET (V.O.) distant", &s).unwrap();
411        assert_eq!(m.key, "margaret");
412        assert_eq!(
413            m.text, "distant",
414            "the (V.O.) form matched whole, not MARGARET + direction"
415        );
416    }
417}