diff options
| author | salmonik <salmonik@tuta.io> | 2026-11-04 01:12:12 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-11-04 01:12:12 +0100 |
| commit | f672d84951cf7dea4f47429475b109426f2be944 (patch) | |
| tree | c6b39d0af495ee7612a45f84f290cf23babba541 | |
| parent | processes: add timeouts and keep decoding off the runtime (diff) | |
| download | senator-sperma-f672d84951cf7dea4f47429475b109426f2be944.tar.gz senator-sperma-f672d84951cf7dea4f47429475b109426f2be944.zip | |
tts: run piper loading and synthesis on blocking threads
| -rw-r--r-- | src/tts.rs | 44 |
1 files changed, 28 insertions, 16 deletions
| @@ -1,12 +1,11 @@ | |||
| 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; | ||
| 4 | use std::sync::atomic::{AtomicUsize, Ordering}; | 3 | use std::sync::atomic::{AtomicUsize, Ordering}; |
| 4 | use std::sync::{Arc, Mutex, OnceLock}; | ||
| 5 | use std::time::{Duration, Instant}; | 5 | use std::time::{Duration, Instant}; |
| 6 | 6 | ||
| 7 | use dashmap::DashMap; | 7 | use dashmap::DashMap; |
| 8 | use piper_rs::Piper; | 8 | use piper_rs::Piper; |
| 9 | use tokio::sync::Mutex; | ||
| 10 | use tracing::{debug, info, warn}; | 9 | use tracing::{debug, info, warn}; |
| 11 | 10 | ||
| 12 | use crate::error::{Error, Result}; | 11 | use crate::error::{Error, Result}; |
| @@ -44,7 +43,7 @@ const EVENT_TYPES: &[&str] = &[ | |||
| 44 | /// Held in poise `Data`. Piper is loaded lazily on first synthesis call. | 43 | /// Held in poise `Data`. Piper is loaded lazily on first synthesis call. |
| 45 | /// Cache maps full text string → resampled 48 kHz stereo PCM bytes. | 44 | /// Cache maps full text string → resampled 48 kHz stereo PCM bytes. |
| 46 | pub struct TtsState { | 45 | pub struct TtsState { |
| 47 | piper: OnceLock<Mutex<Piper>>, | 46 | piper: OnceLock<Arc<Mutex<Piper>>>, |
| 48 | cache: DashMap<String, (Vec<u8>, Instant)>, | 47 | cache: DashMap<String, (Vec<u8>, Instant)>, |
| 49 | cache_bytes: AtomicUsize, | 48 | cache_bytes: AtomicUsize, |
| 50 | model_path: Option<PathBuf>, | 49 | model_path: Option<PathBuf>, |
| @@ -62,9 +61,9 @@ impl TtsState { | |||
| 62 | 61 | ||
| 63 | /// Get or initialize the Piper model. Returns error if model path is | 62 | /// Get or initialize the Piper model. Returns error if model path is |
| 64 | /// unset or model fails to load. | 63 | /// unset or model fails to load. |
| 65 | async fn get_piper(&self) -> Result<&Mutex<Piper>> { | 64 | async fn get_piper(&self) -> Result<Arc<Mutex<Piper>>> { |
| 66 | if let Some(piper) = self.piper.get() { | 65 | if let Some(piper) = self.piper.get() { |
| 67 | return Ok(piper); | 66 | return Ok(piper.clone()); |
| 68 | } | 67 | } |
| 69 | 68 | ||
| 70 | let path = self | 69 | let path = self |
| @@ -88,14 +87,21 @@ impl TtsState { | |||
| 88 | config = %config_path.display(), | 87 | config = %config_path.display(), |
| 89 | "loading piper model" | 88 | "loading piper model" |
| 90 | ); | 89 | ); |
| 91 | let piper = Piper::new(&onnx_path, &config_path).map_err(|e| { | 90 | // Model loading is CPU/IO heavy; keep it off the async runtime too. |
| 92 | Error::Custom(format!("Failed to load piper model: {e}")) | 91 | let piper = tokio::task::spawn_blocking(move || { |
| 93 | })?; | 92 | Piper::new(&onnx_path, &config_path) |
| 93 | .map_err(|e| Error::Custom(format!("Failed to load piper model: {e}"))) | ||
| 94 | }) | ||
| 95 | .await??; | ||
| 94 | info!("piper model loaded successfully"); | 96 | info!("piper model loaded successfully"); |
| 95 | 97 | ||
| 96 | // set() may fail if another task initialized first — that's fine | 98 | // set() may fail if another task initialized first — that's fine |
| 97 | let _ = self.piper.set(Mutex::new(piper)); | 99 | let _ = self.piper.set(Arc::new(Mutex::new(piper))); |
| 98 | Ok(self.piper.get().unwrap()) | 100 | Ok(self |
| 101 | .piper | ||
| 102 | .get() | ||
| 103 | .expect("piper was just initialized") | ||
| 104 | .clone()) | ||
| 99 | } | 105 | } |
| 100 | 106 | ||
| 101 | /// Look up cached audio for a text string. Returns `None` if uncached | 107 | /// Look up cached audio for a text string. Returns `None` if uncached |
| @@ -181,15 +187,21 @@ impl TtsState { | |||
| 181 | return Ok(cached); | 187 | return Ok(cached); |
| 182 | } | 188 | } |
| 183 | 189 | ||
| 184 | // Cache miss — synthesize | 190 | // Cache miss — synthesize on a blocking thread. Piper inference is |
| 191 | // synchronous and can take >100 ms; it must not stall a Tokio worker. | ||
| 185 | let piper = self.get_piper().await?; | 192 | let piper = self.get_piper().await?; |
| 193 | let text_owned = text.to_string(); | ||
| 186 | let started = Instant::now(); | 194 | let started = Instant::now(); |
| 187 | 195 | ||
| 188 | let (samples, sample_rate) = { | 196 | let (samples, sample_rate) = tokio::task::spawn_blocking(move || { |
| 189 | let mut lock = piper.lock().await; | 197 | let mut piper = piper |
| 190 | lock.create(text, false, None, None, None, None) | 198 | .lock() |
| 191 | .map_err(|e| Error::Custom(format!("piper synthesis failed: {e}")))? | 199 | .map_err(|_| Error::Custom("piper mutex poisoned".into()))?; |
| 192 | }; | 200 | piper |
| 201 | .create(&text_owned, false, None, None, None, None) | ||
| 202 | .map_err(|e| Error::Custom(format!("piper synthesis failed: {e}"))) | ||
| 203 | }) | ||
| 204 | .await??; | ||
| 193 | 205 | ||
| 194 | let elapsed_ms = started.elapsed().as_millis() as u64; | 206 | let elapsed_ms = started.elapsed().as_millis() as u64; |
| 195 | debug!( | 207 | debug!( |
