Skip to main content

aria_kit/synthesis/
session.rs

1//! One live synthesis run and the PCM it produces.
2
3use std::sync::Arc;
4use std::thread::{self, JoinHandle};
5
6#[cfg(feature = "playback")]
7use super::{PlaybackError, SpeakerOutput};
8use crate::audio::Clip;
9use crate::backend::BackendError;
10use crate::cancel::CancelToken;
11
12use super::{Engine, SpeakingRate, SpeechStream, SpeechText, SynthesisError, Voice, stream};
13
14const CLIP_READ_SAMPLES: usize = 4_096;
15
16/// A loaded synthesis engine ready to start utterances.
17pub struct Synthesis<T> {
18    pub(super) engine: Arc<T>,
19}
20
21impl<T> Synthesis<T> {
22    pub fn new(engine: T) -> Self {
23        Self {
24            engine: Arc::new(engine),
25        }
26    }
27}
28
29/// One PCM-producing utterance.
30///
31/// The session owns its synthesis worker. Dropping it cancels and joins that
32/// worker, so an abandoned reader cannot leave model inference running.
33pub struct Session {
34    stream: Option<SpeechStream>,
35    worker: Option<JoinHandle<Result<(), SynthesisError>>>,
36    stop: CancelToken,
37    #[cfg_attr(
38        not(feature = "playback"),
39        expect(dead_code, reason = "only playback reads it")
40    )]
41    cancel: CancelToken,
42    backend: &'static str,
43}
44
45impl<T: Engine> Synthesis<T> {
46    /// Starts an utterance with the engine's default voice and natural pace.
47    pub fn start(&self, text: &SpeechText) -> Result<Session, SynthesisError> {
48        self.start_with(text, None, SpeakingRate::standard(), &CancelToken::never())
49    }
50
51    /// Starts an utterance and returns its PCM as it is produced.
52    ///
53    /// An engine that owns playback cannot provide a PCM session. Use
54    /// `Synthesis::speak_with` for that engine instead.
55    pub fn start_with(
56        &self,
57        text: &SpeechText,
58        voice: Option<&Voice>,
59        rate: SpeakingRate,
60        cancel: &CancelToken,
61    ) -> Result<Session, SynthesisError> {
62        if cancel.is_cancelled() {
63            return Err(SynthesisError::Cancelled);
64        }
65        if self.engine.plays_audio_itself() {
66            return Err(SynthesisError::PcmUnavailable { backend: T::NAME });
67        }
68
69        let (writer, speech) = stream(self.engine.sample_rate());
70        let stop = CancelToken::new();
71        let requested = cancel.clone();
72        let stopped = stop.clone();
73        let synthesis_cancel =
74            CancelToken::from_fn(move || requested.is_cancelled() || stopped.is_cancelled());
75
76        let engine = Arc::clone(&self.engine);
77        let text = text.clone();
78        let voice = voice.cloned();
79        let worker_cancel = synthesis_cancel.clone();
80        let worker = thread::Builder::new()
81            .name(format!("{}-synthesis", T::NAME))
82            .spawn(move || {
83                engine.synthesize_with(&text, voice.as_ref(), rate, writer, &worker_cancel)
84            })
85            .map_err(|source| SynthesisError::Inference {
86                backend: T::NAME,
87                source: BackendError::engine(source),
88            })?;
89
90        Ok(Session {
91            stream: Some(speech),
92            worker: Some(worker),
93            stop,
94            cancel: synthesis_cancel,
95            backend: T::NAME,
96        })
97    }
98}
99
100impl Session {
101    pub fn sample_rate(&self) -> u32 {
102        self.stream
103            .as_ref()
104            .expect("the stream exists until the session is consumed")
105            .sample_rate()
106    }
107
108    /// Copies the next PCM samples into `out`, waiting for samples or the
109    /// terminal synthesis result. Zero means the utterance completed.
110    pub fn read(&mut self, out: &mut [f32]) -> Result<usize, SynthesisError> {
111        // Checked before the stream so a bad buffer never joins the worker.
112        if out.is_empty() {
113            return Err(SynthesisError::EmptyReadBuffer);
114        }
115        let result = self
116            .stream
117            .as_mut()
118            .expect("the stream exists until the session is consumed")
119            .read(out);
120        if matches!(result, Ok(0) | Err(_))
121            && let Err(error) = self.join()
122        {
123            return Err(error);
124        }
125        result
126    }
127
128    /// Reads the rest of the utterance as one clip at the engine's rate.
129    pub fn into_clip(mut self) -> Result<Clip, SynthesisError> {
130        let sample_rate = self.sample_rate();
131        let mut samples = Vec::new();
132        let mut block = [0.0; CLIP_READ_SAMPLES];
133        loop {
134            let read = self.read(&mut block)?;
135            if read == 0 {
136                break;
137            }
138            samples.extend_from_slice(&block[..read]);
139        }
140        Clip::new(sample_rate, samples).map_err(|source| SynthesisError::Inference {
141            backend: self.backend,
142            source: BackendError::engine(source),
143        })
144    }
145
146    /// Plays this utterance and reports when its first source sample reaches
147    /// the output callback.
148    #[cfg(feature = "playback")]
149    pub fn play_on(
150        mut self,
151        output: &SpeakerOutput,
152        started: impl FnOnce(),
153    ) -> Result<(), SynthesisError> {
154        let speech = self
155            .stream
156            .take()
157            .expect("the stream exists until the session is consumed");
158        let played = output.play_with(speech, &self.cancel, started);
159        let synthesized = self.join();
160
161        if let Err(error) = synthesized
162            && !matches!(error, SynthesisError::Cancelled)
163        {
164            return Err(error);
165        }
166        match played {
167            Ok(()) => Ok(()),
168            Err(PlaybackError::Cancelled) => Err(SynthesisError::Cancelled),
169            Err(source) => Err(SynthesisError::Playback { source }),
170        }
171    }
172
173    fn join(&mut self) -> Result<(), SynthesisError> {
174        let Some(worker) = self.worker.take() else {
175            return Ok(());
176        };
177        worker.join().unwrap_or(Err(SynthesisError::WorkerPanicked {
178            backend: self.backend,
179        }))
180    }
181}
182
183impl Drop for Session {
184    fn drop(&mut self) {
185        self.stop.cancel();
186        drop(self.stream.take());
187        let _finished = self.join();
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use std::path::PathBuf;
194    use std::sync::atomic::{AtomicBool, Ordering};
195
196    use super::*;
197    use crate::synthesis::{SpeechWriter, Synthesizer};
198
199    enum Behavior {
200        Samples,
201        WaitForCancel(Arc<AtomicBool>),
202        UnsupportedVoice,
203        Panic,
204        Direct,
205    }
206
207    struct Fake {
208        behavior: Behavior,
209    }
210
211    impl Fake {
212        fn with(behavior: Behavior) -> Self {
213            Self { behavior }
214        }
215    }
216
217    impl Engine for Fake {
218        const NAME: &'static str = "fake";
219        const MODEL: &'static [crate::backend::ModelDownload] = &[];
220
221        type Config = Behavior;
222
223        fn check_from(_config: &Self::Config) -> Result<PathBuf, SynthesisError> {
224            Ok(PathBuf::from("fake"))
225        }
226
227        fn load_from(config: Self::Config) -> Result<Self, SynthesisError> {
228            Ok(Self::with(config))
229        }
230    }
231
232    impl Synthesizer for Fake {
233        fn sample_rate(&self) -> u32 {
234            24_000
235        }
236
237        fn plays_audio_itself(&self) -> bool {
238            matches!(self.behavior, Behavior::Direct)
239        }
240
241        fn synthesize_with(
242            &self,
243            _text: &SpeechText,
244            _voice: Option<&Voice>,
245            _rate: SpeakingRate,
246            mut out: SpeechWriter,
247            cancel: &CancelToken,
248        ) -> Result<(), SynthesisError> {
249            match &self.behavior {
250                Behavior::Samples => {
251                    out.write(&[0.1, 0.2, 0.3])
252                        .expect("the session is listening");
253                    out.finish();
254                    Ok(())
255                }
256                Behavior::WaitForCancel(stopped) => {
257                    while !cancel.is_cancelled() {
258                        std::thread::yield_now();
259                    }
260                    stopped.store(true, Ordering::Release);
261                    out.fail(SynthesisError::Cancelled);
262                    Err(SynthesisError::Cancelled)
263                }
264                Behavior::UnsupportedVoice => {
265                    out.fail(SynthesisError::UnsupportedVoice {
266                        backend: Self::NAME,
267                        voice: "unheard".to_owned(),
268                    });
269                    Err(SynthesisError::UnsupportedVoice {
270                        backend: Self::NAME,
271                        voice: "original".to_owned(),
272                    })
273                }
274                Behavior::Panic => panic!("scripted worker panic"),
275                Behavior::Direct => {
276                    out.finish();
277                    Ok(())
278                }
279            }
280        }
281    }
282
283    fn text() -> SpeechText {
284        SpeechText::new("hello").expect("test text is valid")
285    }
286
287    #[test]
288    fn a_session_streams_pcm_and_its_sample_rate() {
289        let synthesis = Synthesis::new(Fake::with(Behavior::Samples));
290        let mut session = synthesis.start(&text()).expect("the session starts");
291        assert_eq!(session.sample_rate(), 24_000);
292
293        let mut heard = Vec::new();
294        let mut samples = [0.0; 2];
295        loop {
296            let copied = session.read(&mut samples).expect("synthesis succeeds");
297            if copied == 0 {
298                break;
299            }
300            heard.extend_from_slice(&samples[..copied]);
301        }
302
303        assert_eq!(heard, [0.1, 0.2, 0.3]);
304    }
305
306    #[test]
307    fn a_session_reads_to_one_clip_at_the_engine_rate() {
308        let synthesis = Synthesis::new(Fake::with(Behavior::Samples));
309        let session = synthesis.start(&text()).expect("the session starts");
310
311        let clip = session.into_clip().expect("synthesis succeeds");
312
313        assert_eq!(clip.sample_rate(), 24_000);
314        assert_eq!(clip.samples(), [0.1, 0.2, 0.3]);
315    }
316
317    #[test]
318    fn dropping_a_session_cancels_and_joins_its_worker() {
319        let stopped = Arc::new(AtomicBool::new(false));
320        let synthesis = Synthesis::new(Fake::with(Behavior::WaitForCancel(Arc::clone(&stopped))));
321        let session = synthesis.start(&text()).expect("the session starts");
322
323        drop(session);
324
325        assert!(stopped.load(Ordering::Acquire));
326    }
327
328    #[test]
329    fn a_terminal_read_returns_the_worker_cause() {
330        let synthesis = Synthesis::new(Fake::with(Behavior::UnsupportedVoice));
331        let mut session = synthesis.start(&text()).expect("the session starts");
332
333        let error = session
334            .read(&mut [0.0; 1])
335            .expect_err("the engine rejects the voice");
336
337        assert!(matches!(
338            error,
339            SynthesisError::UnsupportedVoice { voice, .. } if voice == "original"
340        ));
341    }
342
343    #[test]
344    fn a_panicked_worker_has_a_typed_terminal_error() {
345        let synthesis = Synthesis::new(Fake::with(Behavior::Panic));
346        let mut session = synthesis.start(&text()).expect("the session starts");
347
348        let error = session.read(&mut [0.0; 1]).expect_err("the worker panics");
349
350        assert!(matches!(
351            error,
352            SynthesisError::WorkerPanicked { backend: "fake" }
353        ));
354    }
355
356    #[test]
357    fn a_direct_playback_engine_refuses_a_pcm_session() {
358        let synthesis = Synthesis::new(Fake::with(Behavior::Direct));
359
360        let error = synthesis
361            .start(&text())
362            .err()
363            .expect("a direct engine has no PCM");
364
365        assert!(matches!(
366            error,
367            SynthesisError::PcmUnavailable { backend: "fake" }
368        ));
369    }
370
371    #[test]
372    fn a_boxed_or_shared_engine_still_plays_audio_itself() {
373        let boxed: Box<dyn Synthesizer> = Box::new(Fake::with(Behavior::Direct));
374        let shared = Arc::new(Fake::with(Behavior::Direct));
375
376        assert!(Synthesizer::plays_audio_itself(&boxed));
377        assert!(Synthesizer::plays_audio_itself(&shared));
378    }
379
380    #[cfg(feature = "playback")]
381    #[test]
382    fn a_direct_engine_speaks_without_a_pcm_session() {
383        let synthesis = Synthesis::new(Fake::with(Behavior::Direct));
384
385        synthesis
386            .speak_with(
387                &text(),
388                None,
389                SpeakingRate::standard(),
390                &CancelToken::never(),
391            )
392            .expect("the engine plays the speech itself");
393    }
394
395    #[test]
396    fn an_empty_read_buffer_is_rejected() {
397        let synthesis = Synthesis::new(Fake::with(Behavior::Samples));
398        let mut session = synthesis.start(&text()).expect("the session starts");
399
400        assert!(matches!(
401            session.read(&mut []),
402            Err(SynthesisError::EmptyReadBuffer)
403        ));
404        assert_eq!(
405            session
406                .read(&mut [0.0; 4])
407                .expect("the session can still read"),
408            3
409        );
410    }
411}