1use std::time::Duration;
16
17use async_trait::async_trait;
18use bytes::Bytes;
19use thiserror::Error;
20
21use crate::stream::ResponseStream;
22
23pub const DEFAULT_MAX_AUDIO_BYTES: u64 = 25 * 1024 * 1024;
25
26pub const DEFAULT_MAX_AUDIO_SECONDS: u32 = 600;
28
29pub const DEFAULT_MAX_SYNTHESIS_CHARS: u32 = 4096;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
36#[non_exhaustive]
37pub enum AudioFormat {
38 Mp3,
39 Wav,
40 Opus,
41 Flac,
42 Aac,
43 Pcm,
44}
45
46impl AudioFormat {
47 #[must_use]
49 pub const fn mime(self) -> &'static str {
50 match self {
51 AudioFormat::Mp3 => "audio/mpeg",
52 AudioFormat::Wav => "audio/wav",
53 AudioFormat::Opus => "audio/opus",
54 AudioFormat::Flac => "audio/flac",
55 AudioFormat::Aac => "audio/aac",
56 AudioFormat::Pcm => "audio/pcm",
57 }
58 }
59
60 #[must_use]
62 pub const fn name(self) -> &'static str {
63 match self {
64 AudioFormat::Mp3 => "mp3",
65 AudioFormat::Wav => "wav",
66 AudioFormat::Opus => "opus",
67 AudioFormat::Flac => "flac",
68 AudioFormat::Aac => "aac",
69 AudioFormat::Pcm => "pcm",
70 }
71 }
72
73 #[must_use]
76 pub fn from_mime(mime: &str) -> Option<Self> {
77 let mime = mime.split(';').next()?.trim().to_ascii_lowercase();
78 match mime.as_str() {
79 "audio/mpeg" | "audio/mp3" => Some(AudioFormat::Mp3),
80 "audio/wav" | "audio/x-wav" | "audio/wave" | "audio/vnd.wave" => Some(AudioFormat::Wav),
81 "audio/opus" | "audio/ogg" | "audio/webm" => Some(AudioFormat::Opus),
82 "audio/flac" | "audio/x-flac" => Some(AudioFormat::Flac),
83 "audio/aac" | "audio/mp4" => Some(AudioFormat::Aac),
84 "audio/pcm" | "audio/l16" => Some(AudioFormat::Pcm),
85 _ => None,
86 }
87 }
88}
89
90impl std::fmt::Display for AudioFormat {
91 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92 f.write_str(self.name())
93 }
94}
95
96#[must_use]
98pub fn primary_subtag(tag: &str) -> &str {
99 tag.split('-').next().unwrap_or(tag)
100}
101
102#[derive(Debug, Clone, Default, PartialEq, Eq)]
106pub struct SpeechCapabilities {
107 pub streaming_output: bool,
108 pub word_timings: bool,
109 pub languages: Vec<String>,
110 pub formats: Vec<AudioFormat>,
111}
112
113impl SpeechCapabilities {
114 #[must_use]
117 pub fn supports_language(&self, language: &str) -> bool {
118 if self.languages.is_empty() {
119 return true;
120 }
121 self.languages
122 .iter()
123 .any(|tag| primary_subtag(tag).eq_ignore_ascii_case(primary_subtag(language)))
124 }
125
126 #[must_use]
128 pub fn supports_format(&self, format: AudioFormat) -> bool {
129 self.formats.contains(&format)
130 }
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub struct SpeechLimits {
137 pub max_audio_bytes: u64,
138 pub max_audio_seconds: u32,
139 pub max_synthesis_chars: u32,
140}
141
142impl Default for SpeechLimits {
143 fn default() -> Self {
144 Self {
145 max_audio_bytes: DEFAULT_MAX_AUDIO_BYTES,
146 max_audio_seconds: DEFAULT_MAX_AUDIO_SECONDS,
147 max_synthesis_chars: DEFAULT_MAX_SYNTHESIS_CHARS,
148 }
149 }
150}
151
152#[derive(Debug, Clone, PartialEq, Eq)]
154pub struct SynthesizeRequest {
155 pub text: String,
156 pub voice: String,
157 pub language: Option<String>,
159 pub format: AudioFormat,
160}
161
162impl SynthesizeRequest {
163 #[must_use]
164 pub fn new(text: impl Into<String>, voice: impl Into<String>, format: AudioFormat) -> Self {
165 Self {
166 text: text.into(),
167 voice: voice.into(),
168 language: None,
169 format,
170 }
171 }
172
173 #[must_use]
174 pub fn with_language(mut self, language: impl Into<String>) -> Self {
175 self.language = Some(language.into());
176 self
177 }
178}
179
180#[derive(Debug, Clone, PartialEq)]
182pub struct TranscribeRequest {
183 pub audio: Bytes,
184 pub mime: String,
185 pub language_hint: Option<String>,
187 pub duration: Option<Duration>,
191}
192
193impl TranscribeRequest {
194 #[must_use]
195 pub fn new(audio: Bytes, mime: impl Into<String>) -> Self {
196 Self {
197 audio,
198 mime: mime.into(),
199 language_hint: None,
200 duration: None,
201 }
202 }
203
204 #[must_use]
205 pub fn with_language_hint(mut self, language_hint: impl Into<String>) -> Self {
206 self.language_hint = Some(language_hint.into());
207 self
208 }
209
210 #[must_use]
211 pub fn with_duration(mut self, duration: Duration) -> Self {
212 self.duration = Some(duration);
213 self
214 }
215}
216
217#[derive(Debug, Clone, PartialEq)]
219pub struct WordTiming {
220 pub word: String,
221 pub start: Duration,
222 pub end: Duration,
223 pub confidence: Option<f32>,
224}
225
226impl WordTiming {
227 #[must_use]
228 pub fn new(word: impl Into<String>, start: Duration, end: Duration) -> Self {
229 Self {
230 word: word.into(),
231 start,
232 end,
233 confidence: None,
234 }
235 }
236}
237
238#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
241pub struct SpeechUsage {
242 pub audio_millis: u64,
243 pub characters: u64,
244}
245
246impl SpeechUsage {
247 #[must_use]
248 pub const fn transcribed(audio_millis: u64) -> Self {
249 Self {
250 audio_millis,
251 characters: 0,
252 }
253 }
254
255 #[must_use]
256 pub const fn synthesised(characters: u64) -> Self {
257 Self {
258 audio_millis: 0,
259 characters,
260 }
261 }
262}
263
264#[derive(Debug, Clone, Default, PartialEq)]
266pub struct Transcript {
267 pub text: String,
268 pub language: Option<String>,
270 pub words: Vec<WordTiming>,
271 pub usage: SpeechUsage,
272}
273
274impl Transcript {
275 #[must_use]
276 pub fn new(text: impl Into<String>) -> Self {
277 Self {
278 text: text.into(),
279 ..Self::default()
280 }
281 }
282
283 #[must_use]
284 pub fn with_language(mut self, language: impl Into<String>) -> Self {
285 self.language = Some(language.into());
286 self
287 }
288
289 #[must_use]
290 pub fn with_words(mut self, words: Vec<WordTiming>) -> Self {
291 self.words = words;
292 self
293 }
294
295 #[must_use]
296 pub const fn with_usage(mut self, usage: SpeechUsage) -> Self {
297 self.usage = usage;
298 self
299 }
300}
301
302#[derive(Clone)]
306pub struct Synthesis {
307 pub format: AudioFormat,
308 pub usage: SpeechUsage,
309 pub audio: ResponseStream,
310}
311
312impl Synthesis {
313 #[must_use]
314 pub const fn new(format: AudioFormat, usage: SpeechUsage, audio: ResponseStream) -> Self {
315 Self {
316 format,
317 usage,
318 audio,
319 }
320 }
321}
322
323impl std::fmt::Debug for Synthesis {
324 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
325 f.debug_struct("Synthesis")
326 .field("format", &self.format)
327 .field("usage", &self.usage)
328 .finish_non_exhaustive()
329 }
330}
331
332#[derive(Debug, Clone, PartialEq, Eq)]
334pub struct Voice {
335 pub id: String,
336 pub name: String,
337 pub languages: Vec<String>,
339}
340
341impl Voice {
342 #[must_use]
343 pub fn new(id: impl Into<String>, name: impl Into<String>, languages: Vec<String>) -> Self {
344 Self {
345 id: id.into(),
346 name: name.into(),
347 languages,
348 }
349 }
350}
351
352#[derive(Debug, Clone, Copy, PartialEq, Eq)]
356pub enum SpeechLimit {
357 AudioBytes,
358 AudioSeconds,
359 SynthesisChars,
360}
361
362impl SpeechLimit {
363 #[must_use]
365 pub const fn name(self) -> &'static str {
366 match self {
367 SpeechLimit::AudioBytes => "audio bytes",
368 SpeechLimit::AudioSeconds => "audio seconds",
369 SpeechLimit::SynthesisChars => "synthesis characters",
370 }
371 }
372}
373
374#[derive(Debug, Clone, PartialEq, Eq, Error)]
380#[non_exhaustive]
381pub enum SpeechError {
382 #[error("no speech provider is wired")]
384 NotConfigured,
385 #[error("over the {} ceiling: {actual} > {max}", limit.name())]
387 LimitExceeded {
388 limit: SpeechLimit,
389 actual: u64,
390 max: u64,
391 },
392 #[error("unsupported audio format: {}", scrub(.0))]
394 UnsupportedFormat(String),
395 #[error("unsupported language: {}", scrub(.0))]
397 UnsupportedLanguage(String),
398 #[error("speech request rejected: {}", scrub(.0))]
400 InvalidInput(String),
401 #[error("speech call failed, retryable")]
404 Transient { retry_after: Option<Duration> },
405 #[error("speech provider failed: {}", scrub(.0))]
407 Provider(String),
408}
409
410fn scrub(value: &str) -> String {
412 crate::logging::scrub_text(value)
413}
414
415impl SpeechError {
416 #[must_use]
418 pub fn retry_after(&self) -> Option<Duration> {
419 match self {
420 SpeechError::Transient { retry_after } => *retry_after,
421 _ => None,
422 }
423 }
424}
425
426#[must_use]
430pub fn wav_duration(audio: &[u8]) -> Option<Duration> {
431 if audio.len() < 12 || &audio[0..4] != b"RIFF" || &audio[8..12] != b"WAVE" {
432 return None;
433 }
434 let (mut byte_rate, mut data_len) = (0u32, None);
435 let mut pos = 12usize;
436 while pos + 8 <= audio.len() {
437 let id = audio.get(pos..pos + 4)?;
438 let size = usize::try_from(u32::from_le_bytes(
439 audio.get(pos + 4..pos + 8)?.try_into().ok()?,
440 ))
441 .ok()?;
442 match id {
443 b"fmt " if size >= 16 => {
444 byte_rate = u32::from_le_bytes(audio.get(pos + 16..pos + 20)?.try_into().ok()?);
445 }
446 b"data" => {
447 data_len = Some(size);
448 break;
449 }
450 _ => {}
451 }
452 pos = pos.checked_add(size.checked_add(8)?.checked_add(size & 1)?)?;
455 }
456 let rate = u64::from(byte_rate);
458 if rate == 0 {
459 return None;
460 }
461 let len = u64::try_from(data_len?).ok()?;
462 Some(Duration::from_millis(
463 len.saturating_mul(1_000).div_ceil(rate),
464 ))
465}
466
467pub fn check_transcribe(
478 request: &TranscribeRequest,
479 limits: &SpeechLimits,
480) -> Result<(), SpeechError> {
481 if request.audio.is_empty() {
482 return Err(SpeechError::InvalidInput(
483 "the audio payload is empty".to_owned(),
484 ));
485 }
486 if request.mime.is_empty() || request.mime.bytes().any(|byte| byte.is_ascii_control()) {
487 return Err(SpeechError::InvalidInput(
488 "the audio media type is empty or carries control characters".to_owned(),
489 ));
490 }
491 let bytes = u64::try_from(request.audio.len()).unwrap_or(u64::MAX);
492 if bytes > limits.max_audio_bytes {
493 return Err(SpeechError::LimitExceeded {
494 limit: SpeechLimit::AudioBytes,
495 actual: bytes,
496 max: limits.max_audio_bytes,
497 });
498 }
499 let millis = match request.duration {
500 Some(duration) => Some(u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)),
501 None if AudioFormat::from_mime(&request.mime) == Some(AudioFormat::Wav) => {
502 wav_duration(&request.audio)
503 .map(|duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX))
504 }
505 None => None,
506 };
507 if let Some(actual) = millis {
508 let max = u64::from(limits.max_audio_seconds) * 1_000;
509 if actual > max {
510 return Err(SpeechError::LimitExceeded {
511 limit: SpeechLimit::AudioSeconds,
512 actual,
513 max,
514 });
515 }
516 }
517 Ok(())
518}
519
520pub fn check_synthesize(
531 request: &SynthesizeRequest,
532 limits: &SpeechLimits,
533 capabilities: &SpeechCapabilities,
534) -> Result<(), SpeechError> {
535 if request.text.is_empty() {
536 return Err(SpeechError::InvalidInput("the text is empty".to_owned()));
537 }
538 let chars = u64::try_from(request.text.chars().count()).unwrap_or(u64::MAX);
539 if chars > u64::from(limits.max_synthesis_chars) {
540 return Err(SpeechError::LimitExceeded {
541 limit: SpeechLimit::SynthesisChars,
542 actual: chars,
543 max: u64::from(limits.max_synthesis_chars),
544 });
545 }
546 if !capabilities.supports_format(request.format) {
547 return Err(SpeechError::UnsupportedFormat(
548 request.format.mime().to_owned(),
549 ));
550 }
551 if let Some(language) = &request.language
552 && !capabilities.supports_language(language)
553 {
554 return Err(SpeechError::UnsupportedLanguage(language.clone()));
555 }
556 Ok(())
557}
558
559#[async_trait]
564pub trait Speech: Send + Sync {
565 async fn transcribe(&self, request: &TranscribeRequest) -> Result<Transcript, SpeechError>;
571
572 async fn synthesize(&self, request: &SynthesizeRequest) -> Result<Synthesis, SpeechError>;
578
579 async fn voices(&self, language: Option<&str>) -> Result<Vec<Voice>, SpeechError>;
585
586 fn capabilities(&self) -> SpeechCapabilities;
589
590 fn limits(&self) -> SpeechLimits {
593 SpeechLimits::default()
594 }
595}
596
597#[derive(Debug, Clone, Copy, Default)]
601pub struct NotConfiguredSpeech;
602
603#[async_trait]
604impl Speech for NotConfiguredSpeech {
605 async fn transcribe(&self, _request: &TranscribeRequest) -> Result<Transcript, SpeechError> {
606 Err(SpeechError::NotConfigured)
607 }
608
609 async fn synthesize(&self, _request: &SynthesizeRequest) -> Result<Synthesis, SpeechError> {
610 Err(SpeechError::NotConfigured)
611 }
612
613 async fn voices(&self, _language: Option<&str>) -> Result<Vec<Voice>, SpeechError> {
614 Err(SpeechError::NotConfigured)
615 }
616
617 fn capabilities(&self) -> SpeechCapabilities {
618 SpeechCapabilities::default()
619 }
620}
621
622#[cfg(test)]
623mod tests {
624 use super::*;
625 use futures_core::Stream as _;
626 use std::pin::Pin;
627 use std::task::{Context, Poll};
628
629 fn wav(byte_rate: u32, data: &[u8]) -> Bytes {
632 let data_len = u32::try_from(data.len()).expect("a test clip fits in u32");
633 let mut out = Vec::with_capacity(44 + data.len());
634 out.extend_from_slice(b"RIFF");
635 out.extend_from_slice(&(36 + data_len).to_le_bytes());
636 out.extend_from_slice(b"WAVEfmt ");
637 out.extend_from_slice(&16u32.to_le_bytes());
638 out.extend_from_slice(&1u16.to_le_bytes()); out.extend_from_slice(&1u16.to_le_bytes()); out.extend_from_slice(&8_000u32.to_le_bytes()); out.extend_from_slice(&byte_rate.to_le_bytes());
642 out.extend_from_slice(&1u16.to_le_bytes()); out.extend_from_slice(&8u16.to_le_bytes()); out.extend_from_slice(b"data");
645 out.extend_from_slice(&data_len.to_le_bytes());
646 out.extend_from_slice(data);
647 Bytes::from(out)
648 }
649
650 fn wav_with_odd_chunk(byte_rate: u32, data: &[u8]) -> Bytes {
653 let data_len = u32::try_from(data.len()).expect("a test clip fits in u32");
654 let mut out = Vec::with_capacity(56 + data.len());
655 out.extend_from_slice(b"RIFF");
656 out.extend_from_slice(&(36 + 12 + data_len).to_le_bytes());
657 out.extend_from_slice(b"WAVE");
658 out.extend_from_slice(b"LIST");
659 out.extend_from_slice(&3u32.to_le_bytes());
660 out.extend_from_slice(b"abc");
661 out.extend_from_slice(&[0u8]); out.extend_from_slice(b"fmt ");
663 out.extend_from_slice(&16u32.to_le_bytes());
664 out.extend_from_slice(&1u16.to_le_bytes()); out.extend_from_slice(&1u16.to_le_bytes()); out.extend_from_slice(&8_000u32.to_le_bytes()); out.extend_from_slice(&byte_rate.to_le_bytes());
668 out.extend_from_slice(&1u16.to_le_bytes()); out.extend_from_slice(&8u16.to_le_bytes()); out.extend_from_slice(b"data");
671 out.extend_from_slice(&data_len.to_le_bytes());
672 out.extend_from_slice(data);
673 Bytes::from(out)
674 }
675
676 fn limits() -> SpeechLimits {
677 SpeechLimits {
678 max_audio_bytes: 100,
679 max_audio_seconds: 10,
680 max_synthesis_chars: 20,
681 }
682 }
683
684 fn capabilities() -> SpeechCapabilities {
685 SpeechCapabilities {
686 streaming_output: true,
687 word_timings: true,
688 languages: vec!["en-US".to_owned()],
689 formats: vec![AudioFormat::Mp3, AudioFormat::Wav],
690 }
691 }
692
693 #[test]
694 fn formats_round_trip_through_mime_and_name() {
695 for format in [
696 AudioFormat::Mp3,
697 AudioFormat::Wav,
698 AudioFormat::Opus,
699 AudioFormat::Flac,
700 AudioFormat::Aac,
701 AudioFormat::Pcm,
702 ] {
703 assert_eq!(AudioFormat::from_mime(format.mime()), Some(format));
704 assert_eq!(format.to_string(), format.name());
705 }
706 for (mime, expected) in [
709 ("audio/mp3", Some(AudioFormat::Mp3)),
710 ("audio/x-wav", Some(AudioFormat::Wav)),
711 ("audio/ogg", Some(AudioFormat::Opus)),
712 ("audio/webm", Some(AudioFormat::Opus)),
713 ("audio/x-flac", Some(AudioFormat::Flac)),
714 ("audio/mp4", Some(AudioFormat::Aac)),
715 ("Audio/WAV; codec=1", Some(AudioFormat::Wav)),
716 ("video/mp4", None),
717 ] {
718 assert_eq!(AudioFormat::from_mime(mime), expected, "{mime}");
719 }
720 }
721
722 #[test]
723 fn wav_duration_reads_the_header_and_never_panics_on_garbage() {
724 assert_eq!(
726 wav_duration(&wav(8_000, &vec![0u8; 12_000])),
727 Some(Duration::from_millis(1_500))
728 );
729 assert_eq!(wav_duration(b"OggS whatever"), None);
731 assert_eq!(wav_duration(b"RIFF".as_slice()), None);
732 assert_eq!(wav_duration(&wav(0, b"no rate")), None);
733 let mut truncated = wav(8_000, b"payload").to_vec();
734 truncated.truncate(30);
735 assert_eq!(wav_duration(&truncated), None);
736 assert_eq!(
739 wav_duration(&wav_with_odd_chunk(8_000, &vec![0u8; 12_000])),
740 Some(Duration::from_millis(1_500))
741 );
742 }
743
744 #[test]
745 fn transcribe_refuses_each_limit_before_the_network() {
746 let limits = limits();
747 let empty = TranscribeRequest::new(Bytes::new(), "audio/wav");
748 assert_eq!(
749 check_transcribe(&empty, &limits),
750 Err(SpeechError::InvalidInput(
751 "the audio payload is empty".to_owned()
752 ))
753 );
754
755 let over = TranscribeRequest::new(Bytes::from(vec![0u8; 101]), "audio/wav");
756 assert_eq!(
757 check_transcribe(&over, &limits),
758 Err(SpeechError::LimitExceeded {
759 limit: SpeechLimit::AudioBytes,
760 actual: 101,
761 max: 100,
762 })
763 );
764
765 let unnamed = TranscribeRequest::new(Bytes::from(vec![0u8; 4]), "");
769 assert_eq!(
770 check_transcribe(&unnamed, &limits),
771 Err(SpeechError::InvalidInput(
772 "the audio media type is empty or carries control characters".to_owned()
773 ))
774 );
775 let injected = TranscribeRequest::new(Bytes::from(vec![0u8; 4]), "audio/wav\r\nx: y");
776 assert_eq!(
777 check_transcribe(&injected, &limits),
778 Err(SpeechError::InvalidInput(
779 "the audio media type is empty or carries control characters".to_owned()
780 ))
781 );
782
783 let long = TranscribeRequest::new(Bytes::from(vec![0u8; 4]), "audio/wav")
785 .with_duration(Duration::from_secs(11));
786 assert_eq!(
787 check_transcribe(&long, &limits),
788 Err(SpeechError::LimitExceeded {
789 limit: SpeechLimit::AudioSeconds,
790 actual: 11_000,
791 max: 10_000,
792 })
793 );
794
795 let derived = TranscribeRequest::new(wav(100, &[0u8; 4]), "audio/wav"); assert_eq!(check_transcribe(&derived, &limits), Ok(()));
800 let derived_over = TranscribeRequest::new(wav(4, &[0u8; 48]), "audio/wav");
801 assert!(matches!(
802 check_transcribe(&derived_over, &limits),
803 Err(SpeechError::LimitExceeded {
804 limit: SpeechLimit::AudioSeconds,
805 ..
806 })
807 ));
808
809 let unknown = TranscribeRequest::new(Bytes::from(vec![0u8; 4]), "audio/mpeg");
811 assert_eq!(check_transcribe(&unknown, &limits), Ok(()));
812 }
813
814 #[test]
815 fn synthesize_refuses_each_limit_and_unsupported_ask_before_the_network() {
816 let limits = limits();
817 let capabilities = capabilities();
818 let request = SynthesizeRequest::new("hello world", "narrator", AudioFormat::Mp3);
819 assert_eq!(check_synthesize(&request, &limits, &capabilities), Ok(()));
820
821 let empty = SynthesizeRequest::new("", "narrator", AudioFormat::Mp3);
822 assert_eq!(
823 check_synthesize(&empty, &limits, &capabilities),
824 Err(SpeechError::InvalidInput("the text is empty".to_owned()))
825 );
826
827 let long = SynthesizeRequest::new("x".repeat(21), "narrator", AudioFormat::Mp3);
828 assert_eq!(
829 check_synthesize(&long, &limits, &capabilities),
830 Err(SpeechError::LimitExceeded {
831 limit: SpeechLimit::SynthesisChars,
832 actual: 21,
833 max: 20,
834 })
835 );
836
837 let format = SynthesizeRequest::new("hi", "narrator", AudioFormat::Opus);
838 assert_eq!(
839 check_synthesize(&format, &limits, &capabilities),
840 Err(SpeechError::UnsupportedFormat("audio/opus".to_owned()))
841 );
842
843 let language =
844 SynthesizeRequest::new("hi", "narrator", AudioFormat::Mp3).with_language("fr-FR");
845 assert_eq!(
846 check_synthesize(&language, &limits, &capabilities),
847 Err(SpeechError::UnsupportedLanguage("fr-FR".to_owned()))
848 );
849 let english =
851 SynthesizeRequest::new("hi", "narrator", AudioFormat::Mp3).with_language("en");
852 assert_eq!(check_synthesize(&english, &limits, &capabilities), Ok(()));
853 }
854
855 #[test]
856 fn provider_text_is_scrubbed_in_display_but_not_debug() {
857 let error =
858 SpeechError::Provider("400 for https://api.example.test?token=secret".to_owned());
859 let text = error.to_string();
860 assert!(!text.contains("secret"), "{text}");
861 assert!(format!("{error:?}").contains("secret"));
862 assert_eq!(SpeechError::NotConfigured.retry_after(), None);
863 let throttled = SpeechError::Transient {
864 retry_after: Some(Duration::from_secs(30)),
865 };
866 assert_eq!(throttled.retry_after(), Some(Duration::from_secs(30)));
867 }
868
869 struct Gated {
873 head: Option<Result<Vec<u8>, String>>,
874 gate: futures_channel::oneshot::Receiver<()>,
875 tail: bool,
876 }
877
878 impl futures_core::Stream for Gated {
879 type Item = Result<Vec<u8>, String>;
880
881 fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
882 let this = self.get_mut();
883 if let Some(chunk) = this.head.take() {
884 return Poll::Ready(Some(chunk));
885 }
886 if this.tail {
887 return Poll::Ready(None);
888 }
889 match Pin::new(&mut this.gate).poll(cx) {
890 Poll::Ready(_) => {
891 this.tail = true;
892 Poll::Ready(Some(Ok(b"tail".to_vec())))
893 }
894 Poll::Pending => Poll::Pending,
895 }
896 }
897 }
898
899 #[allow(clippy::disallowed_types)]
902 type GateSlot = std::sync::Mutex<Option<futures_channel::oneshot::Receiver<()>>>;
903
904 struct GatedSpeech {
907 gate: GateSlot,
908 }
909
910 #[async_trait]
911 impl Speech for GatedSpeech {
912 async fn transcribe(&self, _r: &TranscribeRequest) -> Result<Transcript, SpeechError> {
913 Err(SpeechError::NotConfigured)
914 }
915
916 async fn synthesize(&self, _r: &SynthesizeRequest) -> Result<Synthesis, SpeechError> {
917 let gate = self
918 .gate
919 .lock()
920 .expect("gate lock uncontended")
921 .take()
922 .expect("gate receiver available");
923 Ok(Synthesis::new(
924 AudioFormat::Mp3,
925 SpeechUsage::synthesised(8),
926 ResponseStream::new(Gated {
927 head: Some(Ok(b"head".to_vec())),
928 gate,
929 tail: false,
930 }),
931 ))
932 }
933
934 async fn voices(&self, _l: Option<&str>) -> Result<Vec<Voice>, SpeechError> {
935 Err(SpeechError::NotConfigured)
936 }
937
938 fn capabilities(&self) -> SpeechCapabilities {
939 SpeechCapabilities {
940 streaming_output: true,
941 word_timings: false,
942 languages: vec!["en".to_owned()],
943 formats: vec![AudioFormat::Mp3],
944 }
945 }
946 }
947
948 #[test]
949 fn synthesis_streams_before_the_provider_finishes() {
950 let (sender, receiver) = futures_channel::oneshot::channel();
951 let speech = GatedSpeech {
952 gate: GateSlot::new(Some(receiver)),
953 };
954 let request = SynthesizeRequest::new("say this", "narrator", AudioFormat::Mp3);
955 let synthesis = pollster::block_on(speech.synthesize(&request)).expect("synthesizes");
956 let mut stream = synthesis
957 .audio
958 .take()
959 .expect("an unpolled synthesis yields its source");
960
961 let waker = futures_util::task::noop_waker();
962 let mut cx = Context::from_waker(&waker);
963 match Pin::new(&mut stream).poll_next(&mut cx) {
966 Poll::Ready(Some(Ok(chunk))) => assert_eq!(&chunk[..], b"head"),
967 other => panic!("expected the first chunk before completion, got {other:?}"),
968 }
969 assert!(
970 matches!(Pin::new(&mut stream).poll_next(&mut cx), Poll::Pending),
971 "the second chunk must not arrive before the gate opens"
972 );
973 sender.send(()).expect("the gate opens");
974 match Pin::new(&mut stream).poll_next(&mut cx) {
975 Poll::Ready(Some(Ok(chunk))) => assert_eq!(&chunk[..], b"tail"),
976 other => panic!("expected the tail chunk after the gate opened, got {other:?}"),
977 }
978 assert!(matches!(
979 Pin::new(&mut stream).poll_next(&mut cx),
980 Poll::Ready(None)
981 ));
982 }
983}