diff options
| author | salmonik <salmonik@tuta.io> | 2026-11-04 01:11:10 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-11-04 01:11:10 +0100 |
| commit | 267ebcb87b4e22859d43a618aa14dce2045afa29 (patch) | |
| tree | 73a8a83312d49313baed71e495fdac0ee3bcbefc | |
| parent | autocomplete: bound the in-memory caches (diff) | |
| download | senator-sperma-267ebcb87b4e22859d43a618aa14dce2045afa29.tar.gz senator-sperma-267ebcb87b4e22859d43a618aa14dce2045afa29.zip | |
tts: bound the audio cache and request length
| -rw-r--r-- | src/tts.rs | 91 |
1 files changed, 86 insertions, 5 deletions
| @@ -1,6 +1,7 @@ | |||
| 1 | use std::io::Cursor; | 1 | use std::io::Cursor; |
| 2 | use std::path::PathBuf; | 2 | use std::path::PathBuf; |
| 3 | use std::sync::OnceLock; | 3 | use std::sync::OnceLock; |
| 4 | use std::sync::atomic::{AtomicUsize, Ordering}; | ||
| 4 | use std::time::{Duration, Instant}; | 5 | use std::time::{Duration, Instant}; |
| 5 | 6 | ||
| 6 | use dashmap::DashMap; | 7 | use dashmap::DashMap; |
| @@ -18,6 +19,14 @@ const TARGET_SAMPLE_RATE: u32 = 48_000; | |||
| 18 | /// Cache entries older than this are evicted on access. | 19 | /// Cache entries older than this are evicted on access. |
| 19 | const CACHE_TTL: Duration = Duration::from_secs(3600); | 20 | const CACHE_TTL: Duration = Duration::from_secs(3600); |
| 20 | 21 | ||
| 22 | /// Hard cap on cached audio so arbitrary `/tts` text cannot exhaust memory. | ||
| 23 | const CACHE_MAX_ENTRIES: usize = 256; | ||
| 24 | const CACHE_MAX_BYTES: usize = 64 * 1024 * 1024; | ||
| 25 | |||
| 26 | /// Longest text accepted for synthesis. Keeps a single request (and its | ||
| 27 | /// cached PCM) bounded. | ||
| 28 | const MAX_TTS_CHARS: usize = 2_000; | ||
| 29 | |||
| 21 | /// All voice-state TTS event types. | 30 | /// All voice-state TTS event types. |
| 22 | const EVENT_TYPES: &[&str] = &[ | 31 | const EVENT_TYPES: &[&str] = &[ |
| 23 | "joined", | 32 | "joined", |
| @@ -37,6 +46,7 @@ const EVENT_TYPES: &[&str] = &[ | |||
| 37 | pub struct TtsState { | 46 | pub struct TtsState { |
| 38 | piper: OnceLock<Mutex<Piper>>, | 47 | piper: OnceLock<Mutex<Piper>>, |
| 39 | cache: DashMap<String, (Vec<u8>, Instant)>, | 48 | cache: DashMap<String, (Vec<u8>, Instant)>, |
| 49 | cache_bytes: AtomicUsize, | ||
| 40 | model_path: Option<PathBuf>, | 50 | model_path: Option<PathBuf>, |
| 41 | } | 51 | } |
| 42 | 52 | ||
| @@ -45,6 +55,7 @@ impl TtsState { | |||
| 45 | Self { | 55 | Self { |
| 46 | piper: OnceLock::new(), | 56 | piper: OnceLock::new(), |
| 47 | cache: DashMap::new(), | 57 | cache: DashMap::new(), |
| 58 | cache_bytes: AtomicUsize::new(0), | ||
| 48 | model_path, | 59 | model_path, |
| 49 | } | 60 | } |
| 50 | } | 61 | } |
| @@ -93,22 +104,65 @@ impl TtsState { | |||
| 93 | let entry = self.cache.get(text)?; | 104 | let entry = self.cache.get(text)?; |
| 94 | if entry.1.elapsed() > CACHE_TTL { | 105 | if entry.1.elapsed() > CACHE_TTL { |
| 95 | drop(entry); | 106 | drop(entry); |
| 96 | self.cache.remove(text); | 107 | if let Some((old, _)) = self.cache.remove(text) { |
| 108 | self.cache_bytes.fetch_sub(old.len(), Ordering::Relaxed); | ||
| 109 | } | ||
| 97 | None | 110 | None |
| 98 | } else { | 111 | } else { |
| 99 | Some(entry.0.clone()) | 112 | Some(entry.0.clone()) |
| 100 | } | 113 | } |
| 101 | } | 114 | } |
| 102 | 115 | ||
| 103 | /// Store audio bytes in the cache. | 116 | /// Store audio bytes in the cache, evicting expired/old entries first. |
| 104 | fn cache_store(&self, text: String, audio: Vec<u8>) { | 117 | fn cache_store(&self, text: String, audio: Vec<u8>) { |
| 105 | self.cache.insert(text, (audio, Instant::now())); | 118 | let audio_len = audio.len(); |
| 119 | if audio_len > CACHE_MAX_BYTES { | ||
| 120 | return; | ||
| 121 | } | ||
| 122 | self.cache_evict(); | ||
| 123 | while self.cache.len() >= CACHE_MAX_ENTRIES { | ||
| 124 | if !self.cache_evict_oldest() { | ||
| 125 | break; | ||
| 126 | } | ||
| 127 | } | ||
| 128 | while self.cache_bytes.load(Ordering::Relaxed) + audio_len > CACHE_MAX_BYTES { | ||
| 129 | if !self.cache_evict_oldest() { | ||
| 130 | break; | ||
| 131 | } | ||
| 132 | } | ||
| 133 | |||
| 134 | if let Some((old, _)) = self.cache.insert(text, (audio, Instant::now())) { | ||
| 135 | self.cache_bytes.fetch_sub(old.len(), Ordering::Relaxed); | ||
| 136 | } | ||
| 137 | self.cache_bytes.fetch_add(audio_len, Ordering::Relaxed); | ||
| 106 | } | 138 | } |
| 107 | 139 | ||
| 108 | /// Evict all expired cache entries. | 140 | /// Evict all expired cache entries. |
| 109 | fn cache_evict(&self) { | 141 | fn cache_evict(&self) { |
| 110 | self.cache | 142 | let bytes = &self.cache_bytes; |
| 111 | .retain(|_, (_, inserted_at)| inserted_at.elapsed() <= CACHE_TTL); | 143 | self.cache.retain(|_, (audio, inserted_at)| { |
| 144 | let keep = inserted_at.elapsed() <= CACHE_TTL; | ||
| 145 | if !keep { | ||
| 146 | bytes.fetch_sub(audio.len(), Ordering::Relaxed); | ||
| 147 | } | ||
| 148 | keep | ||
| 149 | }); | ||
| 150 | } | ||
| 151 | |||
| 152 | /// Evict the least-recently-inserted entry. Returns false when empty. | ||
| 153 | fn cache_evict_oldest(&self) -> bool { | ||
| 154 | let oldest = self | ||
| 155 | .cache | ||
| 156 | .iter() | ||
| 157 | .min_by_key(|entry| entry.value().1) | ||
| 158 | .map(|entry| entry.key().clone()); | ||
| 159 | let Some(key) = oldest else { | ||
| 160 | return false; | ||
| 161 | }; | ||
| 162 | if let Some((old, _)) = self.cache.remove(&key) { | ||
| 163 | self.cache_bytes.fetch_sub(old.len(), Ordering::Relaxed); | ||
| 164 | } | ||
| 165 | true | ||
| 112 | } | 166 | } |
| 113 | 167 | ||
| 114 | /// Synthesize text → resampled 48 kHz stereo PCM bytes. | 168 | /// Synthesize text → resampled 48 kHz stereo PCM bytes. |
| @@ -116,6 +170,11 @@ impl TtsState { | |||
| 116 | /// Checks cache first. On miss, runs piper inference, resamples, caches, | 170 | /// Checks cache first. On miss, runs piper inference, resamples, caches, |
| 117 | /// and returns. | 171 | /// and returns. |
| 118 | pub async fn synthesize(&self, text: &str) -> Result<Vec<u8>> { | 172 | pub async fn synthesize(&self, text: &str) -> Result<Vec<u8>> { |
| 173 | if text.chars().count() > MAX_TTS_CHARS { | ||
| 174 | return Err(Error::Custom(format!( | ||
| 175 | "TTS text is too long (max {MAX_TTS_CHARS} characters)" | ||
| 176 | ))); | ||
| 177 | } | ||
| 119 | // Cache hit | 178 | // Cache hit |
| 120 | if let Some(cached) = self.cache_lookup(text) { | 179 | if let Some(cached) = self.cache_lookup(text) { |
| 121 | debug!(text_len = text.len(), "tts cache hit"); | 180 | debug!(text_len = text.len(), "tts cache hit"); |
| @@ -289,3 +348,25 @@ pub fn pcm_bytes_to_input(pcm_f32_bytes: Vec<u8>) -> songbird::input::Input { | |||
| 289 | // 2 channels (stereo), 48 kHz — matches TARGET_SAMPLE_RATE | 348 | // 2 channels (stereo), 48 kHz — matches TARGET_SAMPLE_RATE |
| 290 | RawAdapter::new(cursor, TARGET_SAMPLE_RATE, 2).into() | 349 | RawAdapter::new(cursor, TARGET_SAMPLE_RATE, 2).into() |
| 291 | } | 350 | } |
| 351 | |||
| 352 | #[cfg(test)] | ||
| 353 | mod tests { | ||
| 354 | use super::*; | ||
| 355 | |||
| 356 | #[test] | ||
| 357 | fn cache_is_bounded_by_entries_and_bytes() { | ||
| 358 | let state = TtsState::new(None); | ||
| 359 | for i in 0..(CACHE_MAX_ENTRIES + 32) { | ||
| 360 | state.cache_store(format!("phrase-{i}"), vec![0u8; 1024]); | ||
| 361 | } | ||
| 362 | assert!(state.cache.len() <= CACHE_MAX_ENTRIES); | ||
| 363 | assert!(state.cache_bytes.load(Ordering::Relaxed) <= CACHE_MAX_BYTES); | ||
| 364 | } | ||
| 365 | |||
| 366 | #[tokio::test] | ||
| 367 | async fn overlong_text_is_rejected() { | ||
| 368 | let state = TtsState::new(None); | ||
| 369 | let text = "x".repeat(MAX_TTS_CHARS + 1); | ||
| 370 | assert!(state.synthesize(&text).await.is_err()); | ||
| 371 | } | ||
| 372 | } | ||
