Skip to main content

shuyi_assets/
voice.rs

1//! Voice clips: a manifest that lists every clip by dialogue line id, and
2//! a cast that says which voice says each speaker's lines. A game ships its
3//! clips beside it, so nothing here reads a file: the game reads the text
4//! and these functions parse and validate it.
5//!
6//! A clip records the words it says (`spoken`). When the line says
7//! something else today (a name in the names table changed), the clip is
8//! **stale** and is left out of the [`Playable`] set. A line whose words
9//! change with something the player picks (a hero's pronouns, say) has one
10//! clip per [`ClipVariant`] the game names.
11//!
12//! The cast is written for the tool that makes the voices: each entry
13//! names the voice (what a clip's `voice` records: a reference clip's name
14//! for a generated voice, an actor's for a recorded one) and the settings
15//! only that tool knows, of the tool's own type `S` (`()` for none, written
16//! `settings: ()`). A key is a speaker, or a speaker followed by a
17//! variant's [`suffix`](ClipVariant::suffix) for that variant's clips:
18//!
19//! ```ron
20//! (voices: {
21//!     ">": (voice: "narrator", settings: (reader: Kokoro)),
22//!     "hero": (voice: "hero_deep", settings: (reader: Kokoro)),
23//!     "hero.light": (voice: "hero_light", settings: (reader: Piper)),
24//! })
25//! ```
26//!
27//! A voice tool plans its work with [`plan`]: which clips to make, record
28//! again or remove, and each voice's recording script.
29use crate::prelude::*;
30
31use alloc::collections::{BTreeMap, BTreeSet};
32use core::fmt::Debug;
33
34use serde::Deserialize;
35use serde::de::DeserializeOwned;
36
37use crate::dialogue::{DialogueTable, LineId, NARRATION_SPEAKER, Scene};
38use crate::error::{ContentError, line_of};
39use crate::ron::parse_ron;
40
41pub mod plan;
42
43/// Which recording of a line a clip is: the game's type, with its default
44/// for a line's only clip.
45pub trait ClipVariant: Copy + Ord + Default + Debug + DeserializeOwned + 'static {
46    /// What goes between the line id and `.ogg` in the file name: nothing
47    /// for the default, `.m` for a variant `M`, say.
48    fn suffix(self) -> &'static str;
49
50    /// Whether this is the default: the line's only clip.
51    fn is_only(self) -> bool {
52        self == Self::default()
53    }
54
55    /// The variants that go together, every one of them or none: the clips
56    /// of a line whose words change.
57    fn split() -> &'static [Self];
58
59    /// Every variant a clip may be: the default and the
60    /// [`split`](Self::split) ones. A game with other variants too lists
61    /// them all.
62    fn variants() -> Vec<Self> {
63        let mut all = vec![Self::default()];
64        all.extend(Self::split().iter().copied().filter(|v| !v.is_only()));
65        all
66    }
67
68    /// What a line with some of the [`split`](Self::split) variants but not
69    /// all is told.
70    fn incomplete(line: &str) -> String {
71        format!("line \"{line}\" lacks some of its variants' clips")
72    }
73
74    /// What a line with its only clip and split ones too is told.
75    fn mixed(line: &str) -> String {
76        format!("line \"{line}\" has an only clip and variants' clips; keep one kind")
77    }
78}
79
80/// No voices: a line has only its only clip (`()`, as the engine says
81/// "none" elsewhere).
82impl ClipVariant for () {
83    fn suffix(self) -> &'static str {
84        ""
85    }
86
87    fn split() -> &'static [Self] {
88        &[]
89    }
90}
91
92/// Who or what made a clip (the credits say which voices are generated).
93#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
94#[serde(deny_unknown_fields)]
95pub enum MadeBy {
96    /// Made by a text-to-speech tool.
97    Generated {
98        /// The tool.
99        tool: String,
100        /// The model or voice engine version.
101        model: String,
102        /// The day it was made, `YYYY-MM-DD`.
103        date: String,
104    },
105    /// Recorded by a person.
106    Recorded {
107        /// The actor, as they want to be credited.
108        actor: String,
109    },
110}
111
112/// One clip of the manifest.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct VoiceClip<V> {
115    /// The dialogue line it says.
116    pub line: LineId,
117    /// Which recording of the line it is.
118    pub variant: V,
119    /// The words it says: the line's text with every token filled in, as
120    /// it was when the clip was made.
121    pub spoken: String,
122    /// The direction the line had when the clip was made, if any.
123    pub direction: Option<String>,
124    /// The cast voice that says it (a [`CastVoice::voice`]).
125    pub voice: String,
126    /// Who or what made it.
127    pub made_by: MadeBy,
128}
129
130/// The validated voice manifest of one language.
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct VoiceManifest<V> {
133    /// Every clip, in file order.
134    pub clips: Vec<VoiceClip<V>>,
135}
136
137impl<V> Default for VoiceManifest<V> {
138    fn default() -> Self {
139        Self { clips: Vec::new() }
140    }
141}
142
143/// Which voice says each speaker's lines, with the settings of the tool
144/// that makes them (`S`).
145#[derive(Debug, Clone, PartialEq, Eq)]
146#[non_exhaustive]
147pub struct Cast<S> {
148    /// Voices by key: a speaker (a character id, or
149    /// [`NARRATION_SPEAKER`] for narration), or a speaker followed by a
150    /// variant's [`suffix`](ClipVariant::suffix).
151    pub voices: BTreeMap<String, CastVoice<S>>,
152}
153
154impl<S> Default for Cast<S> {
155    fn default() -> Self {
156        Self {
157            voices: BTreeMap::new(),
158        }
159    }
160}
161
162impl<S> Cast<S> {
163    /// A cast of `voices`, by key.
164    pub fn new(voices: BTreeMap<String, CastVoice<S>>) -> Self {
165        Self { voices }
166    }
167
168    /// The voice that says `speaker`'s clips of `variant`: the entry for
169    /// the speaker and the variant's suffix if there is one, else the
170    /// speaker's.
171    pub fn voice_for<V: ClipVariant>(&self, speaker: &str, variant: V) -> Option<&CastVoice<S>> {
172        let suffix = variant.suffix();
173        (!suffix.is_empty())
174            .then(|| self.voices.get(&format!("{speaker}{suffix}")))
175            .flatten()
176            .or_else(|| self.voices.get(speaker))
177    }
178}
179
180/// One voice of the [`Cast`].
181#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
182#[serde(deny_unknown_fields)]
183#[non_exhaustive]
184pub struct CastVoice<S> {
185    /// What a clip's [`voice`](VoiceClip::voice) records: a reference
186    /// clip's name for a generated voice, an actor's for a recorded one.
187    pub voice: String,
188    /// What only the tool that makes the voice knows: which program reads,
189    /// or the sample it copies.
190    pub settings: S,
191}
192
193impl<S> CastVoice<S> {
194    /// A voice named `voice`, made with `settings`.
195    pub fn new(voice: impl Into<String>, settings: S) -> Self {
196        Self {
197            voice: voice.into(),
198            settings,
199        }
200    }
201}
202
203#[derive(Deserialize)]
204#[serde(deny_unknown_fields)]
205#[serde(bound = "V: ClipVariant")]
206struct RawManifest<V> {
207    clips: Vec<RawClip<V>>,
208}
209
210#[derive(Deserialize)]
211#[serde(deny_unknown_fields)]
212#[serde(bound = "V: ClipVariant")]
213struct RawClip<V> {
214    line: String,
215    #[serde(default)]
216    variant: V,
217    spoken: String,
218    #[serde(default)]
219    direction: Option<String>,
220    voice: String,
221    made_by: MadeBy,
222}
223
224#[derive(Deserialize)]
225#[serde(deny_unknown_fields)]
226#[serde(bound = "S: DeserializeOwned")]
227struct RawCast<S> {
228    voices: BTreeMap<String, CastVoice<S>>,
229}
230
231/// A clip's file, relative to its language's folder:
232/// `<scene id>/<line id>.ogg`, with the variant's
233/// [`suffix`](ClipVariant::suffix) before `.ogg`.
234pub fn clip_file<V: ClipVariant>(line: &LineId, variant: V) -> String {
235    format!("{}/{line}{}.ogg", line.scene_id(), variant.suffix())
236}
237
238/// The text of every dialogue line, by line id.
239pub fn line_texts<D>(dialogue: &DialogueTable<D>) -> BTreeMap<&str, &str> {
240    dialogue
241        .scenes
242        .values()
243        .flat_map(Scene::lines)
244        .map(|line| (line.id.as_str(), line.text))
245        .collect()
246}
247
248/// Parses and validates manifest `source`, attributing errors to `file`:
249/// every clip's line exists in `dialogue`, no line has the same variant
250/// twice, a line has either its only clip or every
251/// [`split`](ClipVariant::split) one, and `spoken` and `voice` aren't
252/// empty. Reports every problem found.
253///
254/// A stale clip is not an error (see [`VoiceManifest::playable`]).
255pub fn from_source<V: ClipVariant, D>(
256    file: &str,
257    source: &str,
258    dialogue: &DialogueTable<D>,
259) -> Result<VoiceManifest<V>, Vec<ContentError>> {
260    let raw: RawManifest<V> = parse_ron(file, source).map_err(|e| vec![e])?;
261    let texts = line_texts(dialogue);
262    let mut errors = Vec::new();
263    let mut err = |line: &str, message: String| {
264        let e = ContentError::new(file, message);
265        errors.push(match line_of(source, &format!("\"{line}\"")) {
266            Some(l) => e.at(l, None),
267            None => e,
268        });
269    };
270    let mut variants: BTreeMap<&str, Vec<V>> = BTreeMap::new();
271    for clip in &raw.clips {
272        let id = clip.line.as_str();
273        if !texts.contains_key(id) {
274            err(id, format!("no dialogue line has the id \"{id}\""));
275        }
276        let seen = variants.entry(id).or_default();
277        if seen.contains(&clip.variant) {
278            err(
279                id,
280                format!("line \"{id}\" has two {:?} clips", clip.variant),
281            );
282        }
283        seen.push(clip.variant);
284        if clip.spoken.trim().is_empty() {
285            err(id, format!("the clip of line \"{id}\" has no spoken text"));
286        }
287        if clip.voice.trim().is_empty() {
288            err(id, format!("the clip of line \"{id}\" has no voice"));
289        }
290    }
291    for (&id, seen) in &variants {
292        let split = V::split();
293        let some = split.iter().any(|v| seen.contains(v));
294        if some && !split.iter().all(|v| seen.contains(v)) {
295            err(id, V::incomplete(id));
296        }
297        if some && seen.iter().any(|v| v.is_only()) {
298            err(id, V::mixed(id));
299        }
300    }
301    if !errors.is_empty() {
302        return Err(errors);
303    }
304    let clips = raw
305        .clips
306        .into_iter()
307        .map(|c| VoiceClip {
308            line: LineId::new(c.line),
309            variant: c.variant,
310            spoken: c.spoken,
311            direction: c.direction,
312            voice: c.voice,
313            made_by: c.made_by,
314        })
315        .collect();
316    Ok(VoiceManifest { clips })
317}
318
319/// Parses and validates cast `source`, attributing errors to `file` at
320/// each entry's line: no voice is empty, every key is a speaker that is
321/// [`NARRATION_SPEAKER`] or one that `can_speak`, or such a speaker
322/// followed by the [`suffix`](ClipVariant::suffix) of one of `V`'s
323/// [`variants`](ClipVariant::variants). A key that is a speaker is the
324/// speaker's, whatever it ends with. Settings `S` rejects are a parse
325/// error.
326pub fn cast_from_source<S: DeserializeOwned, V: ClipVariant>(
327    file: &str,
328    source: &str,
329    can_speak: impl Fn(&str) -> bool,
330) -> Result<Cast<S>, Vec<ContentError>> {
331    let raw: RawCast<S> = parse_ron(file, source).map_err(|e| vec![e])?;
332    let speaks = |speaker: &str| speaker == NARRATION_SPEAKER || can_speak(speaker);
333    let suffixes: Vec<&str> = V::variants()
334        .into_iter()
335        .map(V::suffix)
336        .filter(|s| !s.is_empty())
337        .collect();
338    let mut errors = Vec::new();
339    for (key, entry) in &raw.voices {
340        let mut err = |message: String| {
341            let e = ContentError::new(file, message);
342            errors.push(match line_of(source, &format!("\"{key}\"")) {
343                Some(l) => e.at(l, None),
344                None => e,
345            });
346        };
347        if entry.voice.trim().is_empty() {
348            err(format!("\"{key}\" has no voice"));
349        }
350        if speaks(key) {
351            continue;
352        }
353        let mut speakers = suffixes.iter().filter_map(|s| key.strip_suffix(s));
354        if let Some(speaker) = speakers.clone().find(|s| speaks(s)).or(speakers.next()) {
355            if !speaks(speaker) {
356                err(format!(
357                    "\"{key}\": \"{speaker}\" is not a character that speaks"
358                ));
359            }
360        } else if let Some(at) = (1..key.len())
361            .filter(|&at| key.is_char_boundary(at))
362            .find(|&at| speaks(&key[..at]))
363        {
364            let known: Vec<String> = suffixes.iter().map(|s| format!("\"{s}\"")).collect();
365            err(format!(
366                "\"{key}\": \"{}\" is not a variant's suffix (those are: {})",
367                &key[at..],
368                known.join(", ")
369            ));
370        } else {
371            err(format!("\"{key}\" is not a character that speaks"));
372        }
373    }
374    if errors.is_empty() {
375        Ok(Cast { voices: raw.voices })
376    } else {
377        Err(errors)
378    }
379}
380
381/// The clips that may be played: those that say what their line says
382/// today.
383#[derive(Debug, Clone, PartialEq, Eq)]
384pub struct Playable<V> {
385    clips: BTreeSet<(LineId, V)>,
386}
387
388impl<V> Default for Playable<V> {
389    fn default() -> Self {
390        Self {
391            clips: BTreeSet::new(),
392        }
393    }
394}
395
396impl<V: ClipVariant> Playable<V> {
397    /// The clip to play for `line` as `variant` plays it: the line's only
398    /// clip, else `variant`'s. `None` if the line has no playable clip.
399    pub fn variant_for(&self, line: &LineId, variant: V) -> Option<V> {
400        [V::default(), variant]
401            .into_iter()
402            .find(|&v| self.clips.contains(&(line.clone(), v)))
403    }
404
405    /// How many clips may be played.
406    pub fn len(&self) -> usize {
407        self.clips.len()
408    }
409
410    /// Whether no clip may be played.
411    pub fn is_empty(&self) -> bool {
412        self.clips.is_empty()
413    }
414}
415
416impl<V: ClipVariant> VoiceManifest<V> {
417    /// Whether `clip` is stale: `spoken` (the words a line written as the
418    /// text it is given says, as played for a variant; `None` when no clip
419    /// can say them) is not what the clip says. A clip of a line that is
420    /// gone is stale too.
421    pub fn is_stale<D>(
422        clip: &VoiceClip<V>,
423        dialogue: &DialogueTable<D>,
424        spoken: impl Fn(&str, V) -> Option<String>,
425    ) -> bool {
426        let Some(&text) = line_texts(dialogue).get(clip.line.as_str()) else {
427            return true;
428        };
429        stale(clip, text, &spoken)
430    }
431
432    /// Whether `clip` should be made again: it is stale
433    /// ([`is_stale`](Self::is_stale)) or its line's direction changed. A
434    /// clip whose direction changed still says the right words, so it stays
435    /// playable until it is made again.
436    pub fn needs_remake<D>(
437        clip: &VoiceClip<V>,
438        dialogue: &DialogueTable<D>,
439        spoken: impl Fn(&str, V) -> Option<String>,
440    ) -> bool {
441        let line = dialogue
442            .get(clip.line.scene_id())
443            .map(Scene::lines)
444            .and_then(|lines| lines.into_iter().find(|line| *line.id == clip.line));
445        line.is_none_or(|line| {
446            stale(clip, line.text, &spoken) || clip.direction.as_deref() != line.direction
447        })
448    }
449
450    /// The clips that aren't stale ([`is_stale`](Self::is_stale)).
451    pub fn playable<D>(
452        &self,
453        dialogue: &DialogueTable<D>,
454        spoken: impl Fn(&str, V) -> Option<String>,
455    ) -> Playable<V> {
456        let texts = line_texts(dialogue);
457        let clips = self
458            .clips
459            .iter()
460            .filter(|clip| {
461                texts
462                    .get(clip.line.as_str())
463                    .is_some_and(|text| !stale(clip, text, &spoken))
464            })
465            .map(|clip| (clip.line.clone(), clip.variant))
466            .collect();
467        Playable { clips }
468    }
469}
470
471/// Whether `clip`, of the line written `text`, is stale.
472fn stale<V: ClipVariant>(
473    clip: &VoiceClip<V>,
474    text: &str,
475    spoken: &impl Fn(&str, V) -> Option<String>,
476) -> bool {
477    spoken(text, clip.variant).is_none_or(|words| words != clip.spoken)
478}
479
480#[cfg(test)]
481mod tests;