Skip to main content

typecast_rust/
composer.rs

1use crate::client::TypecastClient;
2use crate::errors::{Result, TypecastError};
3use crate::models::{AudioFormat, Output, TTSModel, TTSPrompt, TTSRequest, TTSResponse};
4
5#[derive(Debug, Clone, PartialEq)]
6pub enum SpeechPart {
7    Text(String),
8    Pause(f64),
9}
10
11#[derive(Debug, Clone, Default)]
12pub struct ComposerSettings {
13    pub voice_id: Option<String>,
14    pub model: Option<TTSModel>,
15    pub language: Option<String>,
16    pub prompt: Option<TTSPrompt>,
17    pub output: Option<Output>,
18    pub seed: Option<i32>,
19}
20
21impl ComposerSettings {
22    pub fn new() -> Self {
23        Self::default()
24    }
25
26    pub fn voice_id(mut self, voice_id: impl Into<String>) -> Self {
27        self.voice_id = Some(voice_id.into());
28        self
29    }
30
31    pub fn model(mut self, model: TTSModel) -> Self {
32        self.model = Some(model);
33        self
34    }
35
36    pub fn language(mut self, language: impl Into<String>) -> Self {
37        self.language = Some(language.into());
38        self
39    }
40
41    pub fn prompt(mut self, prompt: impl Into<TTSPrompt>) -> Self {
42        self.prompt = Some(prompt.into());
43        self
44    }
45
46    pub fn output(mut self, output: Output) -> Self {
47        self.output = Some(output);
48        self
49    }
50
51    pub fn seed(mut self, seed: i32) -> Self {
52        self.seed = Some(seed);
53        self
54    }
55}
56
57#[derive(Debug, Clone)]
58enum ComposerPart {
59    Speech {
60        text: String,
61        settings: ComposerSettings,
62    },
63    Pause(f64),
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67struct WavSpec {
68    sample_rate: u32,
69    channels: u16,
70    bits_per_sample: u16,
71}
72
73#[derive(Debug, Clone)]
74struct ParsedWav {
75    spec: WavSpec,
76    samples: Vec<i16>,
77}
78
79pub struct SpeechComposer<'a> {
80    client: &'a TypecastClient,
81    defaults: ComposerSettings,
82    parts: Vec<ComposerPart>,
83}
84
85impl<'a> SpeechComposer<'a> {
86    pub(crate) fn new(client: &'a TypecastClient) -> Self {
87        Self {
88            client,
89            defaults: ComposerSettings::new(),
90            parts: Vec::new(),
91        }
92    }
93
94    pub fn defaults(mut self, settings: ComposerSettings) -> Self {
95        self.defaults = merge_settings(&self.defaults, &settings);
96        self
97    }
98
99    pub fn say(mut self, text: impl Into<String>) -> Self {
100        self.parts.push(ComposerPart::Speech {
101            text: text.into(),
102            settings: self.defaults.clone(),
103        });
104        self
105    }
106
107    pub fn say_with(mut self, text: impl Into<String>, settings: ComposerSettings) -> Self {
108        self.parts.push(ComposerPart::Speech {
109            text: text.into(),
110            settings: merge_settings(&self.defaults, &settings),
111        });
112        self
113    }
114
115    /// Inserts silence between speech segments.
116    ///
117    /// `seconds` is a duration in seconds. Use `0.3` for 300 ms, `3.0` for
118    /// three seconds.
119    pub fn pause(mut self, seconds: f64) -> Self {
120        self.parts.push(ComposerPart::Pause(seconds));
121        self
122    }
123
124    pub async fn generate(self) -> Result<TTSResponse> {
125        let plan = self.build_plan()?;
126        if !plan
127            .iter()
128            .any(|part| matches!(part, ComposerPart::Speech { .. }))
129        {
130            return Err(TypecastError::ValidationError {
131                detail: "at least one speech segment is required".to_string(),
132            });
133        }
134
135        let output_format = self
136            .defaults
137            .output
138            .as_ref()
139            .and_then(|output| output.audio_format)
140            .unwrap_or(AudioFormat::Wav);
141
142        let mut wav_spec: Option<WavSpec> = None;
143        let mut output_samples = Vec::new();
144        for part in plan {
145            match part {
146                ComposerPart::Pause(seconds) => {
147                    let Some(spec) = wav_spec else {
148                        return Err(TypecastError::ValidationError {
149                            detail: "pause cannot be the first composed part".to_string(),
150                        });
151                    };
152                    output_samples.extend(vec![0; seconds_to_samples(seconds, spec.sample_rate)]);
153                }
154                ComposerPart::Speech { text, settings } => {
155                    let response = self
156                        .client
157                        .text_to_speech(&request_from_settings(text, settings)?)
158                        .await?;
159                    let wav = parse_wav(&response.audio_data)?;
160                    if let Some(spec) = wav_spec {
161                        if spec != wav.spec {
162                            return Err(TypecastError::ValidationError {
163                                detail: "all composed WAV segments must use the same PCM format"
164                                    .to_string(),
165                            });
166                        }
167                    }
168                    wav_spec = Some(wav.spec);
169                    output_samples.extend(trim_silence(&wav.samples));
170                }
171            }
172        }
173
174        let spec = wav_spec.expect("speech segments always set a WAV spec");
175        let audio_data = encode_wav(&output_samples, spec);
176        if output_format == AudioFormat::Mp3 {
177            return Err(TypecastError::ValidationError {
178                detail: "ffmpeg is required to encode composed speech as mp3".to_string(),
179            });
180        }
181
182        Ok(TTSResponse {
183            audio_data,
184            duration: output_samples.len() as f64 / spec.sample_rate as f64,
185            format: AudioFormat::Wav,
186        })
187    }
188
189    fn build_plan(&self) -> Result<Vec<ComposerPart>> {
190        let mut plan = Vec::new();
191        for part in &self.parts {
192            match part {
193                ComposerPart::Pause(seconds) => {
194                    if !seconds.is_finite() || *seconds <= 0.0 {
195                        return Err(TypecastError::ValidationError {
196                            detail: "pause seconds must be greater than 0".to_string(),
197                        });
198                    }
199                    plan.push(ComposerPart::Pause(*seconds));
200                }
201                ComposerPart::Speech { text, settings } => {
202                    for parsed in parse_pause_markup(text) {
203                        match parsed {
204                            SpeechPart::Pause(seconds) => plan.push(ComposerPart::Pause(seconds)),
205                            SpeechPart::Text(text) => {
206                                if !text.trim().is_empty() {
207                                    if settings.voice_id.as_deref().unwrap_or("").is_empty() {
208                                        return Err(TypecastError::ValidationError {
209                                            detail:
210                                                "voice_id is required for composed speech segments"
211                                                    .to_string(),
212                                        });
213                                    }
214                                    if settings.model.is_none() {
215                                        return Err(TypecastError::ValidationError {
216                                            detail:
217                                                "model is required for composed speech segments"
218                                                    .to_string(),
219                                        });
220                                    }
221                                    plan.push(ComposerPart::Speech {
222                                        text,
223                                        settings: settings.clone(),
224                                    });
225                                }
226                            }
227                        }
228                    }
229                }
230            }
231        }
232        Ok(plan)
233    }
234}
235
236pub fn parse_pause_markup(text: &str) -> Vec<SpeechPart> {
237    let mut parts = Vec::new();
238    let mut last_emit = 0usize;
239    let mut search_from = 0usize;
240
241    while let Some(relative_start) = text[search_from..].find("<|") {
242        let token_start = search_from + relative_start;
243        let body_start = token_start + 2;
244        let Some(relative_end) = text[body_start..].find("|>") else {
245            break;
246        };
247        let body_end = body_start + relative_end;
248        let token_end = body_end + 2;
249        let token_body = &text[body_start..body_end];
250
251        if let Some(seconds_text) = token_body.strip_suffix('s') {
252            if valid_seconds_literal(seconds_text) {
253                let seconds = seconds_text
254                    .parse::<f64>()
255                    .expect("validated seconds literals parse as f64");
256                if token_start > last_emit {
257                    parts.push(SpeechPart::Text(text[last_emit..token_start].to_string()));
258                }
259                parts.push(SpeechPart::Pause(seconds));
260                last_emit = token_end;
261                search_from = token_end;
262                continue;
263            }
264        }
265
266        search_from = body_start;
267    }
268
269    if last_emit < text.len() {
270        parts.push(SpeechPart::Text(text[last_emit..].to_string()));
271    }
272    parts
273}
274
275fn valid_seconds_literal(value: &str) -> bool {
276    let mut split = value.split('.');
277    let whole = split.next().unwrap_or("");
278    let fraction = split.next();
279    if split.next().is_some() || whole.is_empty() || !whole.chars().all(|c| c.is_ascii_digit()) {
280        return false;
281    }
282    match fraction {
283        Some(fraction) => !fraction.is_empty() && fraction.chars().all(|c| c.is_ascii_digit()),
284        None => true,
285    }
286}
287
288fn merge_settings(
289    base: &ComposerSettings,
290    override_settings: &ComposerSettings,
291) -> ComposerSettings {
292    ComposerSettings {
293        voice_id: override_settings
294            .voice_id
295            .clone()
296            .or_else(|| base.voice_id.clone()),
297        model: override_settings.model.or(base.model),
298        language: override_settings
299            .language
300            .clone()
301            .or_else(|| base.language.clone()),
302        prompt: override_settings
303            .prompt
304            .clone()
305            .or_else(|| base.prompt.clone()),
306        output: merge_output(base.output.clone(), override_settings.output.clone()),
307        seed: override_settings.seed.or(base.seed),
308    }
309}
310
311fn merge_output(base: Option<Output>, override_output: Option<Output>) -> Option<Output> {
312    match (base, override_output) {
313        (None, None) => None,
314        (Some(output), None) | (None, Some(output)) => Some(output),
315        (Some(base), Some(override_output)) => Some(Output {
316            volume: override_output.volume.or(base.volume),
317            target_lufs: override_output.target_lufs.or(base.target_lufs),
318            audio_pitch: override_output.audio_pitch.or(base.audio_pitch),
319            audio_tempo: override_output.audio_tempo.or(base.audio_tempo),
320            audio_format: override_output.audio_format.or(base.audio_format),
321        }),
322    }
323}
324
325fn request_from_settings(text: String, settings: ComposerSettings) -> Result<TTSRequest> {
326    Ok(TTSRequest {
327        voice_id: settings
328            .voice_id
329            .expect("build_plan validates composed speech voice_id"),
330        text,
331        model: settings
332            .model
333            .expect("build_plan validates composed speech model"),
334        language: settings.language,
335        prompt: settings.prompt,
336        output: merge_output(
337            settings.output,
338            Some(Output::new().audio_format(AudioFormat::Wav)),
339        ),
340        seed: settings.seed,
341    })
342}
343
344fn parse_wav(data: &[u8]) -> Result<ParsedWav> {
345    if data.len() < 12 || &data[0..4] != b"RIFF" || &data[8..12] != b"WAVE" {
346        return Err(TypecastError::ValidationError {
347            detail: "unsupported WAV data".to_string(),
348        });
349    }
350
351    let mut offset = 12usize;
352    let mut spec = None;
353    let mut samples = None;
354    while offset + 8 <= data.len() {
355        let chunk_id = &data[offset..offset + 4];
356        let chunk_size = u32::from_le_bytes([
357            data[offset + 4],
358            data[offset + 5],
359            data[offset + 6],
360            data[offset + 7],
361        ]) as usize;
362        let chunk_data_offset = offset + 8;
363        let chunk_end = chunk_data_offset + chunk_size;
364        if chunk_end > data.len() {
365            return Err(TypecastError::ValidationError {
366                detail: "unsupported WAV data".to_string(),
367            });
368        }
369
370        match chunk_id {
371            b"fmt " => {
372                if chunk_size < 16 {
373                    return Err(TypecastError::ValidationError {
374                        detail: "unsupported WAV data".to_string(),
375                    });
376                }
377                let audio_format =
378                    u16::from_le_bytes([data[chunk_data_offset], data[chunk_data_offset + 1]]);
379                let channels =
380                    u16::from_le_bytes([data[chunk_data_offset + 2], data[chunk_data_offset + 3]]);
381                let sample_rate = u32::from_le_bytes([
382                    data[chunk_data_offset + 4],
383                    data[chunk_data_offset + 5],
384                    data[chunk_data_offset + 6],
385                    data[chunk_data_offset + 7],
386                ]);
387                let bits_per_sample = u16::from_le_bytes([
388                    data[chunk_data_offset + 14],
389                    data[chunk_data_offset + 15],
390                ]);
391                if audio_format != 1 || channels != 1 || bits_per_sample != 16 {
392                    return Err(TypecastError::ValidationError {
393                        detail: "only mono 16-bit PCM WAV is supported for composed speech"
394                            .to_string(),
395                    });
396                }
397                spec = Some(WavSpec {
398                    sample_rate,
399                    channels,
400                    bits_per_sample,
401                });
402            }
403            b"data" => {
404                let mut parsed_samples = Vec::with_capacity(chunk_size / 2);
405                for sample in data[chunk_data_offset..chunk_end].chunks_exact(2) {
406                    parsed_samples.push(i16::from_le_bytes([sample[0], sample[1]]));
407                }
408                samples = Some(parsed_samples);
409            }
410            _ => {}
411        }
412        offset = chunk_end + (chunk_size % 2);
413    }
414
415    let Some(spec) = spec else {
416        return Err(TypecastError::ValidationError {
417            detail: "unsupported WAV data".to_string(),
418        });
419    };
420    let Some(samples) = samples else {
421        return Err(TypecastError::ValidationError {
422            detail: "unsupported WAV data".to_string(),
423        });
424    };
425    Ok(ParsedWav { spec, samples })
426}
427
428fn encode_wav(samples: &[i16], spec: WavSpec) -> Vec<u8> {
429    let data_size = (samples.len() * 2) as u32;
430    let mut wav = Vec::with_capacity(44 + samples.len() * 2);
431    wav.extend_from_slice(b"RIFF");
432    wav.extend_from_slice(&(36 + data_size).to_le_bytes());
433    wav.extend_from_slice(b"WAVE");
434    wav.extend_from_slice(b"fmt ");
435    wav.extend_from_slice(&16u32.to_le_bytes());
436    wav.extend_from_slice(&1u16.to_le_bytes());
437    wav.extend_from_slice(&spec.channels.to_le_bytes());
438    wav.extend_from_slice(&spec.sample_rate.to_le_bytes());
439    wav.extend_from_slice(&(spec.sample_rate * spec.channels as u32 * 2).to_le_bytes());
440    wav.extend_from_slice(&(spec.channels * 2).to_le_bytes());
441    wav.extend_from_slice(&spec.bits_per_sample.to_le_bytes());
442    wav.extend_from_slice(b"data");
443    wav.extend_from_slice(&data_size.to_le_bytes());
444    for sample in samples {
445        wav.extend_from_slice(&sample.to_le_bytes());
446    }
447    wav
448}
449
450fn trim_silence(samples: &[i16]) -> Vec<i16> {
451    let mut start = 0usize;
452    let mut end = samples.len();
453    while start < end && samples[start].abs() <= 0 {
454        start += 1;
455    }
456    while end > start && samples[end - 1].abs() <= 0 {
457        end -= 1;
458    }
459    samples[start..end].to_vec()
460}
461
462fn seconds_to_samples(seconds: f64, sample_rate: u32) -> usize {
463    (seconds * sample_rate as f64).round() as usize
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469    use crate::client::ClientConfig;
470    use crate::models::{EmotionPreset, PresetPrompt};
471    use mockito::Server;
472    use std::time::Duration;
473
474    fn small_wav() -> Vec<u8> {
475        let mut buf = Vec::new();
476        buf.extend_from_slice(b"RIFF");
477        buf.extend_from_slice(&36u32.to_le_bytes());
478        buf.extend_from_slice(b"WAVE");
479        buf.extend_from_slice(b"fmt ");
480        buf.extend_from_slice(&16u32.to_le_bytes());
481        buf.extend_from_slice(&1u16.to_le_bytes());
482        buf.extend_from_slice(&1u16.to_le_bytes());
483        buf.extend_from_slice(&44100u32.to_le_bytes());
484        buf.extend_from_slice(&88200u32.to_le_bytes());
485        buf.extend_from_slice(&2u16.to_le_bytes());
486        buf.extend_from_slice(&16u16.to_le_bytes());
487        buf.extend_from_slice(b"data");
488        buf.extend_from_slice(&0u32.to_le_bytes());
489        buf
490    }
491
492    #[tokio::test]
493    async fn compose_speech_smoke_for_lib_binary_coverage() {
494        let mut server = Server::new_async().await;
495        let _m1 = server
496            .mock("POST", "/v1/text-to-speech")
497            .with_status(200)
498            .with_header("content-type", "audio/wav")
499            .with_body(small_wav())
500            .create_async()
501            .await;
502        let _m2 = server
503            .mock("POST", "/v1/text-to-speech")
504            .with_status(200)
505            .with_header("content-type", "audio/wav")
506            .with_body(small_wav())
507            .create_async()
508            .await;
509        let _m3 = server
510            .mock("POST", "/v1/text-to-speech")
511            .with_status(200)
512            .with_header("content-type", "audio/wav")
513            .with_body(small_wav())
514            .create_async()
515            .await;
516        let _m4 = server
517            .mock("POST", "/v1/text-to-speech")
518            .with_status(200)
519            .with_header("content-type", "audio/wav")
520            .with_body(small_wav())
521            .create_async()
522            .await;
523
524        let config = ClientConfig::new("test-api-key")
525            .base_url(server.url())
526            .timeout(Duration::from_secs(5));
527        let client = TypecastClient::new(config).expect("client builds");
528        let response = client
529            .compose_speech()
530            .defaults(
531                ComposerSettings::new()
532                    .voice_id("voice-a")
533                    .model(TTSModel::SsfmV30)
534                    .language("eng")
535                    .prompt(PresetPrompt::new().emotion_preset(EmotionPreset::Normal))
536                    .output(Output::new().audio_format(AudioFormat::Wav))
537                    .seed(1),
538            )
539            .say("Hello<|0.001s|>there")
540            .say_with(
541                "World<|0.001s|>again",
542                ComposerSettings::new()
543                    .voice_id("voice-b")
544                    .model(TTSModel::SsfmV30),
545            )
546            .generate()
547            .await
548            .unwrap();
549
550        assert_eq!(response.format, AudioFormat::Wav);
551    }
552}