1use std::sync::atomic::{AtomicBool, Ordering};
14use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
15use std::sync::{Arc, Mutex};
16use std::thread::JoinHandle;
17use std::time::Duration;
18
19use crate::audio::AudioSink;
20use crate::config::Config;
21use crate::engine::{Command, Engine, SayOpts, Snapshot};
22use crate::synth::Synthesizer;
23
24const TICK_INTERVAL: Duration = Duration::from_millis(10);
29
30type SubmitJob = (String, SayOpts, Sender<Result<Option<u64>, String>>);
32
33enum Msg {
34 Cmd(Command),
35 Submit(Box<SubmitJob>),
36 ReplaceSink(Box<dyn AudioSink>),
37 Shutdown,
38}
39
40#[derive(Clone)]
41pub struct EngineHandle {
42 tx: Sender<Msg>,
43 latest: Arc<Mutex<Snapshot>>,
44 thread: Arc<Mutex<Option<JoinHandle<()>>>>,
45 shut_down: Arc<AtomicBool>,
52}
53
54impl EngineHandle {
55 pub fn spawn(
56 cfg: Config,
57 synth: Box<dyn Synthesizer>,
58 sink: Box<dyn AudioSink>,
59 ) -> EngineHandle {
60 let (tx, rx) = mpsc::channel::<Msg>();
61 let engine = Engine::new(cfg, synth, sink);
62 let latest = Arc::new(Mutex::new(engine.snapshot()));
63 let published = latest.clone();
64 let shut_down = Arc::new(AtomicBool::new(false));
65 let shut_down_writer = shut_down.clone();
66
67 let thread = std::thread::Builder::new()
68 .name("sayd-engine".into())
69 .spawn(move || run(engine, rx, published, shut_down_writer))
70 .ok();
71
72 EngineHandle {
73 tx,
74 latest,
75 thread: Arc::new(Mutex::new(thread)),
76 shut_down,
77 }
78 }
79
80 pub fn send(&self, cmd: Command) {
83 let _ = self.tx.send(Msg::Cmd(cmd));
84 }
85
86 pub fn submit(&self, text: String, opts: SayOpts) -> Result<Option<u64>, String> {
88 let (reply_tx, reply_rx) = mpsc::channel();
89 self.tx
90 .send(Msg::Submit(Box::new((text, opts, reply_tx))))
91 .map_err(|_| "engine thread is not running".to_string())?;
92 reply_rx
93 .recv()
94 .map_err(|_| "engine thread stopped before answering".to_string())?
95 }
96
97 pub fn snapshot(&self) -> Snapshot {
99 match self.latest.lock() {
100 Ok(g) => g.clone(),
101 Err(poisoned) => poisoned.into_inner().clone(),
102 }
103 }
104
105 pub fn replace_sink(&self, sink: Box<dyn AudioSink>) {
109 let _ = self.tx.send(Msg::ReplaceSink(sink));
110 }
111
112 pub fn has_shut_down(&self) -> bool {
117 self.shut_down.load(Ordering::Acquire)
118 }
119
120 pub fn shutdown(&self) {
126 let _ = self.tx.send(Msg::Shutdown);
127 let handle = match self.thread.lock() {
128 Ok(mut g) => g.take(),
129 Err(poisoned) => poisoned.into_inner().take(),
130 };
131 if let Some(h) = handle {
132 let _ = h.join();
133 }
134 }
135}
136
137struct ShutDownOnDrop(Arc<AtomicBool>);
146
147impl Drop for ShutDownOnDrop {
148 fn drop(&mut self) {
149 self.0.store(true, Ordering::Release);
150 }
151}
152
153fn run(
154 mut engine: Engine,
155 rx: Receiver<Msg>,
156 published: Arc<Mutex<Snapshot>>,
157 shut_down: Arc<AtomicBool>,
158) {
159 let _guard = ShutDownOnDrop(shut_down);
160
161 loop {
162 match rx.recv_timeout(TICK_INTERVAL) {
163 Ok(Msg::Cmd(c)) => engine.handle(c),
164 Ok(Msg::Submit(job)) => {
165 let (text, opts, reply) = *job;
166 let r = engine.submit(text, opts);
167 let _ = reply.send(r);
168 }
169 Ok(Msg::ReplaceSink(sink)) => engine.replace_sink(sink),
170 Ok(Msg::Shutdown) => break,
177 Err(RecvTimeoutError::Timeout) => {}
178 Err(RecvTimeoutError::Disconnected) => break,
179 }
180
181 engine.tick();
182
183 match published.lock() {
184 Ok(mut g) => *g = engine.snapshot(),
185 Err(poisoned) => *poisoned.into_inner() = engine.snapshot(),
186 }
187
188 if engine.is_shutdown() {
189 break;
190 }
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197 use crate::audio::VecSink;
198 use crate::config::Config;
199 use crate::engine::{Command, SayOpts, State};
200 use crate::synth::StubSynthesizer;
201 use std::time::{Duration, Instant};
202
203 fn handle() -> EngineHandle {
204 EngineHandle::spawn(
205 Config::default(),
206 Box::new(StubSynthesizer::new()),
207 Box::new(VecSink::new(24_000 * 10)),
208 )
209 }
210
211 fn wait_for(h: &EngineHandle, label: &str, f: impl Fn(&crate::engine::Snapshot) -> bool) {
213 let deadline = Instant::now() + Duration::from_secs(5);
214 while Instant::now() < deadline {
215 if f(&h.snapshot()) {
216 return;
217 }
218 std::thread::sleep(Duration::from_millis(5));
219 }
220 panic!("timed out waiting for {label}; snapshot = {:?}", h.snapshot());
221 }
222
223 #[test]
224 fn spawns_idle_and_reports_a_snapshot() {
225 let h = handle();
226 assert_eq!(h.snapshot().state, State::Idle);
227 h.shutdown();
228 }
229
230 #[test]
231 fn submit_returns_the_engines_answer() {
232 let h = handle();
233 let id = h.submit("hello there.".into(), SayOpts::default()).expect("accepted");
234 assert!(id.is_some());
235 h.shutdown();
236 }
237
238 #[test]
239 fn submit_propagates_a_rejection() {
240 let h = EngineHandle::spawn(
241 Config { max_chars: 5, ..Config::default() },
242 Box::new(StubSynthesizer::new()),
243 Box::new(VecSink::new(24_000)),
244 );
245 assert!(h.submit("much too long".into(), SayOpts::default()).is_err());
246 h.shutdown();
247 }
248
249 #[test]
250 fn the_engine_ticks_on_its_own_thread() {
251 let h = handle();
252 h.submit("hello there. this is a test.".into(), SayOpts::default()).expect("accepted");
253 wait_for(&h, "speaking", |s| s.state == State::Speaking);
254 h.shutdown();
255 }
256
257 #[test]
258 fn commands_reach_the_engine() {
259 let h = handle();
260 h.submit("hello there. this is a test.".into(), SayOpts::default()).expect("accepted");
261 wait_for(&h, "speaking", |s| s.state == State::Speaking);
262 h.send(Command::Stop);
263 wait_for(&h, "idle after stop", |s| s.state == State::Idle && s.queue_len == 0);
264 h.shutdown();
265 }
266
267 #[test]
268 fn the_handle_is_clonable_and_shared_across_threads() {
269 let h = handle();
270 let h2 = h.clone();
271 let t = std::thread::spawn(move || {
272 h2.submit("from another thread.".into(), SayOpts::default())
273 });
274 let r = t.join().expect("thread panicked");
275 assert!(r.expect("accepted").is_some());
276 h.shutdown();
277 }
278
279 #[test]
280 fn shutdown_joins_the_thread_and_does_not_hang() {
281 let h = handle();
282 let start = Instant::now();
283 h.shutdown();
284 assert!(start.elapsed() < Duration::from_secs(5), "shutdown hung");
285 }
286
287 #[test]
288 fn snapshot_after_shutdown_does_not_panic() {
289 let h = handle();
290 let h2 = h.clone();
291 h.shutdown();
292 let _ = h2.snapshot();
294 }
295
296 #[test]
297 fn shutdown_twice_on_the_same_handle_does_not_hang_or_panic() {
298 let h = handle();
299 h.shutdown();
300 let start = Instant::now();
301 h.shutdown();
302 assert!(start.elapsed() < Duration::from_secs(5), "second shutdown hung");
303 }
304
305 #[test]
306 fn concurrent_shutdown_from_two_clones_does_not_hang_or_panic() {
307 let h = handle();
308 let h2 = h.clone();
309 let t1 = std::thread::spawn(move || h.shutdown());
310 let t2 = std::thread::spawn(move || h2.shutdown());
311 t1.join().expect("shutdown panicked on handle 1");
312 t2.join().expect("shutdown panicked on handle 2");
313 }
314
315 fn assert_send_sync<T: Send + Sync>() {}
316
317 #[test]
318 fn handle_is_send_and_sync() {
319 assert_send_sync::<EngineHandle>();
320 }
321
322 #[test]
323 fn replace_sink_forwards_to_the_engine() {
324 let h = EngineHandle::spawn(
327 Config { max_chars: 5, ..Config::default() },
328 Box::new(StubSynthesizer::new()),
329 Box::new(VecSink::new(24_000)),
330 );
331 assert!(h.submit("much too long".into(), SayOpts::default()).is_err());
333 wait_for(&h, "error", |s| s.state == State::Error);
334
335 h.replace_sink(Box::new(VecSink::new(24_000 * 10)));
336 wait_for(&h, "idle after replace_sink", |s| s.state == State::Idle);
337
338 let id = h.submit("hi.".into(), SayOpts::default()).expect("accepted");
340 assert!(id.is_some());
341 h.shutdown();
342 }
343
344 #[test]
345 fn has_shut_down_is_false_until_shutdown_completes() {
346 let h = handle();
347 assert!(!h.has_shut_down());
348 h.shutdown();
349 assert!(h.has_shut_down());
350 }
351
352 #[test]
353 fn has_shut_down_becomes_true_after_a_shutdown_command_over_the_channel() {
354 let h = handle();
355 assert!(!h.has_shut_down());
356 h.send(Command::Shutdown);
357
358 let deadline = Instant::now() + Duration::from_secs(5);
359 while Instant::now() < deadline {
360 if h.has_shut_down() {
361 return;
362 }
363 std::thread::sleep(Duration::from_millis(5));
364 }
365 panic!("timed out waiting for has_shut_down() to become true");
366 }
367
368 #[test]
369 fn has_shut_down_becomes_true_when_the_engine_thread_panics() {
370 struct PhonemizePanics;
377 impl crate::synth::Synthesizer for PhonemizePanics {
378 fn phonemize(&mut self, _text: &str, _voice: &str) -> String {
379 panic!("PhonemizePanics: synthesizer exploded on purpose");
380 }
381 fn fits(&mut self, _phonemes: &str) -> bool {
382 true
383 }
384 fn synth(
385 &mut self,
386 _phonemes: &str,
387 _voice: &str,
388 _speed: f32,
389 ) -> Result<Vec<f32>, String> {
390 Ok(Vec::new())
391 }
392 fn unload(&mut self) {}
393 fn is_loaded(&self) -> bool {
394 true
395 }
396 }
397
398 let previous_hook = std::panic::take_hook();
405 std::panic::set_hook(Box::new(|_| {}));
406
407 let h = EngineHandle::spawn(
408 Config::default(),
409 Box::new(PhonemizePanics),
410 Box::new(VecSink::new(24_000 * 10)),
411 );
412 let _ = h.submit("hello there.".into(), SayOpts::default());
416
417 let deadline = Instant::now() + Duration::from_secs(5);
418 let result = loop {
419 if h.has_shut_down() {
420 break Ok(());
421 }
422 if Instant::now() >= deadline {
423 break Err(());
424 }
425 std::thread::sleep(Duration::from_millis(5));
426 };
427
428 std::panic::set_hook(previous_hook);
429 assert!(
430 result.is_ok(),
431 "timed out waiting for has_shut_down() to become true after a panic"
432 );
433 }
434
435 #[test]
436 fn submit_after_shutdown_returns_an_error_instead_of_hanging() {
437 let h = handle();
442 h.shutdown();
443
444 let h2 = h.clone();
445 let (done_tx, done_rx) = mpsc::channel();
446 std::thread::spawn(move || {
447 let _ = done_tx.send(h2.submit("hello there.".into(), SayOpts::default()));
448 });
449
450 match done_rx.recv_timeout(Duration::from_secs(5)) {
451 Ok(r) => assert!(
452 r.is_err(),
453 "submit after shutdown should be rejected, got {r:?}"
454 ),
455 Err(_) => panic!("submit after shutdown hung instead of returning promptly"),
456 }
457 }
458}