The speech cache: lines spoken once and kept under (voice, exact text). The key is a SHA-256 of the two and what is kept is the audio and the spans, never the words, so a reader of the database sees neither what was said nor in which voice. A device and the service keep the same table.
The cap is in bytes (whiskers_ports::speech_weight). Over it, the entries used longest ago are deleted
first, in the transaction that keeps the new one.
15use crate::Store; 16 17pub struct SqliteSpeechCache { 18 store: Store, 19 cap: u64, 20} 21 22fn key_of(voice: &VoiceId, line: &SpeechLine) -> Vec<u8> { 23 let mut h = Sha256::new(); 24 h.update(voice.as_str().as_bytes()); 25 h.update([0]); 26 h.update(line.as_str().as_bytes()); 27 h.finalize().to_vec() 28} 29 30impl SqliteSpeechCache {
A cache over cap bytes in store.
The cache's own failures are the store's: unavailable, never a panic.
43fn pack(speech: &TimedAudio) -> Vec<u8> { 44 let mut out = Vec::with_capacity(speech.timing().spans().len() * 8); 45 for s in speech.timing().spans() { 46 out.extend_from_slice(&s.start().get().to_le_bytes()); 47 out.extend_from_slice(&s.end().get().to_le_bytes()); 48 } 49 out 50} 51 52fn unpack(blob: &[u8]) -> Option<Vec<(AudioMs, AudioMs)>> { 53 if blob.len() % 8 != 0 { 54 return None; 55 } 56 Some( 57 blob.chunks_exact(8) 58 .map(|c| { 59 let word = |b: &[u8]| u32::from_le_bytes([b[0], b[1], b[2], b[3]]); 60 (AudioMs::new(word(&c[..4])), AudioMs::new(word(&c[4..]))) 61 }) 62 .collect(), 63 ) 64} 65 66impl SpeechCache for SqliteSpeechCache { 67 async fn find(&self, voice: &VoiceId, line: &SpeechLine, _now: Millis) -> Result<Option<TimedAudio>, StoreError> { 68 let key = key_of(voice, line); 69 let found = self 70 .store 71 .transaction(|tx| { 72 let row: Option<(Vec<u8>, Vec<u8>)> = 73 tx.query_row("SELECT audio, spans FROM speech_cache WHERE key = ?1", [&key], |r| Ok((r.get(0)?, r.get(1)?))).optional()?; 74 if row.is_some() { 75 tx.execute("UPDATE speech_cache SET used = (SELECT COALESCE(MAX(used), 0) + 1 FROM speech_cache) WHERE key = ?1", [&key])?; 76 } 77 Ok(row) 78 }) 79 .map_err(|e| Self::unavailable("cannot be read", e))?; 80 let Some((audio, spans)) = found else { return Ok(None) }; 81 let rebuilt = unpack(&spans) 82 .and_then(|spans| LineTiming::new(line.as_str().to_owned(), spans).ok()) 83 .and_then(|timing| TimedAudio::new(Audio(audio), timing, line).ok()); 84 match rebuilt { 85 Some(t) => { 86 trace!("speech cache: found {} for {}, {} bytes", voice.tag(), line.chars(), t.audio().0.len()); 87 Ok(Some(t)) 88 } 89 None => { 90 // Spans that are not of this line: an entry that cannot be used is removed, not trusted. 91 error!("a speech cache entry is damaged ({} chars); removing it", line.chars()); 92 self.store.transaction(|tx| Ok(tx.execute("DELETE FROM speech_cache WHERE key = ?1", [&key])?)).map_err(|e| Self::unavailable("cannot drop an entry", e))?; 93 Ok(None) 94 } 95 } 96 } 97 98 async fn keep(&self, voice: &VoiceId, line: &SpeechLine, speech: &TimedAudio, _now: Millis) -> Result<(), StoreError> { 99 let weight = speech_weight(speech); 100 if weight > self.cap { 101 debug!("speech cache: a line of {weight} bytes is over the whole cap of {}; not kept", self.cap); 102 return Ok(()); 103 } 104 let key = key_of(voice, line); 105 let (audio, spans) = (&speech.audio().0, pack(speech)); 106 let evicted = self 107 .store 108 .transaction(|tx| { 109 tx.execute( 110 "INSERT INTO speech_cache (key, audio, spans, weight, used) VALUES (?1, ?2, ?3, ?4, (SELECT COALESCE(MAX(used), 0) + 1 FROM speech_cache)) 111 ON CONFLICT (key) DO UPDATE SET audio = excluded.audio, spans = excluded.spans, weight = excluded.weight, used = excluded.used", 112 params![key, audio, spans, weight as i64], 113 )?; 114 // Oldest out until it fits; the line just kept has the highest `used`, so it goes last. 115 let mut evicted = 0; 116 loop { 117 let total: i64 = tx.query_row("SELECT COALESCE(SUM(weight), 0) FROM speech_cache", [], |r| r.get(0))?; 118 if total as u64 <= self.cap { 119 break; 120 } 121 evicted += tx.execute("DELETE FROM speech_cache WHERE key = (SELECT key FROM speech_cache ORDER BY used LIMIT 1)", [])?; 122 } 123 Ok(evicted) 124 }) 125 .map_err(|e| Self::unavailable("cannot keep a line", e))?; 126 debug!("speech cache: kept {} bytes for {}, {evicted} older lines dropped", weight, voice.tag()); 127 Ok(()) 128 } 129 130 async fn size(&self) -> Result<CacheSize, StoreError> { 131 let conn = self.store.lock(); 132 conn.query_row("SELECT COUNT(*), COALESCE(SUM(weight), 0) FROM speech_cache", [], |r| Ok(CacheSize { entries: r.get::<_, i64>(0)? as u64, bytes: r.get::<_, i64>(1)? as u64 })) 133 .map_err(|e| Self::unavailable("cannot be measured", e)) 134 } 135}