super-tts-daemon/src/tts_models/subprocess.rs
Line | Count | Source |
1 | | // SPDX-License-Identifier: GPL-3.0-only |
2 | | //! Host for TTS backends shipped as sandboxed native subprocesses |
3 | | //! (experimental — gated behind the `subprocess-backends` feature). |
4 | | //! |
5 | | //! [`SubprocessBackend`] provisions a backend's model files (downloading from |
6 | | //! `HuggingFace` into the per-backend directory), has |
7 | | //! [`super_engine_daemon::subprocess`] spawn the backend binary into a sandbox |
8 | | //! and load the model, and presents the result through the daemon's |
9 | | //! [`Synthesize`] trait. The sandbox, the socket and the `/v1` handshake are |
10 | | //! the engine's; what is here is what a synthesis backend is asked. The |
11 | | //! backend itself is fully self-contained and shares no code with the daemon. |
12 | | |
13 | | use std::path::Path; |
14 | | use std::sync::Arc; |
15 | | use std::time::Duration; |
16 | | |
17 | | use anyhow::{Context, Result, anyhow}; |
18 | | use async_trait::async_trait; |
19 | | use http_body_util::BodyExt; |
20 | | use log::info; |
21 | | use super_engine_daemon::subprocess::{self as engine, Start, json_headers}; |
22 | | use super_tts_shared::models::protocol::LoadProgress; |
23 | | |
24 | | use crate::tts_models::backends::manifest::{FileSpec, Manifest}; |
25 | | use crate::tts_models::download::DownloadItem; |
26 | | use crate::tts_models::synthesize::{ModelInfo, ModelInfoData, ModelState, Synthesize}; |
27 | | |
28 | | /// A running, sandboxed subprocess backend usable as a [`Synthesize`] model. |
29 | | pub struct SubprocessBackend { |
30 | | backend: engine::SubprocessBackend, |
31 | | model_id: String, |
32 | | info: ModelInfoData, |
33 | | } |
34 | | |
35 | | impl SubprocessBackend { |
36 | | /// Provision the selected model, spawn the sandboxed backend, and load it. |
37 | | /// |
38 | | /// `backend_dir` holds `backend.toml` and the `entrypoint` binary; model |
39 | | /// files are downloaded into `<backend_dir>/<dest>`. `device_pref` is the |
40 | | /// resolved accelerator (`"cpu"`, `"cuda"`, `"rocm"`, `"metal"`, |
41 | | /// `"vulkan"`), or empty when none resolved, which leaves the backend to |
42 | | /// select for itself. `host` is what the machine's GPUs actually are, and |
43 | | /// decides which variant of a per-architecture model file is fetched — |
44 | | /// deliberately not `device_pref`, so choosing CPU on a CUDA machine does |
45 | | /// not discard the CUDA files already on disk. `context_headers` are the |
46 | | /// already-formed `x-tts-secret-*` / `x-tts-option-*` pairs to inject on |
47 | | /// every request. |
48 | | /// |
49 | | /// A model that declares `prepare_files` has those downloaded first. Its |
50 | | /// backend is then started and sent `POST /v1/prepare`, so it builds its |
51 | | /// GPU kernels while the rest of the files download, and the load after |
52 | | /// the download is an ordinary one. |
53 | | /// |
54 | | /// # Errors |
55 | | /// Returns an error if provisioning, spawning, or loading fails. |
56 | 0 | pub async fn spawn( |
57 | 0 | backend_dir: &Path, |
58 | 0 | model_name: &str, |
59 | 0 | device_pref: &str, |
60 | 0 | host: &crate::registry::host_detect::Host, |
61 | 0 | tracker: Option<&Arc<crate::download_progress::DownloadProgressTracker>>, |
62 | 0 | context_headers: Vec<(String, String)>, |
63 | 0 | ) -> Result<Self> { |
64 | 0 | let manifest = Manifest::load(backend_dir)?; |
65 | | |
66 | 0 | let model = manifest |
67 | 0 | .models |
68 | 0 | .iter() |
69 | 0 | .find(|m| m.name == model_name) |
70 | 0 | .ok_or_else(|| anyhow!("model {model_name} not declared in backend.toml"))?; |
71 | | |
72 | | // Provision ONLY the selected model's files (lazy per model). The |
73 | | // tracker (when present) reports per-file and per-byte progress through |
74 | | // `DownloadStateManager` so the settings app's progress bar updates in |
75 | | // real time. Each file carries its own URL and destination; `parse` |
76 | | // already validated every `destination` as a safe relative path, so the |
77 | | // join below cannot escape the backend dir. |
78 | | // |
79 | | // Entries sharing a destination are per-architecture variants of one |
80 | | // file, so the list is resolved against the host first: exactly one |
81 | | // variant per destination is fetched, and a manifest that declares no |
82 | | // selector — every v1 manifest — comes back as the files it wrote. |
83 | | // `instantiate_subprocess` resolves the same list to size the progress |
84 | | // card, and both go through this one function so the count and the |
85 | | // downloads cannot disagree. |
86 | | // |
87 | | // The files a prepare reads go first, in the order the manifest lists |
88 | | // them. `prepare_files` names destinations, and the selection keeps |
89 | | // one variant per destination, so each is matched whichever variant |
90 | | // this host takes. |
91 | 0 | let selected = crate::registry::compat::select_files(host, model) |
92 | 0 | .map_err(|reason| anyhow!("provisioning {model_name}: {reason}"))?; |
93 | 0 | let (prepare_files, rest_files): (Vec<_>, Vec<_>) = selected |
94 | 0 | .into_iter() |
95 | 0 | .partition(|spec| model.prepare_files.contains(&spec.destination)); |
96 | 0 | let item = |spec: &FileSpec| DownloadItem { |
97 | 0 | url: spec.url.clone(), |
98 | 0 | destination: backend_dir.join(&spec.destination), |
99 | 0 | sha256: spec.sha256.clone(), |
100 | 0 | }; |
101 | 0 | let prepare_items: Vec<_> = prepare_files.into_iter().map(item).collect(); |
102 | 0 | let rest_items: Vec<_> = rest_files.into_iter().map(item).collect(); |
103 | 0 | info!( |
104 | | "provisioning {model_name}: {} files into {}", |
105 | 0 | prepare_items.len() + rest_items.len(), |
106 | 0 | backend_dir.display() |
107 | | ); |
108 | | |
109 | | // Once every file is on disk, spawning the sandboxed unit and loading |
110 | | // weights onto the device is the slow tail (tens of seconds for a |
111 | | // multi-GB model on GPU) but isn't byte-tracked — flip the tracker |
112 | | // to "loading_model" so the settings app swaps the full download |
113 | | // bar for a "Loading model into memory…" indicator instead of |
114 | | // freezing on a full bar. |
115 | 0 | let mark_loading = || { |
116 | 0 | if let Some(t) = tracker { |
117 | 0 | t.mark_loading(); |
118 | 0 | t.broadcast_progress(); |
119 | 0 | } |
120 | 0 | }; |
121 | | |
122 | 0 | let start = Start { |
123 | 0 | backend_dir, |
124 | 0 | entrypoint: &manifest.backend.entrypoint, |
125 | 0 | model: model_name, |
126 | 0 | provider: model.provider.as_deref(), |
127 | 0 | devices: &model.supported_devices, |
128 | 0 | device_pref, |
129 | 0 | context_headers, |
130 | 0 | }; |
131 | 0 | let mut backend = if model.prepare_files.is_empty() { |
132 | 0 | provision(model_name, &rest_items, tracker, 0).await?; |
133 | 0 | mark_loading(); |
134 | 0 | engine::SubprocessBackend::start(&super_tts_shared::SUPER_TTS, start).await? |
135 | | } else { |
136 | 0 | provision(model_name, &prepare_items, tracker, 0).await?; |
137 | | // Dropping the backend stops it, so a download that fails or is |
138 | | // cancelled below takes the prepare down with it. |
139 | 0 | let backend = |
140 | 0 | engine::SubprocessBackend::start(&super_tts_shared::SUPER_TTS, start).await?; |
141 | 0 | backend.prepare().await; |
142 | 0 | provision(model_name, &rest_items, tracker, prepare_items.len()).await?; |
143 | 0 | mark_loading(); |
144 | 0 | backend |
145 | | }; |
146 | | |
147 | | // What the backend reports of its own load goes to the tracker, so the |
148 | | // card can say what the load is doing rather than sit on a full bar. |
149 | 0 | let forward = tracker.map(|t| { |
150 | 0 | move |load: LoadProgress| { |
151 | 0 | t.set_load_progress(load); |
152 | 0 | t.broadcast_progress(); |
153 | 0 | } |
154 | 0 | }); |
155 | 0 | backend |
156 | 0 | .load( |
157 | 0 | forward |
158 | 0 | .as_ref() |
159 | 0 | .map(|f| f as &(dyn Fn(LoadProgress) + Send + Sync)), |
160 | | ) |
161 | 0 | .await?; |
162 | | |
163 | 0 | let interval = model |
164 | 0 | .processing_interval_ms |
165 | 0 | .map_or_else(|| Duration::from_secs(2), Duration::from_millis); |
166 | 0 | let info = ModelInfoData::new( |
167 | 0 | model_name, |
168 | 0 | manifest.backend.source.clone(), |
169 | 0 | model.multilingual, |
170 | 0 | model.is_online(), |
171 | 0 | interval, |
172 | | ); |
173 | | |
174 | 0 | Ok(Self { |
175 | 0 | backend, |
176 | 0 | model_id: model_name.to_string(), |
177 | 0 | info, |
178 | 0 | }) |
179 | 0 | } |
180 | | |
181 | | /// The headers a model request carries: the JSON body, and which model |
182 | | /// it is for. |
183 | 0 | fn model_headers(&self) -> Vec<(String, String)> { |
184 | 0 | let mut headers = json_headers(); |
185 | 0 | headers.push(("x-tts-model".to_string(), self.model_id.clone())); |
186 | 0 | headers |
187 | 0 | } |
188 | | |
189 | | /// `POST /v1/synthesize` — drive one synthesis and feed `sink` as frames |
190 | | /// decode. |
191 | | /// |
192 | | /// The body is read incrementally rather than collected: a subprocess |
193 | | /// backend writes PCM as it produces it, so the daemon can begin playing |
194 | | /// well before synthesis ends. |
195 | | /// |
196 | | /// # Errors |
197 | | /// Returns an error if the socket dial or request fails, the response is |
198 | | /// not `200`, the headers do not describe usable audio, or the frame stream |
199 | | /// is malformed. |
200 | 0 | pub async fn synthesize( |
201 | 0 | &self, |
202 | 0 | req: &crate::tts_models::v1::SynthesizeRequest<'_>, |
203 | 0 | sink: &mut (dyn crate::tts_models::v1::SynthesisSink + Send), |
204 | 0 | ) -> Result<()> { |
205 | 0 | let body = crate::tts_models::v1::build_synthesize_body(req)?; |
206 | | // The connection is held until the body has been read: the response |
207 | | // is not complete when its head arrives. |
208 | 0 | let (resp, _connection) = self |
209 | 0 | .backend |
210 | 0 | .send("POST", "/v1/synthesize", &self.model_headers(), body) |
211 | 0 | .await?; |
212 | 0 | let status = resp.status().as_u16(); |
213 | 0 | let (parts, body) = resp.into_parts(); |
214 | 0 | if status == 200 { |
215 | 0 | crate::tts_models::v1::pump_synthesis(&parts.headers, body, sink).await |
216 | | } else { |
217 | 0 | let collected = body.collect().await?.to_bytes(); |
218 | 0 | Err(crate::tts_models::v1::synthesize_error(status, &collected)) |
219 | | } |
220 | 0 | } |
221 | | |
222 | | /// `POST /v1/voices` — hand the backend one cloned voice's reference |
223 | | /// audio. |
224 | | /// |
225 | | /// # Errors |
226 | | /// Returns an error if the socket dial fails or the backend refuses the |
227 | | /// clip. |
228 | 0 | pub async fn register_voice( |
229 | 0 | &self, |
230 | 0 | req: &crate::tts_models::v1::RegisterVoiceRequest<'_>, |
231 | 0 | ) -> Result<()> { |
232 | 0 | let body = crate::tts_models::v1::build_register_voice_body(req)?; |
233 | 0 | let (status, resp) = self |
234 | 0 | .backend |
235 | 0 | .request("POST", "/v1/voices", &self.model_headers(), body) |
236 | 0 | .await?; |
237 | 0 | if (200..300).contains(&status) { |
238 | 0 | return Ok(()); |
239 | 0 | } |
240 | 0 | Err(crate::tts_models::v1::voice_error(status, &resp)) |
241 | 0 | } |
242 | | |
243 | | /// `DELETE /v1/voices/{voice}` — drop a registered cloned voice. |
244 | | /// |
245 | | /// The id is percent-encoded into the path, so the `voice:` prefix travels |
246 | | /// as one path segment whatever a backend's router does with a raw colon. |
247 | | /// |
248 | | /// # Errors |
249 | | /// Returns an error if the socket dial fails or the backend refuses. A |
250 | | /// `404` is success: the goal is that the backend not hold the voice. |
251 | 0 | pub async fn unregister_voice(&self, voice: &str) -> Result<()> { |
252 | 0 | let path = format!("/v1/voices/{}", urlencoding::encode(voice)); |
253 | 0 | let (status, resp) = self |
254 | 0 | .backend |
255 | 0 | .request("DELETE", &path, &[], Vec::new()) |
256 | 0 | .await?; |
257 | 0 | if status == 404 || (200..300).contains(&status) { |
258 | 0 | return Ok(()); |
259 | 0 | } |
260 | 0 | Err(crate::tts_models::v1::voice_error(status, &resp)) |
261 | 0 | } |
262 | | } |
263 | | |
264 | | /// Download `items` for `model_name`, numbering them from `first` on the |
265 | | /// tracker so a load provisioned in two parts counts its files once. |
266 | 0 | async fn provision( |
267 | 0 | model_name: &str, |
268 | 0 | items: &[DownloadItem], |
269 | 0 | tracker: Option<&Arc<crate::download_progress::DownloadProgressTracker>>, |
270 | 0 | first: usize, |
271 | 0 | ) -> Result<()> { |
272 | 0 | crate::tts_models::download::download_files(items, tracker, first) |
273 | 0 | .await |
274 | 0 | .with_context(|| format!("provisioning {model_name}")) |
275 | 0 | } |
276 | | |
277 | | impl ModelInfo for SubprocessBackend { |
278 | 0 | fn info(&self) -> &ModelInfoData { |
279 | 0 | &self.info |
280 | 0 | } |
281 | | } |
282 | | |
283 | | impl ModelState for SubprocessBackend { |
284 | | /// Device label the backend reported at load time (e.g. `"cuda"`). |
285 | 0 | fn device(&self) -> String { |
286 | 0 | self.backend.device().to_string() |
287 | 0 | } |
288 | | } |
289 | | |
290 | | #[async_trait] |
291 | | impl Synthesize for SubprocessBackend { |
292 | | /// Swap the injected secret/option pairs. The next `/v1` request carries |
293 | | /// them; one already in flight keeps the set it was built with. |
294 | | /// |
295 | | /// `user_allowed_hosts` is ignored, and there is nothing here to ignore it |
296 | | /// with: the sandbox gives a subprocess backend no network, so it has no |
297 | | /// egress to authorize and `base_url` means nothing to it. |
298 | 0 | fn reconfigure(&self, context: crate::tts_models::synthesize::BackendContext) { |
299 | 0 | self.backend.set_context_headers(context.headers); |
300 | 0 | } |
301 | | |
302 | | /// Forward to the inherent streaming implementation. |
303 | | async fn synthesize( |
304 | | &self, |
305 | | request: &crate::tts_models::v1::SynthesizeRequest<'_>, |
306 | | sink: &mut (dyn crate::tts_models::v1::SynthesisSink + Send), |
307 | 0 | ) -> Result<()> { |
308 | | Self::synthesize(self, request, sink).await |
309 | 0 | } |
310 | | |
311 | | async fn register_voice( |
312 | | &self, |
313 | | request: &crate::tts_models::v1::RegisterVoiceRequest<'_>, |
314 | 0 | ) -> Result<()> { |
315 | | Self::register_voice(self, request).await |
316 | 0 | } |
317 | | |
318 | 0 | async fn unregister_voice(&self, voice: &str) -> Result<()> { |
319 | | Self::unregister_voice(self, voice).await |
320 | 0 | } |
321 | | |
322 | | /// Stop the sandboxed backend asynchronously and remove the socket file. |
323 | | /// Called by the daemon before the |
324 | | /// [`LoadedModel`](crate::daemon::types::LoadedModel) is dropped — gives |
325 | | /// us a real `.await` instead of blocking the runtime in `Drop`. After |
326 | | /// this returns, the synchronous `Drop` path is a no-op and stays for |
327 | | /// crash paths and tests. |
328 | 0 | async fn shutdown(&mut self) -> Result<()> { |
329 | | self.backend.shutdown().await; |
330 | | Ok(()) |
331 | 0 | } |
332 | | } |
333 | | |
334 | | /// Stop backends left behind by a previous daemon run. See |
335 | | /// [`super_engine_daemon::subprocess::cleanup_orphans`]. |
336 | 70 | pub async fn cleanup_orphan_units() { |
337 | 70 | engine::cleanup_orphans(&super_tts_shared::SUPER_TTS).await; |
338 | 70 | } |