1//! Which voice speaks a line, which voices there are, and the cache of lines already spoken.
2//!
3//! A cached line is keyed by the voice and the exact text. What is kept is the audio and the time of every
4//! character, not the text: a reader of the store sees spans, and the line is put back from the request that
5//! asked for it. Only a line the caller declared fixed is ever kept (see [`SpeechCache::keep`]).
6
7use std::fmt;
8
9use serde::Serialize;
10
11use crate::error::{Diagnostic, StoreError};
12use crate::time::Millis;
13use crate::timing::TimedAudio;
14use crate::voice::SpeechLine;
15
16pub use whiskers_core::{NotAVoiceId, VOICE_ID_MAX, VoiceId};
17
18/// A voice the account can use: its id and the name a child is told.
19#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
20pub struct VoiceInfo {
21    pub id: VoiceId,
22    pub name: String,
23}
24
25/// Why the list of voices could not be read.
26#[derive(Clone, Debug, PartialEq, Eq)]
27pub enum VoicesError {
28    NotConfigured,
29    Unreachable(Diagnostic),
30    Refused { status: u16, said: Diagnostic },
31    Unreadable(Diagnostic),
32}
33
34impl fmt::Display for VoicesError {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        match self {
37            VoicesError::NotConfigured => f.write_str("the voice is not configured"),
38            VoicesError::Unreachable(d) | VoicesError::Unreadable(d) | VoicesError::Refused { said: d, .. } => d.fmt(f),
39        }
40    }
41}
42
43impl std::error::Error for VoicesError {}
44
45/// What the cache holds.
46#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
47pub struct CacheSize {
48    pub entries: u64,
49    /// The bytes of audio and timing kept.
50    pub bytes: u64,
51}
52
53/// Spoken lines kept so that a fixed line costs voice characters once.
54///
55/// Contract, for every adapter:
56///
57/// - **Exact.** A line is found only for the voice and the exact text it was kept under.
58/// - **Bounded, least recently used out.** Over its cap the adapter drops the entries used longest ago, never
59///   the one just kept; a line that alone is over the cap is not kept. `find` counts as a use.
60/// - **No words.** What is kept is audio and spans. The text is the caller's.
61/// - **Only what the caller declared fixed.** The service calls `keep` only for a line its caller said was the
62///   same every time (a page name, a hint, a greeting, a preview). Words that came from a memory are never
63///   declared, so a forgotten memory leaves nothing here to forget.
64#[expect(async_fn_in_trait, reason = "a Worker's futures hold JavaScript values and cannot be Send, so no Send bound may be required here")]
65pub trait SpeechCache {
66    /// The line as kept for this voice, or `None`.
67    async fn find(&self, voice: &VoiceId, line: &SpeechLine, now: Millis) -> Result<Option<TimedAudio>, StoreError>;
68
69    /// Keeps the line's speech under (voice, text), replacing an older copy.
70    async fn keep(&self, voice: &VoiceId, line: &SpeechLine, speech: &TimedAudio, now: Millis) -> Result<(), StoreError>;
71
72    async fn size(&self) -> Result<CacheSize, StoreError>;
73}
74
75/// The bytes one cached line takes against the cap: its audio and eight bytes of timing per character.
76pub fn weight(speech: &TimedAudio) -> u64 {
77    speech.audio().0.len() as u64 + 8 * speech.timing().spans().len() as u64
78}
79
80/// The cap of the service's speech cache, in bytes: about two thousand lines of speech.
81pub const SPEECH_CACHE_BYTES: u64 = 64 * 1024 * 1024;
82
83/// The cap of a device's speech cache, in bytes.
84pub const DEVICE_SPEECH_CACHE_BYTES: u64 = 32 * 1024 * 1024;