Skip to main content

pipecrab_tts/
synthesizer.rs

1//! The [`Synthesizer`] trait, its [`TtsError`], and the [`TtsAudioStream`] alias.
2
3use async_trait::async_trait;
4use pipecrab_core::{AudioChunk, AudioFormat};
5use pipecrab_runtime::MaybeSendSync;
6
7/// The audio a [`Synthesizer::synthesize`] call yields: a boxed stream of
8/// [`AudioChunk`] results, delivered incrementally.
9///
10/// `BoxStream` on native and `LocalBoxStream` on `wasm32`, behind one cfg'd
11/// alias — the same `Send`-where-it-exists split as
12/// [`MaybeSend`](pipecrab_runtime::MaybeSend): the pipeline is one logical task
13/// that stays `Send` for a work-stealing executor natively, while on `wasm32`
14/// (one thread, `!Send` JS handles) that bound must vanish.
15#[cfg(not(target_arch = "wasm32"))]
16pub type TtsAudioStream = futures::stream::BoxStream<'static, Result<AudioChunk, TtsError>>;
17/// The audio a [`Synthesizer::synthesize`] call yields: a boxed stream of
18/// [`AudioChunk`] results, delivered incrementally.
19#[cfg(target_arch = "wasm32")]
20pub type TtsAudioStream = futures::stream::LocalBoxStream<'static, Result<AudioChunk, TtsError>>;
21
22/// The swappable text-to-speech capability: text in, audio out incrementally.
23///
24/// This is the durable interface. A native engine and a browser engine (in a Web
25/// Worker) both implement this one trait, so [`TtsStage`](crate::TtsStage) — and
26/// the pipeline above it — never names a concrete model.
27///
28/// # Streaming is a barge-in requirement
29///
30/// [`synthesize`](Synthesizer::synthesize) yields audio a chunk at a time rather
31/// than one buffer at the end: every stream item is a preemption point the run
32/// loop can drop an in-flight synthesis at, so a user barging in stops playback
33/// within one chunk instead of after a whole utterance. Dropping the stream is
34/// how the *stage* stops pulling; [`cancel`](Synthesizer::cancel) is how the
35/// *engine* stops producing.
36///
37/// [`cancel`](Synthesizer::cancel) is a *control call* (see
38/// [`Processor`](pipecrab_core::Processor)'s control-call carve-out): it flips an
39/// atomic the engine's worker observes, so it is synchronous, non-blocking, and
40/// safe to invoke directly from a stage's `decide_*` where the barge-in is
41/// decided. [`synthesize`](Synthesizer::synthesize) is async because it hands
42/// text to that worker and returns its audio stream.
43///
44/// `?Send` on `wasm32` matches pipecrab's single-threaded execution model, so
45/// one implementation runs unchanged on a current-thread executor and in the
46/// browser, where `Send` bounds cannot be satisfied.
47#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
48#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
49pub trait Synthesizer: MaybeSendSync {
50    /// The [`AudioFormat`] of every chunk this engine yields (e.g. 24 kHz mono).
51    ///
52    /// Unlike an STT or VAD engine, a synthesizer does not *accept* an input
53    /// format to reject — it *produces* audio, so it reports the one format its
54    /// chunks arrive in. Resampling to the playback rate belongs to a separate
55    /// stage.
56    fn output_format(&self) -> AudioFormat;
57
58    /// Synthesize `text`, yielding audio incrementally.
59    ///
60    /// Takes `&self`: like every [`Stage::perform`](pipecrab_runtime::Stage::perform),
61    /// synthesis must not mutate observable state, so the run loop can drop an
62    /// in-flight call — at any stream item — on a barge-in interrupt without
63    /// tearing anything. Every item of the returned [`TtsAudioStream`] is such a
64    /// preemption point.
65    async fn synthesize(&self, text: &str) -> Result<TtsAudioStream, TtsError>;
66
67    /// Control call: stop in-flight synthesis. Sync, non-blocking, idempotent.
68    ///
69    /// Flips a flag the engine's worker observes; the next
70    /// [`synthesize`](Synthesizer::synthesize) starts clean. Safe to call from a
71    /// stage's synchronous `decide_*` — see the trait-level note.
72    fn cancel(&self);
73}
74
75/// Why a [`Synthesizer::synthesize`] call failed.
76///
77/// Mirrors the message-plus-kind shape of the pipeline's other error types (e.g.
78/// `pipecrab-stt`'s `SttError`) so the conversion at the stage boundary
79/// (`impl From<TtsError> for StageError`) is direct. A synthesizer produces
80/// audio rather than consuming a caller-chosen format, so it has no
81/// `UnsupportedFormat` variant to reject with.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub enum TtsError {
84    /// The synthesis engine itself failed — an inference error, a worker that
85    /// crashed, a model that never loaded. Carries a human-readable description.
86    Engine(String),
87}
88
89impl std::fmt::Display for TtsError {
90    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        match self {
92            TtsError::Engine(msg) => write!(f, "tts engine error: {msg}"),
93        }
94    }
95}
96
97impl std::error::Error for TtsError {}