super-tts-daemon/src/tts_models/synthesize.rs
Line | Count | Source |
1 | | // SPDX-License-Identifier: GPL-3.0-only |
2 | | //! Unified traits for TTS backends presented to the daemon. |
3 | | //! |
4 | | //! Three layered surfaces: |
5 | | //! - [`ModelInfo`] — static metadata (name, source, capabilities). |
6 | | //! - [`ModelState`] — runtime state that can change after load (device). |
7 | | //! - [`Synthesize`] — actual inference. |
8 | | //! |
9 | | //! `Synthesize` is a supertrait of `ModelState`, which is a supertrait of |
10 | | //! `ModelInfo`, so a `Box<dyn Synthesize>` exposes all three. The concrete |
11 | | //! implementors are the backend hosts ([`WasmBackend`](super::wasm) and |
12 | | //! [`SubprocessBackend`](super::subprocess)); the daemon never sees model |
13 | | //! internals. |
14 | | |
15 | | use anyhow::Result; |
16 | | use async_trait::async_trait; |
17 | | use std::time::Duration; |
18 | | |
19 | | /// Static metadata about a loaded model, built from the discovered backend |
20 | | /// entry that serves it. Self-contained — there is no static registry. |
21 | | #[derive(Debug, Clone)] |
22 | | pub struct ModelInfoData { |
23 | | /// Wire-level model name. |
24 | | pub name: String, |
25 | | /// Repo id of the backend serving this model. |
26 | | pub source: String, |
27 | | /// Whether the model supports multiple languages. |
28 | | pub is_multilingual: bool, |
29 | | /// Whether the model is served by a remote API with no local compute. |
30 | | /// Derived from the model's `supported_devices` (`none` sentinel) at |
31 | | /// construction. |
32 | | pub online: bool, |
33 | | /// Suggested minimum interval between real-time processing chunks. |
34 | | pub processing_interval: Duration, |
35 | | } |
36 | | |
37 | | impl ModelInfoData { |
38 | | /// Build metadata for a discovered backend model. |
39 | | #[must_use] |
40 | 52 | pub fn new( |
41 | 52 | name: impl Into<String>, |
42 | 52 | source: impl Into<String>, |
43 | 52 | is_multilingual: bool, |
44 | 52 | online: bool, |
45 | 52 | processing_interval: Duration, |
46 | 52 | ) -> Self { |
47 | 52 | Self { |
48 | 52 | name: name.into(), |
49 | 52 | source: source.into(), |
50 | 52 | is_multilingual, |
51 | 52 | online, |
52 | 52 | processing_interval, |
53 | 52 | } |
54 | 52 | } |
55 | | } |
56 | | |
57 | | /// Static metadata accessor surface — implemented by every loaded model. |
58 | | pub trait ModelInfo: Send + Sync { |
59 | | /// The underlying metadata payload. |
60 | | fn info(&self) -> &ModelInfoData; |
61 | | /// Wire-level name. |
62 | 0 | fn display_name(&self) -> &str { |
63 | 0 | &self.info().name |
64 | 0 | } |
65 | | |
66 | | /// Whether this model supports multiple languages. |
67 | 0 | fn is_multilingual(&self) -> bool { |
68 | 0 | self.info().is_multilingual |
69 | 0 | } |
70 | | |
71 | | /// Whether this model sends audio to an external API. |
72 | 0 | fn is_online(&self) -> bool { |
73 | 0 | self.info().online |
74 | 0 | } |
75 | | |
76 | | /// Suggested minimum interval between real-time processing chunks. |
77 | 0 | fn processing_interval(&self) -> Duration { |
78 | 0 | self.info().processing_interval |
79 | 0 | } |
80 | | } |
81 | | |
82 | | /// Runtime state that may change after the model is loaded — currently the |
83 | | /// device label the backend reports (e.g. `"cpu"`, `"cuda"`, `"remote"`). |
84 | | pub trait ModelState: ModelInfo { |
85 | | /// Short device label the model runs on, as reported by the backend. |
86 | | fn device(&self) -> String; |
87 | | } |
88 | | |
89 | | /// The settings-derived context a backend runs with: everything the daemon |
90 | | /// resolves from the user's secrets and options, as against what the backend |
91 | | /// declares for itself. |
92 | | /// |
93 | | /// The two fields travel together because one snapshot of the user's options |
94 | | /// produces both, and they have to agree — the headers tell a backend which |
95 | | /// endpoint to dial, the host list is what the sandbox will let it reach. |
96 | | /// Resolved independently, a config write landing between them hands a backend |
97 | | /// one gateway while a different one is authorized, and every request is |
98 | | /// refused until the model is reloaded. |
99 | | /// |
100 | | /// Both halves are read per request — the headers on each `/v1` call, the host |
101 | | /// list on each outbound connection — which is what makes |
102 | | /// [`Synthesize::reconfigure`] possible at all. |
103 | | #[derive(Debug, Clone, Default)] |
104 | | pub struct BackendContext { |
105 | | /// `x-tts-secret-*` / `x-tts-option-*` pairs injected on every `/v1` |
106 | | /// request, per the contract's request-header section. |
107 | | pub headers: Vec<(String, String)>, |
108 | | /// Hosts the *user* authorized through a `base_url` option, which the SSRF |
109 | | /// guard is relaxed for. Empty for a backend declaring no such option, and |
110 | | /// for every subprocess backend — those run under `PrivateNetwork=yes` and |
111 | | /// can dial nothing at all. |
112 | | pub user_allowed_hosts: Vec<String>, |
113 | | } |
114 | | |
115 | | /// Common contract for any TTS backend. |
116 | | /// |
117 | | /// Implementations drive an out-of-tree backend over the `/v1` contract |
118 | | /// (in-process for WASM, over a Unix socket for subprocesses). The trait is |
119 | | /// `async` so callers treat both uniformly. |
120 | | #[async_trait] |
121 | | pub trait Synthesize: ModelState { |
122 | | /// Synthesize speech, feeding `sink` as frames decode. |
123 | | /// |
124 | | /// `sink` is `&mut dyn` rather than a generic so the trait stays |
125 | | /// object-safe: the daemon holds the loaded model as |
126 | | /// `Box<dyn Synthesize>`, and a generic method could not be called through |
127 | | /// it. |
128 | | /// |
129 | | /// Required, not defaulted: synthesis is the only thing a backend exists to |
130 | | /// do, so a host that cannot answer this is not a backend. It was optional |
131 | | /// only while transcription was still the trait's primary call. |
132 | | /// |
133 | | /// # Errors |
134 | | /// Returns an error if the backend cannot be reached, refuses the request, |
135 | | /// or produces a malformed frame stream. |
136 | | async fn synthesize( |
137 | | &self, |
138 | | request: &crate::tts_models::v1::SynthesizeRequest<'_>, |
139 | | sink: &mut (dyn crate::tts_models::v1::SynthesisSink + Send), |
140 | | ) -> Result<()>; |
141 | | |
142 | | /// Register a cloned voice's reference audio with the backend, so later |
143 | | /// syntheses can name it by id alone. |
144 | | /// |
145 | | /// Called once per `(loaded instance, voice)` pair, before the first |
146 | | /// synthesis that uses the voice — not per request. Deriving a speaker |
147 | | /// embedding or encoding reference codes is real work, and the daemon |
148 | | /// issues one synthesis per sentence, so a per-request push would repeat |
149 | | /// that work for every sentence of a paragraph. |
150 | | /// |
151 | | /// Default: unsupported. The daemon only calls this for a model whose |
152 | | /// manifest declares `cloned` in `voice_kinds`, so the default is |
153 | | /// unreachable for a well-formed backend — it exists so a host that has |
154 | | /// not implemented the route says so plainly instead of appearing to |
155 | | /// succeed. |
156 | | /// |
157 | | /// # Errors |
158 | | /// Returns an error if the backend cannot be reached or refuses the clip. |
159 | | async fn register_voice( |
160 | | &self, |
161 | | request: &crate::tts_models::v1::RegisterVoiceRequest<'_>, |
162 | 0 | ) -> Result<()> { |
163 | 0 | let _ = request; |
164 | | anyhow::bail!("this backend does not accept cloned voices") |
165 | 0 | } |
166 | | |
167 | | /// Release a registered cloned voice. |
168 | | /// |
169 | | /// Best-effort housekeeping for a voice the user deleted while the model |
170 | | /// that holds it is still loaded. Default no-op: a backend that keeps |
171 | | /// nothing has nothing to release, and the registration dies with the |
172 | | /// instance either way. |
173 | | /// |
174 | | /// # Errors |
175 | | /// Returns an error if the backend was reachable and refused; a voice the |
176 | | /// backend does not know is not an error. |
177 | 0 | async fn unregister_voice(&self, voice: &str) -> Result<()> { |
178 | 0 | let _ = voice; |
179 | | Ok(()) |
180 | 0 | } |
181 | | |
182 | | /// Run a realtime streaming session, pumping frames between the |
183 | | /// consumer and an upstream until the session ends. Default: |
184 | | /// unsupported. Only WASM backends serving a `realtime` model override |
185 | | /// this. |
186 | | #[cfg(feature = "wasm-backends")] |
187 | | async fn realtime_session( |
188 | | &self, |
189 | | transport: crate::tts_models::wasm::ws_host::ConsumerStreamTransport, |
190 | 0 | ) -> Result<()> { |
191 | | let _ = transport; |
192 | | anyhow::bail!("this model does not support realtime streaming") |
193 | 0 | } |
194 | | |
195 | | /// Replace the settings-derived context this backend was loaded with, so a |
196 | | /// changed option takes effect on the next request instead of the next |
197 | | /// load. |
198 | | /// |
199 | | /// Swapping it is the whole of applying a new option value. Both halves of |
200 | | /// [`BackendContext`] are consulted per request, and none of the expensive |
201 | | /// setup is parameterized by either one — not the component and its |
202 | | /// pre-instantiation, not the spawned unit, not the weights. A reload |
203 | | /// reaches the same state by rebuilding the instance around a fresh |
204 | | /// snapshot, which for a local backend means unmapping and remapping |
205 | | /// several gigabytes to change a number the running process would have read |
206 | | /// off the next request anyway. |
207 | | /// |
208 | | /// Not every option is one a backend can honour mid-flight: the context |
209 | | /// rides `/v1/load` too, so a backend is free to read a value there and |
210 | | /// hold it. That is the backend's own concern. The daemon's job is to make |
211 | | /// the current value available on every request, which this does. |
212 | | /// |
213 | | /// Default no-op, for a host that holds no such context. |
214 | 0 | fn reconfigure(&self, context: BackendContext) { |
215 | 0 | let _ = context; |
216 | 0 | } |
217 | | |
218 | | /// Release any external resources the backend holds. Default no-op. |
219 | | /// |
220 | | /// Subprocess backends override this to stop the `systemd-run --user` |
221 | | /// unit they spawned at load time; in-process WASM backends have |
222 | | /// nothing to release. The daemon calls this *before* dropping the |
223 | | /// [`LoadedModel`](crate::daemon::types::LoadedModel) so the cleanup |
224 | | /// happens in an async context where blocking is appropriate — the |
225 | | /// `Drop` impl is a synchronous safety net for crash paths and tests. |
226 | 4 | async fn shutdown(&mut self) -> Result<()> { |
227 | | Ok(()) |
228 | 4 | } |
229 | | } |