Coverage Report

Created: 2026-11-04 02:14

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
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
}