aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-04 01:11:10 +0100
committersalmonik <salmonik@tuta.io>2026-11-04 01:11:10 +0100
commit267ebcb87b4e22859d43a618aa14dce2045afa29 (patch)
tree73a8a83312d49313baed71e495fdac0ee3bcbefc
parentautocomplete: bound the in-memory caches (diff)
downloadsenator-sperma-267ebcb87b4e22859d43a618aa14dce2045afa29.tar.gz
senator-sperma-267ebcb87b4e22859d43a618aa14dce2045afa29.zip
tts: bound the audio cache and request length
-rw-r--r--src/tts.rs91
1 files changed, 86 insertions, 5 deletions
diff --git a/src/tts.rs b/src/tts.rs
index 3ae5140..dbe0d88 100644
--- a/src/tts.rs
+++ b/src/tts.rs
@@ -1,6 +1,7 @@
1use std::io::Cursor; 1use std::io::Cursor;
2use std::path::PathBuf; 2use std::path::PathBuf;
3use std::sync::OnceLock; 3use std::sync::OnceLock;
4use std::sync::atomic::{AtomicUsize, Ordering};
4use std::time::{Duration, Instant}; 5use std::time::{Duration, Instant};
5 6
6use dashmap::DashMap; 7use 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.
19const CACHE_TTL: Duration = Duration::from_secs(3600); 20const CACHE_TTL: Duration = Duration::from_secs(3600);
20 21
22/// Hard cap on cached audio so arbitrary `/tts` text cannot exhaust memory.
23const CACHE_MAX_ENTRIES: usize = 256;
24const CACHE_MAX_BYTES: usize = 64 * 1024 * 1024;
25
26/// Longest text accepted for synthesis. Keeps a single request (and its
27/// cached PCM) bounded.
28const MAX_TTS_CHARS: usize = 2_000;
29
21/// All voice-state TTS event types. 30/// All voice-state TTS event types.
22const EVENT_TYPES: &[&str] = &[ 31const EVENT_TYPES: &[&str] = &[
23 "joined", 32 "joined",
@@ -37,6 +46,7 @@ const EVENT_TYPES: &[&str] = &[
37pub struct TtsState { 46pub 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)]
353mod 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}