Skip to main content

ravenrs_movie/
narration.rs

1//! A film's narration recorded ahead of time: nobody who watches needs a voice model, or
2//! a good system voice. Each text is kept once, compressed to a quarter of its size.
3
4use std::collections::HashMap;
5
6use ravenrs_widgets::SoundClip;
7
8const MAGIC: &[u8; 4] = b"RVN1";
9const HEADER: usize = 12;
10const ENTRY: usize = 16;
11/// The rate it is kept at: enough for a voice.
12const RATE: u32 = 16_000;
13/// How loud every recording ends up, so that a film does not change volume.
14const LEVEL: f32 = 0.12;
15const LOUDEST: f32 = 0.95;
16
17const STEPS: [i32; 89] = [
18    7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 19, 21, 23, 25, 28, 31, 34, 37, 41, 45, 50, 55, 60, 66, 73, 80, 88,
19    97, 107, 118, 130, 143, 157, 173, 190, 209, 230, 253, 279, 307, 337, 371, 408, 449, 494, 544, 598, 658,
20    724, 796, 876, 963, 1060, 1166, 1282, 1411, 1552, 1707, 1878, 2066, 2272, 2499, 2749, 3024, 3327, 3660,
21    4026, 4428, 4871, 5358, 5894, 6484, 7132, 7845, 8630, 9493, 10442, 11487, 12635, 13899, 15289, 16818,
22    18500, 20350, 22385, 24623, 27086, 29794, 32767,
23];
24const STEP_CHANGE: [i32; 16] = [-1, -1, -1, -1, 2, 4, 6, 8, -1, -1, -1, -1, 2, 4, 6, 8];
25
26/// Where the ADPCM decoder stands. The encoder follows the same path.
27#[derive(Default)]
28struct Adpcm {
29    sample: i32,
30    step: i32,
31}
32
33#[cfg_attr(feature = "live", ravenrs_live::expose)]
34impl Adpcm {
35    fn take(&mut self, code: u8) -> i32 {
36        let step = STEPS[self.step as usize];
37        let mut change = step >> 3;
38        if code & 4 != 0 {
39            change += step;
40        }
41        if code & 2 != 0 {
42            change += step >> 1;
43        }
44        if code & 1 != 0 {
45            change += step >> 2;
46        }
47        self.sample += if code & 8 != 0 { -change } else { change };
48        self.sample = self.sample.clamp(-32768, 32767);
49        self.step = (self.step + STEP_CHANGE[code as usize]).clamp(0, 88);
50        self.sample
51    }
52
53    fn code_for(&self, sample: i32) -> u8 {
54        let mut step = STEPS[self.step as usize];
55        let difference = sample - self.sample;
56        let mut code = if difference < 0 { 8 } else { 0 };
57        let mut left = difference.abs();
58        for bit in [4, 2, 1] {
59            if left >= step {
60                code |= bit;
61                left -= step;
62            }
63            step >>= 1;
64        }
65        code
66    }
67}
68
69fn encode(samples: &[f32]) -> Vec<u8> {
70    let mut state = Adpcm::default();
71    let mut out = vec![0_u8; samples.len().div_ceil(2)];
72    for (index, sample) in samples.iter().enumerate() {
73        let wanted = (sample.clamp(-1.0, 1.0) * 32767.0) as i32;
74        let code = state.code_for(wanted);
75        state.take(code);
76        out[index / 2] |= if index % 2 == 0 { code } else { code << 4 };
77    }
78    out
79}
80
81fn decode(bytes: &[u8], samples: usize) -> Vec<f32> {
82    let mut state = Adpcm::default();
83    (0..samples)
84        .map(|index| {
85            let byte = bytes.get(index / 2).copied().unwrap_or(0);
86            let code = if index % 2 == 0 { byte & 15 } else { byte >> 4 };
87            state.take(code) as f32 / 32768.0
88        })
89        .collect()
90}
91
92/// `samples` at the rate kept, filtered first so that nothing above it folds back.
93fn resampled(samples: &[f32], rate: u32) -> Vec<f32> {
94    const REACH: isize = 12;
95    if rate == RATE || samples.is_empty() {
96        return samples.to_vec();
97    }
98    let ratio = f64::from(rate) / f64::from(RATE);
99    let cutoff = (f64::from(RATE) / f64::from(rate)).min(1.0) * 0.95;
100    let length = (samples.len() as f64 / ratio) as usize;
101    (0..length)
102        .map(|index| {
103            let center = index as f64 * ratio;
104            let nearest = center.round() as isize;
105            let mut sum = 0.0;
106            let mut weights = 0.0;
107            for offset in -REACH..=REACH {
108                let place = nearest + offset;
109                let Some(sample) = usize::try_from(place).ok().and_then(|place| samples.get(place)) else {
110                    continue;
111                };
112                let distance = place as f64 - center;
113                let angle = std::f64::consts::PI * distance * cutoff;
114                let sinc = if angle.abs() < 1e-9 { 1.0 } else { angle.sin() / angle };
115                let window = 0.5 + 0.5 * (std::f64::consts::PI * distance / (REACH as f64 + 1.0)).cos();
116                sum += f64::from(*sample) * sinc * window;
117                weights += sinc * window;
118            }
119            if weights.abs() < 1e-9 { 0.0 } else { (sum / weights) as f32 }
120        })
121        .collect()
122}
123
124/// `samples` at the level kept, without clipping.
125fn leveled(samples: &[f32]) -> Vec<f32> {
126    let energy: f32 = samples.iter().map(|sample| sample * sample).sum();
127    let level = (energy / samples.len().max(1) as f32).sqrt();
128    let peak = samples.iter().fold(0.0_f32, |peak, sample| peak.max(sample.abs()));
129    if level <= f32::EPSILON || peak <= f32::EPSILON {
130        return samples.to_vec();
131    }
132    let gain = (LEVEL / level).min(LOUDEST / peak);
133    samples.iter().map(|sample| sample * gain).collect()
134}
135
136/// The same number on every run and every machine, for finding a text.
137fn key_of(text: &str) -> u64 {
138    text.bytes()
139        .fold(0xcbf2_9ce4_8422_2325, |hash, byte| (hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3))
140}
141
142/// Recordings of what films say, each found by its text. It shares the bytes, so
143/// cloning it is cheap. Give it to [`MoviePlayer::narration`](crate::MoviePlayer::narration).
144#[derive(Clone, Debug)]
145#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
146pub struct Narration {
147    bytes: &'static [u8],
148    entries: std::rc::Rc<HashMap<u64, (usize, usize)>>,
149}
150
151#[cfg_attr(feature = "live", ravenrs_live::expose)]
152impl Narration {
153    /// Reads what [`Narration::pack`] wrote. Bytes it does not understand hold no
154    /// recordings, and the films use the system's voice.
155    pub fn new(bytes: &'static [u8]) -> Self {
156        Self { bytes, entries: std::rc::Rc::new(entries_of(bytes).unwrap_or_default()) }
157    }
158
159    /// The recording of `text`, if there is one.
160    pub fn clip(&self, text: &str) -> Option<SoundClip> {
161        let (samples, start) = *self.entries.get(&key_of(text))?;
162        let body = self.bytes.get(start..start + samples.div_ceil(2))?;
163        Some(SoundClip::new(decode(body, samples), RATE))
164    }
165
166    /// How long the recording of `text` lasts, in seconds, without decoding it.
167    pub fn seconds(&self, text: &str) -> Option<f64> {
168        let (samples, _) = *self.entries.get(&key_of(text))?;
169        Some(samples as f64 / f64::from(RATE))
170    }
171
172    /// How many recordings it has.
173    pub fn len(&self) -> usize {
174        self.entries.len()
175    }
176
177    /// Whether it has none.
178    pub fn is_empty(&self) -> bool {
179        self.entries.is_empty()
180    }
181
182    /// Packs recordings: each text with its samples, at `rate` a second.
183    pub fn pack(recordings: &[(String, Vec<f32>)], rate: u32) -> Vec<u8> {
184        Self::pack_onto(b"", recordings, rate)
185    }
186
187    /// What [`Narration::pack`] wrote in `bytes`, with `recordings` added. What is in
188    /// `bytes` stays exactly as it was, so that adding a few texts after a film changed
189    /// keeps the takes chosen before; a text in both is the new recording. Bytes it does
190    /// not understand count as no recordings.
191    pub fn pack_onto(bytes: &[u8], recordings: &[(String, Vec<f32>)], rate: u32) -> Vec<u8> {
192        let mut entries: Vec<(u64, usize, Vec<u8>)> = recordings
193            .iter()
194            .map(|(text, samples)| {
195                let samples = leveled(&resampled(samples, rate));
196                (key_of(text), samples.len(), encode(&samples))
197            })
198            .collect();
199        for (key, (samples, start)) in entries_of(bytes).unwrap_or_default() {
200            let new = entries.iter().any(|entry| entry.0 == key);
201            if let Some(body) = bytes.get(start..start + samples.div_ceil(2)).filter(|_| !new) {
202                entries.push((key, samples, body.to_vec()));
203            }
204        }
205        // In a fixed order, so that a text that did not change stays the same in the file.
206        entries.sort_by_key(|entry| entry.0);
207        entries.dedup_by_key(|entry| entry.0);
208        let mut out = Vec::new();
209        out.extend_from_slice(MAGIC);
210        out.extend_from_slice(&RATE.to_le_bytes());
211        out.extend_from_slice(&(entries.len() as u32).to_le_bytes());
212        let mut start = HEADER + entries.len() * ENTRY;
213        for (key, samples, body) in &entries {
214            out.extend_from_slice(&key.to_le_bytes());
215            out.extend_from_slice(&(*samples as u32).to_le_bytes());
216            out.extend_from_slice(&(start as u32).to_le_bytes());
217            start += body.len();
218        }
219        for (_, _, body) in &entries {
220            out.extend_from_slice(body);
221        }
222        out
223    }
224}
225
226fn entries_of(bytes: &[u8]) -> Option<HashMap<u64, (usize, usize)>> {
227    if bytes.get(..4)? != MAGIC || u32::from_le_bytes(bytes.get(4..8)?.try_into().ok()?) != RATE {
228        return None;
229    }
230    let count = u32::from_le_bytes(bytes.get(8..12)?.try_into().ok()?) as usize;
231    let mut entries = HashMap::with_capacity(count);
232    for index in 0..count {
233        let at = HEADER + index * ENTRY;
234        let entry = bytes.get(at..at + ENTRY)?;
235        let key = u64::from_le_bytes(entry[..8].try_into().ok()?);
236        let samples = u32::from_le_bytes(entry[8..12].try_into().ok()?) as usize;
237        let start = u32::from_le_bytes(entry[12..16].try_into().ok()?) as usize;
238        entries.insert(key, (samples, start));
239    }
240    Some(entries)
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    /// A voice-like tone: a low note with a higher one on it.
248    fn tone(rate: u32, seconds: f32) -> Vec<f32> {
249        (0..(rate as f32 * seconds) as usize)
250            .map(|index| {
251                let time = index as f32 / rate as f32;
252                let turn = std::f32::consts::TAU * time;
253                0.3 * (turn * 180.0).sin() + 0.1 * (turn * 1200.0).sin()
254            })
255            .collect()
256    }
257
258    #[test]
259    fn a_recording_comes_back_close_to_what_went_in() {
260        let bytes = Narration::pack(&[("Oi".to_owned(), tone(24_000, 1.0))], 24_000);
261        let narration = Narration::new(Box::leak(bytes.into_boxed_slice()));
262        let clip = narration.clip("Oi").expect("the recording");
263        assert_eq!(narration.seconds("Oi"), Some(1.0));
264        assert_eq!(narration.seconds("Tchau"), None);
265        assert_eq!(clip.rate(), RATE);
266        assert_eq!(clip.samples().len(), RATE as usize);
267        let wanted = leveled(&tone(RATE, 1.0));
268        let noise: f32 = clip.samples().iter().zip(&wanted).skip(800).map(|(a, b)| (a - b).powi(2)).sum();
269        let signal: f32 = wanted.iter().skip(800).map(|a| a * a).sum();
270        assert!(noise / signal < 0.02, "{}", noise / signal);
271    }
272
273    #[test]
274    fn adding_keeps_what_was_there_byte_for_byte() {
275        let before = Narration::pack(
276            &[("Oi".to_owned(), tone(RATE, 0.5)), ("Tchau".to_owned(), tone(RATE, 0.2))],
277            RATE,
278        );
279        let after = Narration::pack_onto(&before, &[("Novo".to_owned(), tone(RATE, 0.3))], RATE);
280        let (old, new) = (
281            Narration::new(Box::leak(before.clone().into_boxed_slice())),
282            Narration::new(Box::leak(after.into_boxed_slice())),
283        );
284        assert_eq!(new.len(), 3);
285        for text in ["Oi", "Tchau"] {
286            assert_eq!(
287                new.clip(text).map(|clip| clip.samples().to_vec()),
288                old.clip(text).map(|clip| clip.samples().to_vec())
289            );
290        }
291        assert_eq!(new.seconds("Novo"), Some(0.3));
292        let again = Narration::pack_onto(&before, &[("Oi".to_owned(), tone(RATE, 1.0))], RATE);
293        let again = Narration::new(Box::leak(again.into_boxed_slice()));
294        assert_eq!((again.len(), again.seconds("Oi")), (2, Some(1.0)), "a new take replaces the old one");
295        assert_eq!(Narration::pack_onto(b"junk", &[], RATE), Narration::pack(&[], RATE));
296    }
297
298    #[test]
299    fn it_takes_a_quarter_of_the_space_of_plain_samples() {
300        let bytes = Narration::pack(&[("Oi".to_owned(), tone(RATE, 1.0))], RATE);
301        assert!(bytes.len() <= (RATE as usize) / 2 + HEADER + ENTRY + 1);
302    }
303
304    #[test]
305    fn a_text_with_no_recording_has_none() {
306        let bytes = Narration::pack(&[("Oi".to_owned(), tone(RATE, 0.1))], RATE);
307        let narration = Narration::new(Box::leak(bytes.into_boxed_slice()));
308        assert_eq!(narration.len(), 1);
309        assert!(narration.clip("Tchau").is_none());
310    }
311
312    #[test]
313    fn bytes_it_does_not_understand_hold_nothing() {
314        assert!(Narration::new(b"not a narration").is_empty());
315        assert!(Narration::new(b"").is_empty());
316    }
317
318    #[test]
319    fn the_same_recordings_make_the_same_file() {
320        let list = |first: &str, second: &str| {
321            Narration::pack(
322                &[(first.to_owned(), tone(RATE, 0.1)), (second.to_owned(), tone(RATE, 0.1))],
323                RATE,
324            )
325        };
326        assert_eq!(list("a", "b"), list("b", "a"));
327    }
328}