super-tts-daemon/src/daemon/startup.rs
Line | Count | Source |
1 | | // SPDX-License-Identifier: GPL-3.0-only |
2 | | //! Daemon construction + startup orchestration: assembling subsystem handles, |
3 | | //! loading + CLI-overriding the config, and kicking off the background load of |
4 | | //! the configured startup model. Split out of `types.rs` so that file holds |
5 | | //! just the daemon struct, its type aliases, and runtime accessors. |
6 | | |
7 | | use crate::config::DaemonConfig; |
8 | | use crate::daemon::events::EventBus; |
9 | | use crate::daemon::types::{LoadedModel, SharedLoadedModel, SuperTTSDaemon, normalize_device}; |
10 | | use crate::download_progress::DownloadStateManager; |
11 | | use crate::resource_management::ResourceManager; |
12 | | use crate::services::dbus::DBusManager; |
13 | | use crate::tts_models::backends; |
14 | | use anyhow::Result; |
15 | | use log::{info, warn}; |
16 | | use std::sync::{Arc, RwLock}; |
17 | | use tokio::sync::broadcast; |
18 | | |
19 | | /// Pre-assembled subsystem handles created during daemon startup. |
20 | | struct DaemonComponents { |
21 | | shutdown_tx: broadcast::Sender<()>, |
22 | | model: SharedLoadedModel, |
23 | | download_manager: Arc<DownloadStateManager>, |
24 | | resource_manager: Arc<ResourceManager>, |
25 | | dbus_manager: Option<Arc<DBusManager>>, |
26 | | } |
27 | | |
28 | | impl DaemonComponents { |
29 | | /// Instantiate all subsystem handles that do not depend on configuration |
30 | | /// values (those come from `SuperTTSDaemon::load_and_persist_config`). |
31 | 70 | async fn init() -> Self { |
32 | 70 | let (shutdown_tx, _) = broadcast::channel(1); |
33 | | |
34 | | // Model slot starts empty; filled once the startup model loads. |
35 | 70 | let model: SharedLoadedModel = Arc::new(tokio::sync::RwLock::new(None)); |
36 | | |
37 | 70 | let download_manager = Arc::new(DownloadStateManager::new()); |
38 | | |
39 | 70 | let resource_manager = if cfg!(debug_assertions) { |
40 | 70 | Arc::new(ResourceManager::development()) |
41 | | } else { |
42 | 0 | Arc::new(ResourceManager::production()) |
43 | | }; |
44 | | |
45 | | // D-Bus is optional; absence is non-fatal. |
46 | 70 | let dbus_manager = match DBusManager::new().await { |
47 | 62 | Ok(mgr) => Some(Arc::new(mgr)), |
48 | 8 | Err(e) => { |
49 | 8 | warn!("D-Bus initialization failed (this is normal on some systems): {e}"); |
50 | 8 | None |
51 | | } |
52 | | }; |
53 | | |
54 | 70 | Self { |
55 | 70 | shutdown_tx, |
56 | 70 | model, |
57 | 70 | download_manager, |
58 | 70 | resource_manager, |
59 | 70 | dbus_manager, |
60 | 70 | } |
61 | 70 | } |
62 | | } |
63 | | |
64 | | impl SuperTTSDaemon { |
65 | | /// Create a new `SuperTTSDaemon` instance |
66 | | /// |
67 | | /// # Errors |
68 | | /// |
69 | | /// Returns an error if model loading fails. |
70 | 70 | pub async fn new() -> Result<Self> { |
71 | 70 | info!("Initializing Super TTS Daemon..."); |
72 | | |
73 | 70 | let config = Self::load_and_persist_config(); |
74 | | |
75 | | // Extract config fields needed for the struct before config is moved in. |
76 | 70 | let preferred_device = config.device.preferred_device.clone(); |
77 | 70 | let actual_device = preferred_device.clone(); // Will be updated when model loads |
78 | 70 | let active_backend = config.synthesis.active_backend.clone(); |
79 | 70 | let audio_theme = config.audio.theme; |
80 | 70 | let volume = config.audio.volume; |
81 | | |
82 | 70 | let components = DaemonComponents::init().await; |
83 | | // Built here rather than inline so the speech engine can publish onto the |
84 | | // same bus `/events` subscribers read from. |
85 | 70 | let events = Arc::new(EventBus::new()); |
86 | 70 | let voices = Arc::new(crate::voices::VoiceLibrary::new( |
87 | 70 | crate::voices::VoiceLibrary::default_dir(), |
88 | | )); |
89 | | |
90 | 70 | let daemon = SuperTTSDaemon { |
91 | 70 | model: components.model, |
92 | 70 | shutdown_tx: components.shutdown_tx, |
93 | 70 | dbus_manager: components.dbus_manager, |
94 | 70 | events: Arc::clone(&events), |
95 | 70 | audio_theme: Arc::new(RwLock::new(audio_theme)), |
96 | 70 | volume: Arc::new(RwLock::new(volume)), |
97 | 70 | download_manager: components.download_manager, |
98 | 70 | preferred_device: Arc::new(tokio::sync::RwLock::new(preferred_device)), |
99 | 70 | actual_device: Arc::new(tokio::sync::RwLock::new(actual_device)), |
100 | 70 | config: Arc::new(tokio::sync::RwLock::new(config)), |
101 | 70 | resource_manager: components.resource_manager, |
102 | 70 | backends: Arc::new(tokio::sync::RwLock::new(Vec::new())), |
103 | 70 | active_backend: Arc::new(tokio::sync::RwLock::new(active_backend)), |
104 | 70 | notifier: Arc::new(tokio::sync::Mutex::new( |
105 | 70 | crate::output::notification::Notifier::dbus(), |
106 | 70 | )), |
107 | 70 | self_update: Arc::new(crate::self_update::checker()), |
108 | 70 | speech: Arc::new( |
109 | 70 | crate::daemon::speech::SpeechEngine::new() |
110 | 70 | .with_events(Arc::clone(&events)) |
111 | 70 | .with_voices(Arc::clone(&voices)), |
112 | 70 | ), |
113 | 70 | voices, |
114 | 70 | loading: Arc::new(super_engine_daemon::load_gate::LoadGate::default()), |
115 | 70 | }; |
116 | | |
117 | 70 | daemon.post_init().await; |
118 | | |
119 | 70 | Ok(daemon) |
120 | 70 | } |
121 | | |
122 | | /// Load the daemon config from disk, apply any CLI overrides, and persist |
123 | | /// back to disk if anything changed. Returns the ready-to-use config. |
124 | 70 | fn load_and_persist_config() -> DaemonConfig { |
125 | 70 | let config = DaemonConfig::load(); |
126 | 70 | info!("Loaded daemon configuration from disk"); |
127 | 70 | config |
128 | 70 | } |
129 | | |
130 | | /// Perform post-construction startup: discover backends, apply any |
131 | | /// temporary session-level device override, then kick off background |
132 | | /// loading of the startup model (if one is configured). |
133 | 70 | async fn post_init(&self) { |
134 | | // Discover installed backends. |
135 | 70 | self.refresh_backends().await; |
136 | | |
137 | | // Load the configured startup model — if any — in the **background** so |
138 | | // the HTTP listener can come up immediately. A model load may download |
139 | | // gigabytes and bind a GPU; blocking startup on it would leave clients |
140 | | // unable to connect (and unable to pick a lighter model) until it |
141 | | // finishes. Clients watch the `daemon_status_changed` SSE topic for the |
142 | | // `ready` transition. With no configured preference the daemon stays |
143 | | // idle until the user selects a model — it never auto-pulls a model. |
144 | 70 | if let Some((name0 , source0 )) = self.pick_startup_model().await { |
145 | 0 | let bg = self.clone(); |
146 | 0 | tokio::spawn(async move { |
147 | 0 | if let Err(e) = |
148 | 0 | Self::load_initial_model_and_broadcast(&bg, name.clone(), source).await |
149 | | { |
150 | 0 | warn!("Failed to load startup model {name}: {e}; daemon is idle"); |
151 | 0 | bg.download_manager.clear_download(()); |
152 | 0 | } |
153 | 0 | }); |
154 | | } else { |
155 | 70 | info!("No startup model configured; daemon is idle until one is selected"); |
156 | | } |
157 | | |
158 | | // Periodic self-update check: first ~60s after start (don't compete |
159 | | // with model load), then daily. POST /v1/update/check bypasses the |
160 | | // check_enabled gate; this loop honors it. |
161 | | { |
162 | 70 | let bg = self.clone(); |
163 | 70 | let mut shutdown = self.shutdown_tx.subscribe(); |
164 | 70 | tokio::spawn(async move { |
165 | 70 | tokio::select! { |
166 | 70 | () = tokio::time::sleep(std::time::Duration::from_mins(1)) => {}0 |
167 | 70 | _ = shutdown.recv() => return, |
168 | | } |
169 | 0 | let mut interval = tokio::time::interval(std::time::Duration::from_hours(24)); |
170 | 0 | interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); |
171 | | loop { |
172 | | // interval fires immediately on the first tick, which is |
173 | | // exactly the "check on startup" behavior we want. |
174 | 0 | tokio::select! { |
175 | 0 | _ = interval.tick() => {} |
176 | 0 | _ = shutdown.recv() => return, |
177 | | } |
178 | 0 | if bg.config.read().await.update.check_enabled { |
179 | 0 | let _ = bg.run_self_update_check_and_notify().await; |
180 | 0 | } |
181 | | } |
182 | 70 | }); |
183 | | } |
184 | 70 | } |
185 | | |
186 | | /// Record the backend serving `source` as the active one when nothing has |
187 | | /// selected one yet, returning whether it changed anything. |
188 | | /// |
189 | | /// The startup path resolves its model from the legacy |
190 | | /// `preferred_model`/`preferred_source` config, which |
191 | | /// carries no `active_backend` — a config written before that field |
192 | | /// existed loads it as `None`. Without this the daemon speaks |
193 | | /// happily while `GET /active_backend` stays null, so the settings app |
194 | | /// shows its "no backend loaded" empty state and `GET /models` (scoped to |
195 | | /// the active backend) returns nothing. |
196 | | /// |
197 | | /// Guarded on `is_none()`, so it only ever fills a gap: an explicit |
198 | | /// selection through `set_active_backend`/`set_model` always wins. |
199 | | /// |
200 | | /// Persisting is deliberately left to the caller. Keeping this free of |
201 | | /// disk I/O is what lets it be unit-tested without writing over the user's |
202 | | /// real config. |
203 | 4 | async fn adopt_active_backend_for(&self, source: &str) -> bool { |
204 | 4 | if self.active_backend.read().await.is_some() { |
205 | 1 | return false; |
206 | 3 | } |
207 | 3 | let dir_name = { |
208 | 3 | let backends = self.backends.read().await; |
209 | 3 | backends |
210 | 3 | .iter() |
211 | 3 | .find(|b| b.source == source) |
212 | 3 | .and_then(backends::dir_name) |
213 | | }; |
214 | 3 | let Some(dir2 ) = dir_name else { |
215 | 1 | return false; |
216 | | }; |
217 | 2 | *self.active_backend.write().await = Some(dir.clone()); |
218 | 2 | self.config.write().await.synthesis.active_backend = Some(dir); |
219 | 2 | true |
220 | 4 | } |
221 | | |
222 | 0 | async fn load_initial_model_and_broadcast( |
223 | 0 | daemon: &SuperTTSDaemon, |
224 | 0 | name: String, |
225 | 0 | source: String, |
226 | 0 | ) -> Result<()> { |
227 | 0 | daemon.broadcast_model_loading_status(&name); |
228 | | |
229 | | // The persisted model comes back on the device it was persisted with — |
230 | | // its own if it has one, else the global default. Reading the default |
231 | | // alone would quietly undo a per-model choice on every restart. |
232 | 0 | let device_pref = daemon.config.read().await.effective_device(&source, &name); |
233 | 0 | let (instance, definition) = daemon |
234 | 0 | .instantiate_backend(&name, &source, &device_pref) |
235 | 0 | .await?; |
236 | | |
237 | 0 | info!("model {name} loaded successfully"); |
238 | | |
239 | 0 | if daemon.adopt_active_backend_for(&source).await |
240 | 0 | && let Err(e) = daemon.persist_config().await |
241 | | { |
242 | 0 | warn!("Failed to persist active_backend after startup load: {e}"); |
243 | 0 | } |
244 | | |
245 | | // Capture the device label before moving `instance` into the shared |
246 | | // `LoadedModel` — `ready` event consumers (e.g. the settings app's |
247 | | // current_device tracking) only update when `actual_device` is present. |
248 | 0 | let actual_device = normalize_device(&instance.device()); |
249 | 0 | *daemon.actual_device.write().await = actual_device.clone(); |
250 | 0 | *daemon.model.write().await = Some(LoadedModel::new(definition, instance)); |
251 | | |
252 | | // Announce the active model the same way a user-initiated switch does |
253 | | // (`model_switched` + `ready`). The startup load formerly emitted only |
254 | | // `ready`, so a settings app reconnecting after a daemon restart never |
255 | | // learned which model became active and kept showing "no model loaded". |
256 | 0 | daemon.broadcast_model_active(&name, &source, &actual_device); |
257 | 0 | Ok(()) |
258 | 0 | } |
259 | | } |
260 | | |
261 | | #[cfg(test)] |
262 | | #[path = "startup_tests.rs"] |
263 | | mod tests; |