Skip to main content

cratefield_core/ports/
speech.rs

1//! The `Speech` port (issue #861): vendor-neutral speech-to-text and
2//! text-to-speech, asked for by capability, never by vendor — the way
3//! [`TextModel`](crate::TextModel) is for completions. A module holds
4//! `Arc<dyn Speech>` and never learns which provider spoke.
5//!
6//! **Limits are checked locally, before any network call.** Every
7//! implementation MUST run [`check_transcribe`] / [`check_synthesize`] on
8//! the request first, so an over-limit ask is refused with
9//! [`SpeechError::LimitExceeded`] without being paid for; the defaults
10//! ([`SpeechLimits::default()`]) are `OpenAI`'s documented speech caps.
11//! There is no outcome enum: the unwired answer is
12//! [`SpeechError::NotConfigured`] — an error the caller matches, never a
13//! silently empty transcript. [`NotConfiguredSpeech`] is the placeholder.
14
15use std::time::Duration;
16
17use async_trait::async_trait;
18use bytes::Bytes;
19use thiserror::Error;
20
21use crate::stream::ResponseStream;
22
23/// The most audio one transcribe request may carry: 25 MiB.
24pub const DEFAULT_MAX_AUDIO_BYTES: u64 = 25 * 1024 * 1024;
25
26/// The longest clip one transcribe request may claim: ten minutes.
27pub const DEFAULT_MAX_AUDIO_SECONDS: u32 = 600;
28
29/// The most text one synthesize request may carry: 4 096 characters.
30pub const DEFAULT_MAX_SYNTHESIS_CHARS: u32 = 4096;
31
32/// An audio container/codec a [`Speech`] adapter speaks;
33/// `#[non_exhaustive]` so the next provider-native format does not break
34/// every `match`.
35#[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    /// The canonical MIME type on the wire.
48    #[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    /// The short name used in logs and errors.
61    #[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    /// Resolves a MIME type to a format, aliases included (`audio/x-wav`,
74    /// `audio/ogg`, …); unknown types answer `None`.
75    #[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/// The primary subtag of a BCP-47 tag: `en` from `en-US`.
97#[must_use]
98pub fn primary_subtag(tag: &str) -> &str {
99    tag.split('-').next().unwrap_or(tag)
100}
101
102/// What a [`Speech`] adapter can do, asked before any call. An empty
103/// `languages` serves anything; `formats` is the opposite of a wildcard —
104/// an adapter that advertises none may be asked for none.
105#[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    /// Whether `language` is served: always on an empty list, else a
115    /// case-insensitive primary-subtag match (`en` serves `en-US`).
116    #[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    /// Whether `format` is among the advertised TTS output formats.
127    #[must_use]
128    pub fn supports_format(&self, format: AudioFormat) -> bool {
129        self.formats.contains(&format)
130    }
131}
132
133/// The local ceilings a [`Speech`] adapter enforces before any network
134/// call, read off `Speech::limits`.
135#[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/// One text-to-speech request, built with [`SynthesizeRequest::new`].
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub struct SynthesizeRequest {
155    pub text: String,
156    pub voice: String,
157    /// A BCP-47 tag steering pronunciation, when the caller knows it.
158    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/// One speech-to-text request, built with [`TranscribeRequest::new`].
181#[derive(Debug, Clone, PartialEq)]
182pub struct TranscribeRequest {
183    pub audio: Bytes,
184    pub mime: String,
185    /// A BCP-47 tag hinting the spoken language, when the caller knows it.
186    pub language_hint: Option<String>,
187    /// The clip's length, caller-declared. When absent and `mime` is WAV,
188    /// [`check_transcribe`] derives it from the RIFF header; when still
189    /// unknown, only [`SpeechLimits::max_audio_bytes`] applies.
190    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/// One spoken word in a transcript, with where it starts and ends.
218#[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/// What one speech call cost, in the provider's own billing units:
239/// milliseconds transcribed, characters synthesised.
240#[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/// One completed transcription.
265#[derive(Debug, Clone, Default, PartialEq)]
266pub struct Transcript {
267    pub text: String,
268    /// The language the provider detected (or echoed from the hint).
269    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/// One completed synthesis: the audio as a stream that can start reaching
303/// the caller before the provider has finished. `Debug` shows the shape,
304/// never the bytes.
305#[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/// One voice a provider can synthesize with.
333#[derive(Debug, Clone, PartialEq, Eq)]
334pub struct Voice {
335    pub id: String,
336    pub name: String,
337    /// The BCP-47 tags this voice speaks.
338    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/// Which [`SpeechLimits`] ceiling a request crossed; `AudioSeconds`
353/// compares milliseconds, so a sub-second clip is not rounded into
354/// compliance.
355#[derive(Debug, Clone, Copy, PartialEq, Eq)]
356pub enum SpeechLimit {
357    AudioBytes,
358    AudioSeconds,
359    SynthesisChars,
360}
361
362impl SpeechLimit {
363    /// The name used in errors and logs.
364    #[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/// Speech failures. [`LimitExceeded`](Self::LimitExceeded) is the typed
375/// **local** refusal, carrying only numbers, safe in a log line as-is; the
376/// variants carrying provider or caller text are scrubbed in `Display` the
377/// way [`TextModelError`](crate::TextModelError)'s are (issue #235), while
378/// `Debug` still shows the raw string for tests.
379#[derive(Debug, Clone, PartialEq, Eq, Error)]
380#[non_exhaustive]
381pub enum SpeechError {
382    /// No speech provider is wired; nothing is wrong with the request.
383    #[error("no speech provider is wired")]
384    NotConfigured,
385    /// A local ceiling was crossed and the provider was never called.
386    #[error("over the {} ceiling: {actual} > {max}", limit.name())]
387    LimitExceeded {
388        limit: SpeechLimit,
389        actual: u64,
390        max: u64,
391    },
392    /// Not among the adapter's advertised [`SpeechCapabilities::formats`].
393    #[error("unsupported audio format: {}", scrub(.0))]
394    UnsupportedFormat(String),
395    /// Not among the adapter's advertised [`SpeechCapabilities::languages`].
396    #[error("unsupported language: {}", scrub(.0))]
397    UnsupportedLanguage(String),
398    /// The request was malformed before any provider saw it.
399    #[error("speech request rejected: {}", scrub(.0))]
400    InvalidInput(String),
401    /// Retryable (a `5xx`, a `429`, a transport error); wait out
402    /// `retry_after` when the provider named one.
403    #[error("speech call failed, retryable")]
404    Transient { retry_after: Option<Duration> },
405    /// The provider refused the request or the call failed.
406    #[error("speech provider failed: {}", scrub(.0))]
407    Provider(String),
408}
409
410/// The `Display` scrub for the variants carrying provider or caller text.
411fn scrub(value: &str) -> String {
412    crate::logging::scrub_text(value)
413}
414
415impl SpeechError {
416    /// The provider-named back-off, where there was one.
417    #[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/// The length of a RIFF/WAVE clip from its header: the `data` chunk's
427/// declared size over the `fmt ` byte rate, rounded up to the millisecond.
428/// A short, truncated or non-WAV payload answers `None`, never panics.
429#[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        // RIFF pads every odd-sized chunk to even: honour the pad byte,
453        // or the next chunk header would be read one byte early.
454        pos = pos.checked_add(size.checked_add(8)?.checked_add(size & 1)?)?;
455    }
456    // Ceiling to the millisecond: a clip never under-reports its length.
457    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
467/// Refuses a transcribe request crossing a local ceiling, **before** any
468/// network call: empty audio is [`SpeechError::InvalidInput`], and so is a
469/// mime that is empty or carries ASCII control characters (adapters paste
470/// the mime into headers and multipart part lines, so a CR/LF must never
471/// reach one); the payload over [`SpeechLimits::max_audio_bytes`] or the
472/// clip over [`SpeechLimits::max_audio_seconds`] (declared, or derived
473/// from the WAV header) is [`SpeechError::LimitExceeded`].
474///
475/// # Errors
476/// The refusals named above.
477pub 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
520/// Refuses a synthesize request crossing a local ceiling or the adapter's
521/// own advertised capabilities, **before** any network call: empty text is
522/// [`SpeechError::InvalidInput`], text over
523/// [`SpeechLimits::max_synthesis_chars`] is [`SpeechError::LimitExceeded`],
524/// and a format or language outside [`SpeechCapabilities::formats`] /
525/// [`SpeechCapabilities::languages`] is `UnsupportedFormat` /
526/// `UnsupportedLanguage`.
527///
528/// # Errors
529/// The refusals named above; the first refusal wins.
530pub 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/// Speaks for a venture: speech-to-text and text-to-speech over whichever
560/// provider it wired. Every adapter MUST run the `check_*` functions at
561/// the top of `transcribe` / `synthesize`, before any network call, so a
562/// refusal costs nothing and every adapter refuses identically.
563#[async_trait]
564pub trait Speech: Send + Sync {
565    /// Transcribes `request`'s audio.
566    ///
567    /// # Errors
568    /// [`SpeechError::NotConfigured`] unwired; the refusal and failure
569    /// variants otherwise.
570    async fn transcribe(&self, request: &TranscribeRequest) -> Result<Transcript, SpeechError>;
571
572    /// Synthesizes `request`'s text to audio; a streaming provider's
573    /// [`Synthesis::audio`] starts yielding before the clip is complete.
574    ///
575    /// # Errors
576    /// As `transcribe`, with `UnsupportedFormat` for `UnsupportedLanguage`.
577    async fn synthesize(&self, request: &SynthesizeRequest) -> Result<Synthesis, SpeechError>;
578
579    /// The voices the provider offers, filtered to `language` (a BCP-47
580    /// tag) when given.
581    ///
582    /// # Errors
583    /// As `transcribe`.
584    async fn voices(&self, language: Option<&str>) -> Result<Vec<Voice>, SpeechError>;
585
586    /// What this adapter can do, so a caller shapes its ask before
587    /// calling.
588    fn capabilities(&self) -> SpeechCapabilities;
589
590    /// The local ceilings this adapter enforces;
591    /// [`SpeechLimits::default()`] when it has none of its own.
592    fn limits(&self) -> SpeechLimits {
593        SpeechLimits::default()
594    }
595}
596
597/// The standing placeholder when a venture wires no speech provider:
598/// every call answers [`SpeechError::NotConfigured`], the way
599/// [`NoopDefer`](crate::NoopDefer) drops deferred work.
600#[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    /// A minimal 8-bit mono 8 kHz RIFF/WAVE clip with `byte_rate` as its
630    /// fmt byte rate and `data` as its payload.
631    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()); // PCM
639        out.extend_from_slice(&1u16.to_le_bytes()); // mono
640        out.extend_from_slice(&8_000u32.to_le_bytes()); // sample rate
641        out.extend_from_slice(&byte_rate.to_le_bytes());
642        out.extend_from_slice(&1u16.to_le_bytes()); // block align
643        out.extend_from_slice(&8u16.to_le_bytes()); // bits per sample
644        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    /// The same clip with one odd-sized `LIST` chunk — 3 bytes of payload
651    /// plus its RIFF pad byte — ahead of `fmt `, as real writers emit.
652    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]); // the pad byte
662        out.extend_from_slice(b"fmt ");
663        out.extend_from_slice(&16u32.to_le_bytes());
664        out.extend_from_slice(&1u16.to_le_bytes()); // PCM
665        out.extend_from_slice(&1u16.to_le_bytes()); // mono
666        out.extend_from_slice(&8_000u32.to_le_bytes()); // sample rate
667        out.extend_from_slice(&byte_rate.to_le_bytes());
668        out.extend_from_slice(&1u16.to_le_bytes()); // block align
669        out.extend_from_slice(&8u16.to_le_bytes()); // bits per sample
670        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        // The aliases providers and browsers actually send; parameters and
707        // case are ignored, junk does not parse.
708        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        // 12 000 bytes at 8 000 bytes/second: 1.5 seconds.
725        assert_eq!(
726            wav_duration(&wav(8_000, &vec![0u8; 12_000])),
727            Some(Duration::from_millis(1_500))
728        );
729        // Not WAV, truncated, and an absurd chunk size: all None.
730        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        // An odd-sized chunk with its RIFF pad byte is walked past, and
737        // `data` after it still reads: 12 000 bytes at 8 000 B/s, 1.5 s.
738        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        // A mime that is empty, or carries ASCII control characters (a
766        // CR/LF riding into a header or a multipart part line), is
767        // malformed before any adapter formats it.
768        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        // A declared duration over the seconds ceiling.
784        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        // The same refusal derived from the WAV header when nothing is
796        // declared: 48 data bytes at 4 bytes/second is 12 s, under the
797        // byte cap.
798        let derived = TranscribeRequest::new(wav(100, &[0u8; 4]), "audio/wav"); // 40 ms
799        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        // Unknown length: only the byte ceiling applies.
810        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        // Primary-subtag matching: `en` is served by an `en-US` capability.
850        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    /// A synthesis whose stream yields `head`, then waits on a oneshot
870    /// gate before yielding `tail` and ending — a provider that has not
871    /// finished while the caller already has its first bytes.
872    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    // Test-only one-shot plumbing, not request state; `std::sync::Mutex` is
900    // disallowed workspace-wide (ADR 0007), so the alias carries the allow.
901    #[allow(clippy::disallowed_types)]
902    type GateSlot = std::sync::Mutex<Option<futures_channel::oneshot::Receiver<()>>>;
903
904    /// The mock [`Speech`] whose `synthesize` hands back the gated stream,
905    /// so the proof below runs through the port itself.
906    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        // The first chunk arrives while the gate is still shut: the caller
964        // has audio before the synthesis has completed.
965        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}