Skip to main content

sayd_core/
engine.rs

1//! The engine: one owner for the queue, the synthesizer and the sink.
2//!
3//! Nothing else in the process may touch that state. Commands come in,
4//! immutable snapshots go out, so the tray, MPRIS and any settings window
5//! cannot disagree about what is playing.
6//!
7//! `tick` does one unit of work and returns. The binary calls it in a loop
8//! between commands; tests call it directly, which is what makes the whole
9//! state machine assertable without threads or timing.
10
11use std::cell::RefCell;
12use std::time::{Duration, Instant};
13
14use crate::audio::AudioSink;
15use crate::chunk::{chunk, refit, Chunk};
16use crate::cleanup::clean;
17use crate::config::Config;
18use crate::queue::{Policy, Queue, Source, Utterance};
19use crate::synth::Synthesizer;
20
21/// Measured: 181.55 s of audio for 498 words in kokoro-eval's passage bench.
22pub const SECONDS_PER_WORD: f64 = 0.365;
23
24#[derive(Copy, Clone, Debug, PartialEq, Eq)]
25pub enum State {
26    Idle,
27    Speaking,
28    Paused,
29    Error,
30}
31
32#[derive(Clone, Debug, Default)]
33pub struct SayOpts {
34    pub policy: Option<Policy>,
35    pub voice: Option<String>,
36    pub speed: Option<f32>,
37    /// Defaults to `Source::DBus`, whose policy is `Enqueue`.
38    pub source: Source,
39}
40
41#[derive(Clone, Debug)]
42pub enum Command {
43    Say { text: String, opts: SayOpts },
44    Pause,
45    Resume,
46    PlayPause,
47    Stop,
48    Next,
49    SkipSentence,
50    ClearQueue,
51    Cancel(u64),
52    SetMuted(bool),
53    SetVoice(String),
54    SetSpeed(f32),
55    Shutdown,
56}
57
58#[derive(Clone, Debug, PartialEq)]
59pub struct Snapshot {
60    pub state: State,
61    pub muted: bool,
62    pub voice: String,
63    pub speed: f32,
64    pub queue_len: usize,
65    pub remaining_secs: f64,
66    pub current_text: String,
67    pub current_id: u64,
68    pub error: Option<String>,
69}
70
71/// The utterance currently being spoken, decomposed into chunks.
72struct Current {
73    id: u64,
74    text: String,
75    voice: String,
76    speed: f32,
77    chunks: Vec<Chunk>,
78    next_chunk: usize,
79    /// Samples produced but not yet accepted by the sink.
80    carry: Vec<f32>,
81}
82
83pub struct Engine {
84    cfg: Config,
85    synth: Box<dyn Synthesizer>,
86    sink: Box<dyn AudioSink>,
87    queue: Queue,
88    current: Option<Current>,
89    state: State,
90    /// Invariant: `error.is_some() <=> state == State::Error`. Every place
91    /// that changes `state` away from or into `Error` must keep this in
92    /// sync in the same step; see `submit`, `dismiss_error_and_go_idle` and
93    /// the pop branch of `tick`.
94    ///
95    /// Second invariant: `state != State::Paused` implies `!sink.is_paused()`.
96    /// `Command::Pause` is the only place that pauses the sink, and it always
97    /// sets `state = Paused` in the same step; every route by which `state`
98    /// can leave `Paused` (`Command::Resume`, and every call to
99    /// `dismiss_error_and_go_idle`, which is the one place that can move
100    /// `state` to `Idle` unconditionally -- including out of `Paused` --
101    /// from `Stop`, `Next`, `SkipSentence` and `SetMuted(true)`) must
102    /// unpause the sink in that same step, or a later command has no way
103    /// left to reach it: `Resume` only fires when `state == Paused`.
104    /// `tick`'s device-failure branch (`Paused -> Error`) is the same kind
105    /// of route and unpauses the sink for the same reason.
106    error: Option<String>,
107    idle_since: Option<Instant>,
108    shutdown: bool,
109}
110
111impl Engine {
112    pub fn new(cfg: Config, synth: Box<dyn Synthesizer>, sink: Box<dyn AudioSink>) -> Self {
113        Engine {
114            cfg,
115            synth,
116            sink,
117            queue: Queue::new(),
118            current: None,
119            state: State::Idle,
120            error: None,
121            idle_since: Some(Instant::now()),
122            shutdown: false,
123        }
124    }
125
126    pub fn is_shutdown(&self) -> bool {
127        self.shutdown
128    }
129
130    pub fn handle(&mut self, cmd: Command) {
131        match cmd {
132            Command::Say { text, opts } => {
133                let _ = self.submit(text, opts);
134            }
135            Command::Pause => {
136                if self.state == State::Speaking {
137                    self.state = State::Paused;
138                    self.sink.set_paused(true);
139                }
140            }
141            Command::Resume => {
142                if self.state == State::Paused {
143                    self.state = State::Speaking;
144                    self.sink.set_paused(false);
145                }
146            }
147            Command::PlayPause => match self.state {
148                State::Speaking => self.handle(Command::Pause),
149                State::Paused => self.handle(Command::Resume),
150                _ => {}
151            },
152            Command::Stop => {
153                // The shut-up verb: always returns to a clean slate, even
154                // from Error. `dismiss_error_and_go_idle` unpauses the sink
155                // (see the pause invariant on the `error` field's doc
156                // comment), so there is no separate `set_paused(false)` here.
157                self.queue.clear();
158                self.discard_current();
159                self.dismiss_error_and_go_idle();
160            }
161            Command::Next => {
162                self.discard_current();
163                if self.queue.is_empty() {
164                    self.dismiss_error_and_go_idle();
165                }
166            }
167            Command::SkipSentence => {
168                self.sink.clear();
169                if let Some(c) = self.current.as_mut() {
170                    c.carry.clear();
171                    if c.next_chunk >= c.chunks.len() {
172                        self.current = None;
173                        if self.queue.is_empty() {
174                            self.dismiss_error_and_go_idle();
175                        }
176                    }
177                } else if self.queue.is_empty() {
178                    self.dismiss_error_and_go_idle();
179                }
180            }
181            Command::ClearQueue => {
182                self.queue.clear();
183            }
184            Command::Cancel(id) => {
185                self.queue.cancel(id);
186            }
187            Command::SetMuted(m) => {
188                self.cfg.muted = m;
189                if m {
190                    self.queue.clear();
191                    self.discard_current();
192                    self.dismiss_error_and_go_idle();
193                }
194            }
195            Command::SetVoice(v) => self.cfg.voice = v,
196            Command::SetSpeed(s) => self.cfg.speed = s.clamp(0.5, 2.0),
197            Command::Shutdown => {
198                self.shutdown = true;
199                self.handle(Command::Stop);
200            }
201        }
202    }
203
204    /// Submit text for synthesis, returning the queued utterance's id on
205    /// acceptance, `None` if accepted but not queued (muted or empty after
206    /// cleanup), or the rejection reason on failure. This is the synchronous
207    /// answer a caller gets back for *its own* submission -- distinct from
208    /// `error`/`state`, which describe the engine as a whole and must not be
209    /// disturbed by a rejection that has nothing to do with whatever else is
210    /// legitimately in flight (see the busy check below).
211    ///
212    /// Returns `Ok(Some(id))` if the text was queued for synthesis.
213    /// Returns `Ok(None)` if the submission was accepted but nothing was
214    /// queued (muted, or empty after cleanup). This is not an error.
215    /// Returns `Err(reason)` if the submission was rejected (e.g. text too long).
216    ///
217    /// `handle(Command::Say { .. })` calls this and discards the result, so
218    /// the existing command path is unchanged for callers that do not care.
219    /// A D-Bus `Say` method or a CLI entry point should call this directly
220    /// to learn whether its own submission was accepted and queued.
221    pub fn submit(&mut self, text: String, opts: SayOpts) -> Result<Option<u64>, String> {
222        if text.chars().count() > self.cfg.max_chars {
223            let msg = format!(
224                "text is {} characters, limit is {}",
225                text.chars().count(),
226                self.cfg.max_chars
227            );
228            // Something unrelated is genuinely still playing (or paused):
229            // this rejection must not stomp on it and report a global Error
230            // when nothing about A is actually wrong. The caller still
231            // learns the submission was refused, via the `Err` returned
232            // here rather than a shared snapshot field.
233            if self.state != State::Speaking && self.state != State::Paused {
234                self.state = State::Error;
235                self.error = Some(msg.clone());
236            }
237            return Err(msg);
238        }
239        if self.state == State::Error {
240            // Error only ever arises with nothing legitimately in flight
241            // (see the branch above and `tick`'s synth-failure path), so
242            // there is no `current` to preserve here.
243            self.state = State::Idle;
244            self.error = None;
245        }
246        if self.cfg.muted {
247            return Ok(None); // accepted and discarded
248        }
249
250        let cleaned = clean(&text, &self.cfg.cleanup);
251        if cleaned.trim().is_empty() {
252            return Ok(None);
253        }
254
255        let policy = opts.policy.unwrap_or_else(|| opts.source.default_policy());
256        let id = self.queue.next_id();
257        let u = Utterance {
258            id,
259            text: cleaned,
260            voice: opts.voice.unwrap_or_else(|| self.cfg.voice.clone()),
261            speed: opts.speed.unwrap_or(self.cfg.speed),
262            source: opts.source,
263        };
264        self.queue.submit(u, policy);
265
266        match policy {
267            Policy::Replace | Policy::Interrupt => self.discard_current(),
268            _ => {}
269        }
270
271        if self.state != State::Paused {
272            self.state = State::Speaking;
273            self.idle_since = None;
274        }
275
276        Ok(Some(id))
277    }
278
279    /// One unit of work: top up the sink, or advance the queue, or unload.
280    pub fn tick(&mut self) {
281        // Checked before the `Paused` early-return, not after: the failure
282        // this reports comes from cpal's stream error callback, which fires
283        // on its own thread whenever the device dies, independent of
284        // whether playback is paused. `push` cannot detect it -- it only
285        // ever writes into the in-process ring, which keeps accepting
286        // samples whether or not anything is left to drain them -- so this
287        // poll is the only place a lost device is ever noticed. Deferring
288        // it until a later `Resume` would leave a paused user believing
289        // their queued speech is intact for however long they stay paused,
290        // only to discover otherwise (and only then) on the next `tick`
291        // after resuming; checking here surfaces it as soon as it happens
292        // instead. The clear-the-queue behaviour is the same either way, so
293        // this does not special-case `Paused` versus `Speaking` -- it just
294        // stops the special-casing from mattering.
295        if let Some(e) = self.sink.take_error() {
296            self.state = State::Error;
297            self.error = Some(e);
298            self.current = None;
299            self.queue.clear();
300            // This is a route out of `Paused` (see the pause invariant on
301            // `error`'s doc comment): a device failure can arrive while
302            // paused, since this check runs before the `Paused` early
303            // return below, so it must unpause the sink in the same step
304            // rather than leaving it stranded for a `Resume` that can no
305            // longer fire.
306            self.sink.set_paused(false);
307            return;
308        }
309
310        if self.state == State::Paused {
311            return;
312        }
313
314        // Flush anything left over from a previous partial push.
315        if let Some(c) = self.current.as_mut() {
316            if !c.carry.is_empty() {
317                let n = self.sink.push(&c.carry);
318                c.carry.drain(..n);
319                if !c.carry.is_empty() {
320                    return; // sink is full; try again next tick
321                }
322            }
323        }
324
325        // Start the next utterance if nothing is current.
326        if self.current.is_none() {
327            match self.queue.pop_front() {
328                Some(u) => {
329                    // `refit`'s predicate must be `Fn`, but phonemizing needs
330                    // `&mut self.synth`. A closure that captures `&mut
331                    // self.synth` and calls a `&mut self` method through it
332                    // is inferred as `FnMut`, which `refit` will not accept.
333                    // A `RefCell` around the mutable borrow gives interior
334                    // mutability so the closure only ever needs `&self`,
335                    // satisfying `Fn` while still driving the real
336                    // synthesizer on every call `refit` makes (as opposed to
337                    // cloning the synthesizer, or phonemizing text up front
338                    // and discarding the result).
339                    //
340                    // The voice must come from `u` before `u.text`/`u.voice`
341                    // are moved into `Current` below, since this predicate
342                    // runs first.
343                    let voice = u.voice.clone();
344                    let cs = chunk(&u.text, self.cfg.chunking.target_chars);
345                    let synth = RefCell::new(&mut self.synth);
346                    let cs = refit(cs, |t| {
347                        let mut synth = synth.borrow_mut();
348                        let ph = synth.phonemize(t, &voice);
349                        synth.fits(&ph)
350                    });
351                    self.current = Some(Current {
352                        id: u.id,
353                        text: u.text,
354                        voice: u.voice,
355                        speed: u.speed,
356                        chunks: cs,
357                        next_chunk: 0,
358                        carry: Vec::new(),
359                    });
360                    self.state = State::Speaking;
361                    self.error = None;
362                    self.idle_since = None;
363                }
364                None => {
365                    if self.state != State::Error {
366                        self.go_idle();
367                    }
368                    self.maybe_unload();
369                    return;
370                }
371            }
372        }
373
374        // Bound the lookahead: stop synthesizing once the sink is well fed.
375        // `lookahead_chunks` comes straight from a user-editable config file
376        // with no validation, so the `+ 1` must not be able to overflow (it
377        // would panic with overflow checks on, or silently wrap to 0 and
378        // then get masked back up to a divisor of 2 by `.max(2)` in a
379        // release build -- behaviour that must not depend on build profile).
380        let headroom = self.sink.capacity().saturating_sub(self.sink.pending());
381        let divisor = self.cfg.chunking.lookahead_chunks.saturating_add(1).max(2);
382        if headroom < self.sink.capacity() / divisor {
383            return;
384        }
385
386        let Some(c) = self.current.as_mut() else { return };
387        if c.next_chunk >= c.chunks.len() {
388            self.current = None;
389            if self.queue.is_empty() {
390                self.go_idle();
391            }
392            return;
393        }
394
395        let text = c.chunks[c.next_chunk].text.clone();
396        let voice = c.voice.clone();
397        let speed = c.speed;
398        c.next_chunk += 1;
399
400        let phonemes = self.synth.phonemize(&text, &voice);
401        match self.synth.synth(&phonemes, &voice, speed) {
402            Ok(samples) => {
403                let n = self.sink.push(&samples);
404                if n < samples.len() {
405                    if let Some(c) = self.current.as_mut() {
406                        c.carry = samples[n..].to_vec();
407                    }
408                }
409            }
410            Err(e) => {
411                self.state = State::Error;
412                self.error = Some(e);
413                self.current = None;
414                self.queue.clear();
415            }
416        }
417    }
418
419    /// Swap in a fresh sink after a device failure, clearing the error.
420    ///
421    /// The daemon calls this when it manages to reacquire the audio device.
422    /// The queue was cleared when the failure surfaced, so this returns the
423    /// engine to a clean idle state rather than resuming a half-played
424    /// utterance whose audio is gone.
425    pub fn replace_sink(&mut self, sink: Box<dyn AudioSink>) {
426        self.sink = sink;
427        // Enforce the pause invariant by construction rather than trusting
428        // the incoming sink to already be unpaused: both `AudioSink` impls
429        // in this codebase happen to construct unpaused, but nothing about
430        // the trait guarantees that of an arbitrary future implementation,
431        // and this method always leaves `state == Idle`.
432        self.sink.set_paused(false);
433        self.current = None;
434        self.error = None;
435        self.state = State::Idle;
436        self.idle_since = Some(Instant::now());
437    }
438
439    /// Both call sites in `tick` reach this only once `current` is already
440    /// `None` and the queue is empty, so the one thing left to check before
441    /// announcing `Idle` is whether the sink has actually finished playing
442    /// what it was given. Without that check the engine would report `Idle`
443    /// while `sink.pending()` (and therefore `Snapshot::remaining_secs`)
444    /// still counts seconds of audio that has not been heard yet --
445    /// self-contradictory, and both M2's D-Bus `State` property and M3's
446    /// MPRIS `PlaybackStatus` read this field directly.
447    ///
448    /// Only reached with `state != State::Paused`: `tick` returns before
449    /// this point while paused, so this never has to reason about a sink
450    /// that is deliberately not draining.
451    fn go_idle(&mut self) {
452        if self.state != State::Error {
453            if self.sink.pending() > 0 {
454                // Nothing left to queue or synthesize, but the sink is
455                // still draining what it already has -- stay Speaking until
456                // it actually finishes, not the instant nothing is left to
457                // feed it. `idle_since` stays untouched (still `None` from
458                // when this utterance started) so `maybe_unload` keeps
459                // declining to fire; see its own guard.
460                self.state = State::Speaking;
461                return;
462            }
463            self.state = State::Idle;
464        }
465        if self.idle_since.is_none() {
466            self.idle_since = Some(Instant::now());
467        }
468    }
469
470    /// Like `go_idle`, but unconditionally -- including out of `Error` and
471    /// `Paused`, and without waiting on `sink.pending()`. Used by the
472    /// explicit "shut up" commands (`Stop`, `Next`, `SkipSentence`,
473    /// `SetMuted`), which must be able to dismiss a stuck error even though
474    /// nothing else can. `go_idle` itself stays Error-preserving and
475    /// pending-gated: it is also reached from plain `tick()` when the queue
476    /// drains with no command involved at all, and an error (or audio still
477    /// playing) must not evaporate on its own just because the caller kept
478    /// polling.
479    ///
480    /// Also enforces the pause invariant documented on the `error` field:
481    /// every one of this function's callers is a point where `state` can
482    /// move to `Idle` regardless of what it was before, including `Paused`,
483    /// and `Command::Resume` -- the only other place that unpauses the sink
484    /// -- is itself gated on `state == Paused`, so this is the last chance
485    /// to unpause before that guard becomes permanently unreachable.
486    fn dismiss_error_and_go_idle(&mut self) {
487        self.state = State::Idle;
488        self.error = None;
489        self.sink.set_paused(false);
490        if self.idle_since.is_none() {
491            self.idle_since = Some(Instant::now());
492        }
493    }
494
495    /// Discard whatever is currently speaking and drop any buffered audio.
496    /// Shared by `Stop`, `Next`, a `Replace`/`Interrupt` submission and
497    /// `SetMuted(true)` -- the four places that blow away the current
498    /// utterance.
499    fn discard_current(&mut self) {
500        self.current = None;
501        self.sink.clear();
502    }
503
504    fn maybe_unload(&mut self) {
505        if !self.synth.is_loaded() {
506            return;
507        }
508        let Some(since) = self.idle_since else { return };
509        if since.elapsed() >= Duration::from_secs(self.cfg.idle_unload_secs) {
510            self.synth.unload();
511        }
512    }
513
514    pub fn snapshot(&self) -> Snapshot {
515        Snapshot {
516            state: self.state,
517            muted: self.cfg.muted,
518            voice: self.cfg.voice.clone(),
519            speed: self.cfg.speed,
520            queue_len: self.queue.len(),
521            remaining_secs: self.remaining_secs(),
522            current_text: self.current.as_ref().map(|c| c.text.clone()).unwrap_or_default(),
523            current_id: self.current.as_ref().map(|c| c.id).unwrap_or(0),
524            error: self.error.clone(),
525        }
526    }
527
528    /// Three buckets, in decreasing order of certainty: exact for audio
529    /// already accepted by the sink, exact for audio already synthesized
530    /// but still parked in `carry` waiting for room in the sink, and
531    /// estimated (via `SECONDS_PER_WORD`) for text not yet spoken at all.
532    fn remaining_secs(&self) -> f64 {
533        let sr = self.synth.sample_rate() as f64;
534        let carried = self.current.as_ref().map(|c| c.carry.len()).unwrap_or(0) as f64;
535        let buffered = (self.sink.pending() as f64 + carried) / sr;
536
537        let mut words = 0usize;
538        if let Some(c) = self.current.as_ref() {
539            for ch in &c.chunks[c.next_chunk.min(c.chunks.len())..] {
540                words += ch.text.split_whitespace().count();
541            }
542        }
543        for u in self.queue.iter() {
544            words += u.text.split_whitespace().count();
545        }
546        let speed = self.cfg.speed.max(0.1) as f64;
547        buffered + (words as f64 * SECONDS_PER_WORD) / speed
548    }
549
550    // --- test helpers -------------------------------------------------
551
552    #[cfg(test)]
553    fn audio_written(&self) -> usize {
554        self.sink.total_written()
555    }
556
557    #[cfg(test)]
558    fn is_model_loaded(&self) -> bool {
559        self.synth.is_loaded()
560    }
561
562    #[cfg(test)]
563    fn snapshot_queue_ids(&self) -> Vec<u64> {
564        self.queue.iter().map(|u| u.id).collect()
565    }
566}
567
568#[cfg(test)]
569mod tests {
570    use std::sync::{Arc, Mutex};
571
572    use super::*;
573    use crate::audio::{AudioSink, VecSink};
574    use crate::config::Config;
575    use crate::synth::StubSynthesizer;
576
577    fn engine() -> Engine {
578        Engine::new(
579            Config::default(),
580            Box::new(StubSynthesizer::new()),
581            Box::new(VecSink::new(24_000 * 10)),
582        )
583    }
584
585    /// `Engine` owns its sink as a private `Box<dyn AudioSink>`, so a test
586    /// that wants to model *playback* -- not just accept samples -- needs a
587    /// handle it can drain from outside after handing the sink away. This
588    /// wraps `VecSink` (which already knows how to simulate playback via
589    /// `drain`) behind `Arc<Mutex<_>>` so both sides can reach it:
590    /// `AudioSink: Send` rules out `Rc<RefCell<_>>`.
591    ///
592    /// This is the "explicitly-drained sink" option from C1's two choices
593    /// (test double that reports samples as played, vs. an explicitly
594    /// drained sink) -- chosen because `VecSink::drain` already exists and
595    /// models exactly the fact the review measured: a sink that only frees
596    /// space as audio is actually played, not the instant it's pushed. A
597    /// sink that auto-drains on every `push`/`pending` call was considered
598    /// and rejected: it would make `pending() > 0` unobservable, which is
599    /// the exact condition C1's new tests need to hold under an explicit
600    /// hand.
601    #[derive(Clone)]
602    struct SharedVecSink(Arc<Mutex<VecSink>>);
603
604    impl AudioSink for SharedVecSink {
605        fn push(&mut self, samples: &[f32]) -> usize {
606            self.0.lock().unwrap().push(samples)
607        }
608        fn pending(&self) -> usize {
609            self.0.lock().unwrap().pending()
610        }
611        fn clear(&mut self) {
612            self.0.lock().unwrap().clear()
613        }
614        fn set_paused(&mut self, paused: bool) {
615            self.0.lock().unwrap().set_paused(paused)
616        }
617        fn is_paused(&self) -> bool {
618            self.0.lock().unwrap().is_paused()
619        }
620        fn capacity(&self) -> usize {
621            self.0.lock().unwrap().capacity()
622        }
623        fn total_written(&self) -> usize {
624            self.0.lock().unwrap().total_written()
625        }
626    }
627
628    /// Build an engine over a sink the test can drain (simulate playback)
629    /// from outside, plus a handle to do that draining with.
630    fn engine_with_drainable_sink(capacity: usize) -> (Engine, Arc<Mutex<VecSink>>) {
631        let sink = Arc::new(Mutex::new(VecSink::new(capacity)));
632        let e = Engine::new(
633            Config::default(),
634            Box::new(StubSynthesizer::new()),
635            Box::new(SharedVecSink(sink.clone())),
636        );
637        (e, sink)
638    }
639
640    /// A sink whose `take_error` can be populated from outside, independent
641    /// of `push` -- unlike `FailingSink` below, which only ever fails from
642    /// inside `push` and therefore can never be triggered while the engine
643    /// is `Paused` (`tick` never calls `push` while paused). The real
644    /// `RingSink` fails this way for real: cpal reports a dead device from
645    /// its own error-callback thread, asynchronously and independent of
646    /// whether anything is currently being pushed. This wraps `VecSink` the
647    /// same way `SharedVecSink` does, plus a second shared slot a test can
648    /// write into directly to model that asynchronous arrival.
649    #[derive(Clone)]
650    struct FaultInjectableSink {
651        inner: Arc<Mutex<VecSink>>,
652        fault: Arc<Mutex<Option<String>>>,
653    }
654
655    impl FaultInjectableSink {
656        fn new(capacity: usize) -> Self {
657            FaultInjectableSink {
658                inner: Arc::new(Mutex::new(VecSink::new(capacity))),
659                fault: Arc::new(Mutex::new(None)),
660            }
661        }
662
663        /// Simulate cpal's error callback firing on its own thread: make the
664        /// next `take_error` observe a failure, with no `push` involved.
665        fn inject_failure(&self, msg: &str) {
666            *self.fault.lock().unwrap() = Some(msg.into());
667        }
668    }
669
670    impl AudioSink for FaultInjectableSink {
671        fn push(&mut self, samples: &[f32]) -> usize {
672            self.inner.lock().unwrap().push(samples)
673        }
674        fn pending(&self) -> usize {
675            self.inner.lock().unwrap().pending()
676        }
677        fn clear(&mut self) {
678            self.inner.lock().unwrap().clear()
679        }
680        fn set_paused(&mut self, paused: bool) {
681            self.inner.lock().unwrap().set_paused(paused)
682        }
683        fn is_paused(&self) -> bool {
684            self.inner.lock().unwrap().is_paused()
685        }
686        fn capacity(&self) -> usize {
687            self.inner.lock().unwrap().capacity()
688        }
689        fn total_written(&self) -> usize {
690            self.inner.lock().unwrap().total_written()
691        }
692        fn take_error(&mut self) -> Option<String> {
693            self.fault.lock().unwrap().take()
694        }
695    }
696
697    fn say(text: &str) -> Command {
698        Command::Say { text: text.into(), opts: SayOpts::default() }
699    }
700
701    /// Run `tick` until idle or `max` iterations, whichever comes first.
702    fn run(e: &mut Engine, max: usize) {
703        for _ in 0..max {
704            if e.snapshot().state == State::Idle {
705                return;
706            }
707            e.tick();
708        }
709    }
710
711    #[test]
712    fn starts_idle() {
713        let e = engine();
714        let s = e.snapshot();
715        assert_eq!(s.state, State::Idle);
716        assert_eq!(s.queue_len, 0);
717        assert_eq!(s.error, None);
718    }
719
720    #[test]
721    fn say_moves_to_speaking_and_produces_audio() {
722        let mut e = engine();
723        e.handle(say("Hello there. This is a test."));
724        e.tick();
725        assert_eq!(e.snapshot().state, State::Speaking);
726        run(&mut e, 500);
727        assert!(e.audio_written() > 0, "expected samples to reach the sink");
728    }
729
730    #[test]
731    fn returns_to_idle_when_the_queue_empties() {
732        // C1: with nothing draining it, a `VecSink` never empties on its
733        // own, so reaching `Idle` here now requires modelling playback --
734        // this is what pins the original intent of this test (an emptied
735        // queue eventually leads to `Idle`) now that the engine also waits
736        // for the sink to actually finish. See
737        // `state_stays_speaking_while_audio_is_still_pending_in_the_sink`
738        // for the part of C1's behaviour this test used to (silently) not
739        // cover: that it does *not* go `Idle` before that.
740        let (mut e, sink) = engine_with_drainable_sink(24_000 * 10);
741        e.handle(say("Short."));
742        run(&mut e, 500);
743        sink.lock().unwrap().drain(usize::MAX);
744        e.tick();
745        assert_eq!(e.snapshot().state, State::Idle);
746    }
747
748    #[test]
749    fn state_stays_speaking_while_audio_is_still_pending_in_the_sink() {
750        // C1, pinned directly: the engine must not announce Idle the instant
751        // there is nothing left to *synthesize* -- it must wait until the
752        // sink has actually finished playing what it already has.
753        let (mut e, sink) = engine_with_drainable_sink(24_000 * 10);
754        e.handle(say("Hello there. This is sayd speaking from the engine."));
755        run(&mut e, 500);
756
757        let pending = sink.lock().unwrap().pending();
758        assert!(pending > 0, "test is only meaningful with audio still buffered");
759        let s = e.snapshot();
760        assert_eq!(
761            s.state,
762            State::Speaking,
763            "must not report Idle with {pending} samples still unplayed"
764        );
765        assert!(
766            s.remaining_secs > 0.0,
767            "remaining_secs must agree with state: both say audio is still outstanding"
768        );
769
770        sink.lock().unwrap().drain(usize::MAX);
771        e.tick();
772        assert_eq!(e.snapshot().state, State::Idle, "must go Idle once the sink actually drains");
773    }
774
775    #[test]
776    fn paused_engine_with_pending_audio_does_not_go_idle_or_spin() {
777        // The interaction C1 calls out explicitly: while Paused the sink
778        // does not drain (nothing is popping it) and `tick` returns before
779        // ever reaching `go_idle`, so a paused engine with buffered audio
780        // must neither drift to Idle on its own nor loop/panic under
781        // repeated ticking.
782        let (mut e, sink) = engine_with_drainable_sink(24_000 * 10);
783        e.handle(say("Hello there. This is a reasonably long test sentence."));
784        run(&mut e, 500);
785        assert_eq!(e.snapshot().state, State::Speaking);
786        let pending_before = sink.lock().unwrap().pending();
787        assert!(pending_before > 0, "test is only meaningful with audio still buffered");
788
789        e.handle(Command::Pause);
790        assert_eq!(e.snapshot().state, State::Paused);
791
792        for _ in 0..200 {
793            e.tick();
794        }
795
796        let s = e.snapshot();
797        assert_eq!(s.state, State::Paused, "must not spuriously become Idle while paused");
798        assert_eq!(
799            sink.lock().unwrap().pending(),
800            pending_before,
801            "a paused sink must not drain, and tick must not touch it while paused"
802        );
803    }
804
805    #[test]
806    fn pause_and_resume_toggle_state() {
807        let mut e = engine();
808        e.handle(say("Hello there. This is a test."));
809        e.tick();
810        e.handle(Command::Pause);
811        assert_eq!(e.snapshot().state, State::Paused);
812        e.handle(Command::Resume);
813        assert_eq!(e.snapshot().state, State::Speaking);
814    }
815
816    #[test]
817    fn play_pause_toggles_both_ways() {
818        let mut e = engine();
819        e.handle(say("Hello there."));
820        e.tick();
821        e.handle(Command::PlayPause);
822        assert_eq!(e.snapshot().state, State::Paused);
823        e.handle(Command::PlayPause);
824        assert_eq!(e.snapshot().state, State::Speaking);
825    }
826
827    #[test]
828    fn pause_when_idle_is_a_no_op() {
829        let mut e = engine();
830        e.handle(Command::Pause);
831        assert_eq!(e.snapshot().state, State::Idle);
832    }
833
834    #[test]
835    fn stop_clears_the_queue_and_goes_idle() {
836        let mut e = engine();
837        e.handle(say("First one here."));
838        e.handle(say("Second one here."));
839        e.tick();
840        e.handle(Command::Stop);
841        let s = e.snapshot();
842        assert_eq!(s.state, State::Idle);
843        assert_eq!(s.queue_len, 0, "Stop is the shut-up verb: it clears everything");
844    }
845
846    #[test]
847    fn clear_queue_keeps_the_current_utterance() {
848        let mut e = engine();
849        e.handle(say("First one here."));
850        e.handle(say("Second one here."));
851        e.tick();
852        e.handle(Command::ClearQueue);
853        let s = e.snapshot();
854        assert_eq!(s.state, State::Speaking, "the current utterance survives");
855        assert_eq!(s.queue_len, 0);
856    }
857
858    #[test]
859    fn next_advances_to_the_following_utterance() {
860        let mut e = engine();
861        e.handle(say("First."));
862        e.handle(say("Second."));
863        e.tick();
864        let first = e.snapshot().current_id;
865        e.handle(Command::Next);
866        e.tick();
867        assert_ne!(e.snapshot().current_id, first);
868    }
869
870    #[test]
871    fn hotkey_source_replaces_by_default() {
872        let mut e = engine();
873        e.handle(say("First one here."));
874        e.handle(say("Second one here."));
875        e.tick();
876        e.handle(Command::Say {
877            text: "Selected text.".into(),
878            opts: SayOpts { source: Source::Hotkey, ..Default::default() },
879        });
880        assert_eq!(e.snapshot().queue_len, 1, "replace drops everything pending");
881    }
882
883    #[test]
884    fn explicit_policy_overrides_the_source_default() {
885        let mut e = engine();
886        e.handle(say("First one here."));
887        e.handle(Command::Say {
888            text: "Selected.".into(),
889            opts: SayOpts {
890                source: Source::Hotkey,
891                policy: Some(Policy::Enqueue),
892                ..Default::default()
893            },
894        });
895        assert_eq!(e.snapshot().queue_len, 2, "explicit enqueue beats the hotkey default");
896    }
897
898    #[test]
899    fn muted_accepts_and_discards() {
900        let mut e = engine();
901        e.handle(Command::SetMuted(true));
902        e.handle(say("Nobody hears this."));
903        run(&mut e, 100);
904        assert_eq!(e.audio_written(), 0, "muted must produce no audio");
905        assert_eq!(e.snapshot().state, State::Idle);
906    }
907
908    #[test]
909    fn text_over_max_chars_is_rejected() {
910        let cfg = Config { max_chars: 10, ..Config::default() };
911        let mut e = Engine::new(
912            cfg,
913            Box::new(StubSynthesizer::new()),
914            Box::new(VecSink::new(24_000)),
915        );
916        e.handle(say("this is definitely longer than ten characters"));
917        let s = e.snapshot();
918        assert_eq!(s.state, State::Error);
919        assert!(s.error.as_deref().unwrap_or("").contains("10"));
920    }
921
922    #[test]
923    fn a_later_successful_say_clears_the_error() {
924        let cfg = Config { max_chars: 10, ..Config::default() };
925        let mut e = Engine::new(
926            cfg,
927            Box::new(StubSynthesizer::new()),
928            Box::new(VecSink::new(24_000 * 10)),
929        );
930        e.handle(say("far too long to be accepted"));
931        assert_eq!(e.snapshot().state, State::Error);
932        e.handle(say("ok."));
933        assert_eq!(e.snapshot().error, None);
934    }
935
936    #[test]
937    fn stop_dismisses_a_stuck_error() {
938        // Stop is the daemon's designated "shut up" command: it must be able
939        // to clear Error even though nothing else naturally can.
940        let cfg = Config { max_chars: 5, ..Config::default() };
941        let mut e = Engine::new(
942            cfg,
943            Box::new(StubSynthesizer::new()),
944            Box::new(VecSink::new(24_000 * 10)),
945        );
946        e.handle(say("way too long for the limit"));
947        assert_eq!(e.snapshot().state, State::Error);
948        e.handle(Command::Stop);
949        let s = e.snapshot();
950        assert_eq!(s.state, State::Idle);
951        assert_eq!(s.error, None);
952    }
953
954    #[test]
955    fn next_dismisses_a_stuck_error_when_the_queue_is_empty() {
956        let cfg = Config { max_chars: 5, ..Config::default() };
957        let mut e = Engine::new(
958            cfg,
959            Box::new(StubSynthesizer::new()),
960            Box::new(VecSink::new(24_000 * 10)),
961        );
962        e.handle(say("way too long for the limit"));
963        assert_eq!(e.snapshot().state, State::Error);
964        e.handle(Command::Next);
965        let s = e.snapshot();
966        assert_eq!(s.state, State::Idle);
967        assert_eq!(s.error, None);
968    }
969
970    #[test]
971    fn a_stuck_error_survives_plain_ticking_with_no_command() {
972        // Mirror image of the two tests above: an error must not clear
973        // itself just because the caller kept polling `tick()` -- only an
974        // explicit command may dismiss it. `synth_failure_surfaces_as_error_
975        // and_does_not_wedge` already covers the synth-failure route to
976        // Error; this covers the rejection route.
977        let cfg = Config { max_chars: 5, ..Config::default() };
978        let mut e = Engine::new(
979            cfg,
980            Box::new(StubSynthesizer::new()),
981            Box::new(VecSink::new(24_000 * 10)),
982        );
983        e.handle(say("way too long for the limit"));
984        assert_eq!(e.snapshot().state, State::Error);
985        for _ in 0..20 {
986            e.tick();
987        }
988        let s = e.snapshot();
989        assert_eq!(s.state, State::Error, "no command was issued; the error must persist");
990        assert!(s.error.is_some());
991    }
992
993    #[test]
994    fn rejection_while_speaking_leaves_playback_untouched() {
995        // Submitting an over-long text while an unrelated utterance A is
996        // legitimately speaking must not flip the engine to Error: A is
997        // unaffected and should play to completion. The rejection must
998        // still be observable -- now synchronously, as `submit`'s `Err`.
999        let cfg = Config { max_chars: 5, ..Config::default() };
1000        let mut e = Engine::new(
1001            cfg,
1002            Box::new(StubSynthesizer::new()),
1003            Box::new(VecSink::new(24_000 * 10)),
1004        );
1005        e.handle(say("Hi.")); // 3 chars, within the limit
1006        e.tick();
1007        let before = e.snapshot();
1008        assert_eq!(before.state, State::Speaking);
1009        let id = before.current_id;
1010
1011        let result = e.submit(
1012            "this one is definitely too long for the limit".into(),
1013            SayOpts::default(),
1014        );
1015
1016        assert!(
1017            result.as_ref().unwrap_err().contains('5'),
1018            "the rejection must still be observable: {result:?}"
1019        );
1020        let after = e.snapshot();
1021        assert_eq!(after.state, State::Speaking, "A must keep playing");
1022        assert_eq!(after.current_id, id, "A must not be disturbed");
1023        assert_eq!(after.error, None, "nothing about A is actually wrong");
1024        assert_eq!(after.queue_len, 0, "the rejected text must not be queued");
1025    }
1026
1027    #[test]
1028    fn rejection_while_paused_leaves_playback_untouched() {
1029        let cfg = Config { max_chars: 5, ..Config::default() };
1030        let mut e = Engine::new(
1031            cfg,
1032            Box::new(StubSynthesizer::new()),
1033            Box::new(VecSink::new(24_000 * 10)),
1034        );
1035        e.handle(say("Hi."));
1036        e.tick();
1037        e.handle(Command::Pause);
1038        assert_eq!(e.snapshot().state, State::Paused);
1039
1040        let result = e.submit(
1041            "this one is definitely too long for the limit".into(),
1042            SayOpts::default(),
1043        );
1044
1045        assert!(result.is_err());
1046        let after = e.snapshot();
1047        assert_eq!(after.state, State::Paused);
1048        assert_eq!(after.error, None);
1049    }
1050
1051    #[test]
1052    fn error_state_invariant_holds_after_every_command_from_every_state() {
1053        // Pin both invariants documented on `Engine::error`, not just the
1054        // individual scenarios covering each of them elsewhere: `error.
1055        // is_some() == (state == State::Error)`, and `state == State::Paused`
1056        // whenever the sink is left paused -- both after every command, from
1057        // every reachable state. (C2's bug was exactly a case where the
1058        // second invariant broke while the first stayed fine: `Next`,
1059        // `SkipSentence` and `SetMuted(true)` all correctly reached `Idle`
1060        // with `error == None`, while quietly leaving `sink.paused == true`
1061        // behind.)
1062        fn assert_invariants(e: &Engine, ctx: &str) {
1063            let s = e.snapshot();
1064            assert_eq!(
1065                s.error.is_some(),
1066                s.state == State::Error,
1067                "{ctx}: error={:?} state={:?}",
1068                s.error,
1069                s.state
1070            );
1071            assert!(
1072                s.state == State::Paused || !e.sink.is_paused(),
1073                "{ctx}: state={:?} but the sink is still paused",
1074                s.state
1075            );
1076        }
1077
1078        fn all_commands() -> Vec<Command> {
1079            vec![
1080                say("Something reasonably short."),
1081                Command::Pause,
1082                Command::Resume,
1083                Command::PlayPause,
1084                Command::Stop,
1085                Command::Next,
1086                Command::SkipSentence,
1087                Command::ClearQueue,
1088                Command::Cancel(1),
1089                Command::SetMuted(true),
1090                Command::SetMuted(false),
1091                Command::SetVoice("am_fenrir".into()),
1092                Command::SetSpeed(1.5),
1093                Command::Shutdown,
1094            ]
1095        }
1096
1097        fn build_idle() -> Engine {
1098            engine()
1099        }
1100        fn build_speaking() -> Engine {
1101            let mut e = engine();
1102            e.handle(say("Hello there. This keeps it busy for quite a while indeed."));
1103            e.tick();
1104            e
1105        }
1106        fn build_paused() -> Engine {
1107            let mut e = build_speaking();
1108            e.handle(Command::Pause);
1109            e
1110        }
1111        fn build_error() -> Engine {
1112            let cfg = Config { max_chars: 5, ..Config::default() };
1113            let mut e = Engine::new(
1114                cfg,
1115                Box::new(StubSynthesizer::new()),
1116                Box::new(VecSink::new(24_000 * 10)),
1117            );
1118            e.handle(say("way too long for the limit"));
1119            e
1120        }
1121        // C2/M2's device-failure branch (`tick`'s `take_error` check) is a
1122        // second, independent route into `Error`, and the only one that can
1123        // fire while `state == Paused` -- exactly the case Finding 1 missed.
1124        // `all_commands()` has nothing that triggers `take_error` (nor could
1125        // it: the real failure arrives asynchronously from cpal's callback,
1126        // not from a `Command`), so a dedicated build function is the only
1127        // way to get this class of failure under the same sweep as every
1128        // other reachable state, rather than only the bespoke tests below.
1129        fn build_error_from_device_failure_while_paused() -> Engine {
1130            let sink = FaultInjectableSink::new(24_000 * 10);
1131            let mut e = Engine::new(
1132                Config::default(),
1133                Box::new(StubSynthesizer::new()),
1134                Box::new(sink.clone()),
1135            );
1136            e.handle(say("Hello there. This keeps it busy for quite a while indeed."));
1137            e.tick();
1138            e.handle(Command::Pause);
1139            sink.inject_failure("audio device disappeared");
1140            e.tick();
1141            e
1142        }
1143
1144        fn check_from(name: &str, build: fn() -> Engine) {
1145            for cmd in all_commands() {
1146                let mut e = build();
1147                assert_invariants(&e, &format!("before {name} -> {cmd:?}"));
1148                e.handle(cmd.clone());
1149                assert_invariants(&e, &format!("after {name} -> {cmd:?}"));
1150            }
1151        }
1152
1153        check_from("idle", build_idle);
1154        check_from("speaking", build_speaking);
1155        check_from("paused", build_paused);
1156        check_from("error", build_error);
1157        check_from("device_failed_while_paused", build_error_from_device_failure_while_paused);
1158    }
1159
1160    #[test]
1161    fn huge_lookahead_chunks_does_not_overflow() {
1162        // `lookahead_chunks` comes straight from a user-editable config file
1163        // with no validation. A value near `usize::MAX` used to panic on
1164        // `+ 1` with overflow checks on (which is how tests run), and
1165        // silently wrap to a different divisor in release builds.
1166        let mut cfg = Config::default();
1167        cfg.chunking.lookahead_chunks = usize::MAX;
1168        let mut e = Engine::new(
1169            cfg,
1170            Box::new(StubSynthesizer::new()),
1171            Box::new(VecSink::new(24_000 * 10)),
1172        );
1173        e.handle(say("Hello there. This is a test."));
1174        for _ in 0..10 {
1175            e.tick();
1176        }
1177        assert!(e.audio_written() > 0, "expected samples to reach the sink");
1178    }
1179
1180    #[test]
1181    fn remaining_seconds_includes_audio_parked_in_carry() {
1182        // A tiny sink forces most of the first chunk's synthesized audio
1183        // into `carry` rather than the sink itself. `next_chunk` has already
1184        // advanced past that chunk, so if `carry` weren't counted the
1185        // estimate would understate the time left by nearly the whole chunk.
1186        let mut e = Engine::new(
1187            Config::default(),
1188            Box::new(StubSynthesizer::new()),
1189            Box::new(VecSink::new(100)),
1190        );
1191        e.handle(say("A reasonably long sentence to force a big carry remainder."));
1192        e.tick(); // pop -> synth -> partial push -> the rest parked in carry
1193        let s = e.snapshot();
1194        // With only 100 samples possibly in the sink (100 / 24_000 s), any
1195        // reading much larger than that must be coming from carry.
1196        assert!(
1197            s.remaining_secs > 1.0,
1198            "carry must count toward remaining_secs, got {}",
1199            s.remaining_secs
1200        );
1201    }
1202
1203    #[test]
1204    fn remaining_seconds_scales_with_word_count() {
1205        let mut e = engine();
1206        e.handle(say("one two three four five six seven eight nine ten."));
1207        let s = e.snapshot();
1208        // 10 words at 0.365 s each, give or take the estimator's rounding.
1209        assert!(s.remaining_secs > 2.0, "got {}", s.remaining_secs);
1210        assert!(s.remaining_secs < 6.0, "got {}", s.remaining_secs);
1211    }
1212
1213    #[test]
1214    fn remaining_seconds_halves_at_double_speed() {
1215        let mut e = engine();
1216        e.handle(Command::SetSpeed(2.0));
1217        e.handle(say("one two three four five six seven eight nine ten."));
1218        let fast = e.snapshot().remaining_secs;
1219        let mut e2 = engine();
1220        e2.handle(say("one two three four five six seven eight nine ten."));
1221        let normal = e2.snapshot().remaining_secs;
1222        assert!(fast < normal * 0.75, "fast {fast} vs normal {normal}");
1223    }
1224
1225    #[test]
1226    fn set_voice_applies_to_the_next_utterance() {
1227        let mut e = engine();
1228        e.handle(Command::SetVoice("am_fenrir".into()));
1229        assert_eq!(e.snapshot().voice, "am_fenrir");
1230    }
1231
1232    #[test]
1233    fn cancel_removes_a_queued_utterance() {
1234        let mut e = engine();
1235        e.handle(say("First."));
1236        e.handle(say("Second."));
1237        let id = e.snapshot_queue_ids()[1];
1238        e.handle(Command::Cancel(id));
1239        assert_eq!(e.snapshot().queue_len, 1);
1240    }
1241
1242    #[test]
1243    fn idle_unload_drops_the_model_after_the_configured_delay() {
1244        // unload as soon as idle. C1: as in `returns_to_idle_when_the_queue_
1245        // empties`, actually reaching `Idle` -- the precondition this test
1246        // is exercising -- now requires draining the sink first.
1247        let cfg = Config { idle_unload_secs: 0, ..Config::default() };
1248        let sink = Arc::new(Mutex::new(VecSink::new(24_000 * 10)));
1249        let mut e = Engine::new(
1250            cfg,
1251            Box::new(StubSynthesizer::new()),
1252            Box::new(SharedVecSink(sink.clone())),
1253        );
1254        e.handle(say("Hello."));
1255        run(&mut e, 500);
1256        assert_eq!(
1257            e.snapshot().state,
1258            State::Speaking,
1259            "sanity: audio must still be pending before the drain below"
1260        );
1261        sink.lock().unwrap().drain(usize::MAX);
1262        e.tick(); // reach Idle and trigger the unload check in the same tick
1263        assert_eq!(e.snapshot().state, State::Idle);
1264        assert!(!e.is_model_loaded(), "expected the model to unload when idle");
1265    }
1266
1267    #[test]
1268    fn model_does_not_unload_while_speaking() {
1269        let cfg = Config { idle_unload_secs: 0, ..Config::default() };
1270        let mut e = Engine::new(
1271            cfg,
1272            Box::new(StubSynthesizer::new()),
1273            Box::new(VecSink::new(24_000 * 10)),
1274        );
1275        e.handle(say("A reasonably long sentence to keep it busy for a while."));
1276        e.tick();
1277        e.tick();
1278        assert!(e.is_model_loaded());
1279    }
1280
1281    #[test]
1282    fn lookahead_is_bounded() {
1283        let mut e = engine();
1284        // Long text, small sink: the engine must stop synthesizing once the
1285        // sink is full rather than running ahead unboundedly.
1286        e.handle(say(&"word ".repeat(500)));
1287        for _ in 0..50 {
1288            e.tick();
1289        }
1290        assert!(
1291            e.audio_written() <= 24_000 * 10 + 24_000,
1292            "engine ran further ahead than the sink can hold"
1293        );
1294    }
1295
1296    #[test]
1297    fn skip_sentence_stops_current_audio_promptly() {
1298        // Correction 3: with the default target_chars (400) this 57-char text
1299        // becomes a single chunk, so after the first tick next_chunk ==
1300        // chunks.len() and SkipSentence would drop straight to Idle. Use a
1301        // small target_chars so the three sentences become three chunks
1302        // (20, 21, 15 chars; none merge since 20 + 1 + 21 > 25), which is
1303        // what this test is actually meant to exercise.
1304        let mut cfg = Config::default();
1305        cfg.chunking.target_chars = 25;
1306        let mut e = Engine::new(
1307            cfg,
1308            Box::new(StubSynthesizer::new()),
1309            Box::new(VecSink::new(24_000 * 10)),
1310        );
1311        e.handle(say("First sentence here. Second sentence here. Third one here."));
1312        e.tick();
1313        let before = e.audio_written();
1314        e.handle(Command::SkipSentence);
1315        e.tick();
1316        assert!(e.audio_written() >= before, "skip must not lose the sink");
1317        assert_eq!(e.snapshot().state, State::Speaking);
1318    }
1319
1320    #[test]
1321    fn skip_sentence_dismisses_a_stuck_error_from_a_rejected_submission() {
1322        // `SkipSentence`'s call to `dismiss_error_and_go_idle` used to sit
1323        // inside `if let Some(c) = self.current.as_mut()`, but `Error`
1324        // always implies `current.is_none()` -- so the branch could never
1325        // run while in `Error` and `SkipSentence` from an error state was a
1326        // silent no-op, contradicting `dismiss_error_and_go_idle`'s own doc
1327        // comment. This pins the rejection entry point to `Error`.
1328        let cfg = Config { max_chars: 5, ..Config::default() };
1329        let mut e = Engine::new(
1330            cfg,
1331            Box::new(StubSynthesizer::new()),
1332            Box::new(VecSink::new(24_000 * 10)),
1333        );
1334        e.handle(say("way too long for the limit"));
1335        assert_eq!(e.snapshot().state, State::Error);
1336        e.handle(Command::SkipSentence);
1337        let s = e.snapshot();
1338        assert_eq!(s.state, State::Idle);
1339        assert_eq!(s.error, None);
1340    }
1341
1342    #[test]
1343    fn skip_sentence_dismisses_a_stuck_error_from_a_synthesis_failure() {
1344        // Mirror of the test above via the other entry point into `Error`.
1345        struct Failing;
1346        impl crate::synth::Synthesizer for Failing {
1347            fn phonemize(&mut self, t: &str, _voice: &str) -> String {
1348                t.into()
1349            }
1350            fn fits(&mut self, _: &str) -> bool {
1351                true
1352            }
1353            fn synth(&mut self, _: &str, _: &str, _: f32) -> Result<Vec<f32>, String> {
1354                Err("model exploded".into())
1355            }
1356            fn unload(&mut self) {}
1357            fn is_loaded(&self) -> bool {
1358                true
1359            }
1360        }
1361        let mut e = Engine::new(
1362            Config::default(),
1363            Box::new(Failing),
1364            Box::new(VecSink::new(24_000)),
1365        );
1366        e.handle(say("Anything."));
1367        for _ in 0..20 {
1368            e.tick();
1369        }
1370        assert_eq!(e.snapshot().state, State::Error);
1371        e.handle(Command::SkipSentence);
1372        let s = e.snapshot();
1373        assert_eq!(s.state, State::Idle);
1374        assert_eq!(s.error, None);
1375    }
1376
1377    #[test]
1378    fn synth_failure_surfaces_as_error_and_does_not_wedge() {
1379        struct Failing;
1380        impl crate::synth::Synthesizer for Failing {
1381            fn phonemize(&mut self, t: &str, _voice: &str) -> String {
1382                t.into()
1383            }
1384            fn fits(&mut self, _: &str) -> bool {
1385                true
1386            }
1387            fn synth(&mut self, _: &str, _: &str, _: f32) -> Result<Vec<f32>, String> {
1388                Err("model exploded".into())
1389            }
1390            fn unload(&mut self) {}
1391            fn is_loaded(&self) -> bool {
1392                true
1393            }
1394        }
1395        let mut e = Engine::new(
1396            Config::default(),
1397            Box::new(Failing),
1398            Box::new(VecSink::new(24_000)),
1399        );
1400        e.handle(say("Anything."));
1401        for _ in 0..20 {
1402            e.tick();
1403        }
1404        let s = e.snapshot();
1405        assert_eq!(s.state, State::Error);
1406        assert!(s.error.as_deref().unwrap_or("").contains("model exploded"));
1407    }
1408
1409    #[test]
1410    fn empty_text_is_accepted_and_produces_nothing() {
1411        let mut e = engine();
1412        e.handle(say("   "));
1413        run(&mut e, 50);
1414        assert_eq!(e.snapshot().state, State::Idle);
1415        assert_eq!(e.audio_written(), 0);
1416    }
1417
1418    #[test]
1419    fn submit_rejection_while_idle_returns_err_and_sets_error_state() {
1420        // Nothing is legitimately in flight, so the rejection both answers
1421        // the caller directly and becomes the engine-wide `Error`.
1422        let cfg = Config { max_chars: 5, ..Config::default() };
1423        let mut e = Engine::new(
1424            cfg,
1425            Box::new(StubSynthesizer::new()),
1426            Box::new(VecSink::new(24_000 * 10)),
1427        );
1428        let result = e.submit("way too long for the limit".into(), SayOpts::default());
1429        let msg = result.expect_err("over-long text must be rejected");
1430        assert!(msg.contains('5'), "got {msg:?}");
1431        let s = e.snapshot();
1432        assert_eq!(s.state, State::Error);
1433        assert_eq!(s.error.as_deref(), Some(msg.as_str()));
1434    }
1435
1436    #[test]
1437    fn submit_rejection_while_speaking_returns_err_but_leaves_state_untouched() {
1438        // Same busy-vs-idle distinction as `rejection_while_speaking_leaves_
1439        // playback_untouched`, but pinned directly against the new method's
1440        // return value rather than only through `handle`.
1441        let cfg = Config { max_chars: 5, ..Config::default() };
1442        let mut e = Engine::new(
1443            cfg,
1444            Box::new(StubSynthesizer::new()),
1445            Box::new(VecSink::new(24_000 * 10)),
1446        );
1447        e.handle(say("Hi."));
1448        e.tick();
1449        assert_eq!(e.snapshot().state, State::Speaking);
1450
1451        let result = e.submit(
1452            "this one is definitely too long for the limit".into(),
1453            SayOpts::default(),
1454        );
1455
1456        assert!(result.is_err());
1457        let s = e.snapshot();
1458        assert_eq!(s.state, State::Speaking, "the unrelated playback must continue");
1459        assert_eq!(s.error, None);
1460    }
1461
1462    #[test]
1463    fn submit_accepted_returns_the_id_that_later_appears_as_current_id() {
1464        let mut e = engine();
1465        let id = e
1466            .submit("Hello there. This is a test.".into(), SayOpts::default())
1467            .expect("well-formed text must be accepted")
1468            .expect("well-formed text must be queued");
1469        e.tick();
1470        assert_eq!(e.snapshot().current_id, id);
1471    }
1472
1473    #[test]
1474    fn submit_returns_none_when_muted() {
1475        let mut e = engine();
1476        e.handle(Command::SetMuted(true));
1477        assert_eq!(e.submit("nobody hears this".into(), SayOpts::default()), Ok(None));
1478    }
1479
1480    #[test]
1481    fn submit_returns_none_for_text_that_is_empty_after_cleanup() {
1482        let mut e = engine();
1483        assert_eq!(e.submit("   ".into(), SayOpts::default()), Ok(None));
1484    }
1485
1486    #[test]
1487    fn submit_returns_some_nonzero_id_when_queued() {
1488        let mut e = engine();
1489        let id = e.submit("hello there.".into(), SayOpts::default()).expect("accepted");
1490        assert!(id.is_some());
1491        assert_ne!(id, Some(0), "id 0 is the nothing-is-playing sentinel");
1492    }
1493
1494    #[test]
1495    fn submit_still_returns_err_when_rejected() {
1496        let mut e = Engine::new(
1497            Config { max_chars: 5, ..Config::default() },
1498            Box::new(StubSynthesizer::new()),
1499            Box::new(VecSink::new(24_000)),
1500        );
1501        assert!(e.submit("far too long".into(), SayOpts::default()).is_err());
1502    }
1503
1504    /// A sink that reports a device failure after accepting one push.
1505    struct FailingSink {
1506        accepted_once: bool,
1507        err: Option<String>,
1508        paused: bool,
1509    }
1510
1511    impl FailingSink {
1512        fn new() -> Self {
1513            FailingSink { accepted_once: false, err: None, paused: false }
1514        }
1515    }
1516
1517    impl crate::audio::AudioSink for FailingSink {
1518        fn push(&mut self, samples: &[f32]) -> usize {
1519            if self.accepted_once {
1520                self.err = Some("audio device disappeared".into());
1521                return 0;
1522            }
1523            self.accepted_once = true;
1524            samples.len()
1525        }
1526        fn pending(&self) -> usize {
1527            0
1528        }
1529        fn clear(&mut self) {}
1530        fn set_paused(&mut self, p: bool) {
1531            self.paused = p
1532        }
1533        fn is_paused(&self) -> bool {
1534            self.paused
1535        }
1536        fn capacity(&self) -> usize {
1537            24_000
1538        }
1539        fn total_written(&self) -> usize {
1540            0
1541        }
1542        fn take_error(&mut self) -> Option<String> {
1543            self.err.take()
1544        }
1545    }
1546
1547    /// `FailingSink` accepts exactly one `push` and fails every one after
1548    /// that, so the engine needs at least two synthesis chunks to ever see
1549    /// the failure -- one to consume the free pass, one to hit it. `chunk()`
1550    /// merges short multi-sentence input (like "one. two. three.") into a
1551    /// single chunk under the default 400-char `target_chars`, which would
1552    /// only ever call `push` once and never trigger the failure at all.
1553    /// This text is long enough to force a second chunk.
1554    fn text_spanning_multiple_chunks() -> String {
1555        "This is one sentence in a long batch of text. ".repeat(15)
1556    }
1557
1558    #[test]
1559    fn a_device_failure_surfaces_as_error_rather_than_wedging() {
1560        let mut e = Engine::new(
1561            Config::default(),
1562            Box::new(StubSynthesizer::new()),
1563            Box::new(FailingSink::new()),
1564        );
1565        e.submit(text_spanning_multiple_chunks(), SayOpts::default()).expect("accepted");
1566        for _ in 0..200 {
1567            e.tick();
1568        }
1569        let s = e.snapshot();
1570        assert_eq!(s.state, State::Error, "a dead device must not leave the engine Speaking");
1571        assert!(s.error.as_deref().unwrap_or("").contains("device"));
1572    }
1573
1574    #[test]
1575    fn replace_sink_clears_the_error_and_accepts_new_work() {
1576        let mut e = Engine::new(
1577            Config::default(),
1578            Box::new(StubSynthesizer::new()),
1579            Box::new(FailingSink::new()),
1580        );
1581        e.submit(text_spanning_multiple_chunks(), SayOpts::default()).expect("accepted");
1582        for _ in 0..200 {
1583            e.tick();
1584        }
1585        assert_eq!(e.snapshot().state, State::Error);
1586
1587        e.replace_sink(Box::new(VecSink::new(24_000 * 10)));
1588        let s = e.snapshot();
1589        assert_eq!(s.state, State::Idle, "a fresh sink clears the failure");
1590        assert_eq!(s.error, None);
1591
1592        e.submit("after recovery.".into(), SayOpts::default()).expect("accepted");
1593        for _ in 0..500 {
1594            e.tick();
1595        }
1596        assert!(e.audio_written() > 0, "the engine must work again after the sink is replaced");
1597    }
1598
1599    #[test]
1600    fn device_failure_while_paused_unpauses_the_sink_and_reaches_error() {
1601        // Finding 1: `tick`'s device-failure branch is a route out of
1602        // `Paused` (`Paused -> Error`) and must unpause the sink in the same
1603        // step, exactly like `dismiss_error_and_go_idle` already does for
1604        // its own routes -- otherwise a later `Resume` has no way left to
1605        // fire (it's gated on `state == Paused`, which is no longer true)
1606        // and the sink is stranded paused forever. `FailingSink` cannot
1607        // reproduce this: it only ever fails from inside `push`, and `tick`
1608        // never calls `push` while paused. `FaultInjectableSink` can, since
1609        // its failure is set from outside, matching how the real `RingSink`
1610        // reports a device failure asynchronously from cpal's error
1611        // callback, independent of whether anything is being pushed.
1612        let sink = FaultInjectableSink::new(24_000 * 10);
1613        let mut e = Engine::new(
1614            Config::default(),
1615            Box::new(StubSynthesizer::new()),
1616            Box::new(sink.clone()),
1617        );
1618        e.handle(say("Hello there. This keeps it busy for quite a while indeed."));
1619        e.tick();
1620        e.handle(Command::Pause);
1621        assert_eq!(e.snapshot().state, State::Paused);
1622        assert!(sink.is_paused());
1623
1624        sink.inject_failure("audio device disappeared");
1625        e.tick();
1626
1627        let s = e.snapshot();
1628        assert_eq!(s.state, State::Error, "a device failure must surface even while paused");
1629        assert!(
1630            !sink.is_paused(),
1631            "leaving Paused for Error must unpause the sink in the same step"
1632        );
1633        assert_eq!(
1634            s.error.is_some(),
1635            s.state == State::Error,
1636            "error={:?} state={:?}",
1637            s.error,
1638            s.state
1639        );
1640    }
1641
1642    #[test]
1643    fn replace_sink_recovers_from_a_device_failure_that_arrived_while_paused() {
1644        let sink = FaultInjectableSink::new(24_000 * 10);
1645        let mut e = Engine::new(
1646            Config::default(),
1647            Box::new(StubSynthesizer::new()),
1648            Box::new(sink.clone()),
1649        );
1650        e.handle(say("Hello there. This keeps it busy for quite a while indeed."));
1651        e.tick();
1652        e.handle(Command::Pause);
1653        sink.inject_failure("audio device disappeared");
1654        e.tick();
1655        assert_eq!(e.snapshot().state, State::Error);
1656
1657        e.replace_sink(Box::new(VecSink::new(24_000 * 10)));
1658        let s = e.snapshot();
1659        assert_eq!(s.state, State::Idle, "a fresh sink clears the failure");
1660        assert_eq!(s.error, None);
1661
1662        e.submit("after recovery.".into(), SayOpts::default()).expect("accepted");
1663        for _ in 0..500 {
1664            e.tick();
1665        }
1666        assert!(
1667            e.audio_written() > 0,
1668            "the engine must work again after the sink is replaced, even though the failure \
1669             arrived while paused"
1670        );
1671    }
1672}