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};
4use serde::Serialize;
5
6#[derive(Debug, Clone, PartialEq)]
7pub enum SpeechPart {
8    Text(String),
9    Pause(f64),
10}
11
12#[derive(Debug, Clone, Default)]
13pub struct ComposerSettings {
14    pub voice_id: Option<String>,
15    pub model: Option<TTSModel>,
16    pub language: Option<String>,
17    pub prompt: Option<TTSPrompt>,
18    pub output: Option<Output>,
19    pub seed: Option<i32>,
20}
21
22impl ComposerSettings {
23    pub fn new() -> Self {
24        Self::default()
25    }
26
27    pub fn voice_id(mut self, voice_id: impl Into<String>) -> Self {
28        self.voice_id = Some(voice_id.into());
29        self
30    }
31
32    pub fn model(mut self, model: TTSModel) -> Self {
33        self.model = Some(model);
34        self
35    }
36
37    pub fn language(mut self, language: impl Into<String>) -> Self {
38        self.language = Some(language.into());
39        self
40    }
41
42    pub fn prompt(mut self, prompt: impl Into<TTSPrompt>) -> Self {
43        self.prompt = Some(prompt.into());
44        self
45    }
46
47    pub fn output(mut self, output: Output) -> Self {
48        self.output = Some(output);
49        self
50    }
51
52    pub fn seed(mut self, seed: i32) -> Self {
53        self.seed = Some(seed);
54        self
55    }
56}
57
58#[derive(Debug, Clone)]
59enum ComposerPart {
60    Speech {
61        text: String,
62        settings: Box<ComposerSettings>,
63    },
64    Pause(f64),
65}
66
67#[derive(Serialize)]
68#[serde(tag = "type")]
69enum ComposeSegment {
70    #[serde(rename = "tts")]
71    Tts {
72        #[serde(flatten)]
73        request: Box<TTSRequest>,
74    },
75    #[serde(rename = "pause")]
76    Pause { duration_seconds: f64 },
77}
78
79#[derive(Serialize)]
80struct ComposeRequest {
81    segments: Vec<ComposeSegment>,
82}
83
84pub struct SpeechComposer<'a> {
85    client: &'a TypecastClient,
86    defaults: ComposerSettings,
87    parts: Vec<ComposerPart>,
88}
89
90impl<'a> SpeechComposer<'a> {
91    pub(crate) fn new(client: &'a TypecastClient) -> Self {
92        Self {
93            client,
94            defaults: ComposerSettings::new(),
95            parts: Vec::new(),
96        }
97    }
98
99    pub fn defaults(mut self, settings: ComposerSettings) -> Self {
100        self.defaults = merge_settings(&self.defaults, &settings);
101        self
102    }
103
104    pub fn say(mut self, text: impl Into<String>) -> Self {
105        self.parts.push(ComposerPart::Speech {
106            text: text.into(),
107            settings: Box::new(self.defaults.clone()),
108        });
109        self
110    }
111
112    pub fn say_with(mut self, text: impl Into<String>, settings: ComposerSettings) -> Self {
113        self.parts.push(ComposerPart::Speech {
114            text: text.into(),
115            settings: Box::new(merge_settings(&self.defaults, &settings)),
116        });
117        self
118    }
119
120    /// Inserts silence between speech segments.
121    ///
122    /// `seconds` is a duration in seconds. Use `0.3` for 300 ms, `3.0` for
123    /// three seconds.
124    pub fn pause(mut self, seconds: f64) -> Self {
125        self.parts.push(ComposerPart::Pause(seconds));
126        self
127    }
128
129    pub async fn generate(self) -> Result<TTSResponse> {
130        let plan = self.build_plan()?;
131        if !plan
132            .iter()
133            .any(|part| matches!(part, ComposerPart::Speech { .. }))
134        {
135            return Err(TypecastError::ValidationError {
136                detail: "at least one speech segment is required".to_string(),
137            });
138        }
139
140        let mut output_format = None;
141        for format in plan.iter().filter_map(|part| match part {
142            ComposerPart::Speech { settings, .. } => settings
143                .output
144                .as_ref()
145                .and_then(|output| output.audio_format),
146            ComposerPart::Pause(_) => None,
147        }) {
148            if output_format.is_some_and(|current| current != format) {
149                return Err(TypecastError::ValidationError {
150                    detail: "composed speech segments must use one audio format".to_string(),
151                });
152            }
153            output_format = Some(format);
154        }
155        let output_format = output_format.unwrap_or(AudioFormat::Wav);
156
157        let mut segments = Vec::with_capacity(plan.len());
158        for part in plan {
159            match part {
160                ComposerPart::Pause(duration_seconds) => {
161                    segments.push(ComposeSegment::Pause { duration_seconds })
162                }
163                ComposerPart::Speech { text, settings } => {
164                    segments.push(ComposeSegment::Tts {
165                        request: Box::new(request_from_settings(text, *settings, output_format)?),
166                    });
167                }
168            }
169        }
170        self.client
171            .compose_text_to_speech(&ComposeRequest { segments })
172            .await
173    }
174
175    fn build_plan(&self) -> Result<Vec<ComposerPart>> {
176        let mut plan = Vec::new();
177        for part in &self.parts {
178            match part {
179                ComposerPart::Pause(seconds) => {
180                    if !seconds.is_finite() || *seconds <= 0.0 {
181                        return Err(TypecastError::ValidationError {
182                            detail: "pause seconds must be greater than 0".to_string(),
183                        });
184                    }
185                    plan.push(ComposerPart::Pause(*seconds));
186                }
187                ComposerPart::Speech { text, settings } => {
188                    for parsed in parse_pause_markup(text) {
189                        match parsed {
190                            SpeechPart::Pause(seconds) => {
191                                plan.push(ComposerPart::Pause(seconds));
192                            }
193                            SpeechPart::Text(text) => {
194                                if !text.trim().is_empty() {
195                                    if text.chars().count() > 2000 {
196                                        return Err(TypecastError::ValidationError {
197                                            detail: "composed speech segment text must not exceed 2000 characters".to_string(),
198                                        });
199                                    }
200                                    if settings.voice_id.as_deref().unwrap_or("").is_empty() {
201                                        return Err(TypecastError::ValidationError {
202                                            detail:
203                                                "voice_id is required for composed speech segments"
204                                                    .to_string(),
205                                        });
206                                    }
207                                    if settings.model.is_none() {
208                                        return Err(TypecastError::ValidationError {
209                                            detail:
210                                                "model is required for composed speech segments"
211                                                    .to_string(),
212                                        });
213                                    }
214                                    plan.push(ComposerPart::Speech {
215                                        text,
216                                        settings: settings.clone(),
217                                    });
218                                }
219                            }
220                        }
221                    }
222                }
223            }
224        }
225        Ok(plan)
226    }
227}
228
229pub fn parse_pause_markup(text: &str) -> Vec<SpeechPart> {
230    let mut parts = Vec::new();
231    let mut last_emit = 0usize;
232    let mut search_from = 0usize;
233
234    while let Some(relative_start) = text[search_from..].find("<|") {
235        let token_start = search_from + relative_start;
236        let body_start = token_start + 2;
237        let Some(relative_end) = text[body_start..].find("|>") else {
238            break;
239        };
240        let body_end = body_start + relative_end;
241        let token_end = body_end + 2;
242        let token_body = &text[body_start..body_end];
243
244        if let Some(seconds_text) = token_body.strip_suffix('s') {
245            if valid_seconds_literal(seconds_text) {
246                let seconds = seconds_text
247                    .parse::<f64>()
248                    .expect("validated seconds literals parse as f64");
249                if !seconds.is_finite() || seconds <= 0.0 {
250                    search_from = body_start;
251                    continue;
252                }
253                if token_start > last_emit {
254                    parts.push(SpeechPart::Text(text[last_emit..token_start].to_string()));
255                }
256                parts.push(SpeechPart::Pause(seconds));
257                last_emit = token_end;
258                search_from = token_end;
259                continue;
260            }
261        }
262
263        search_from = body_start;
264    }
265
266    if last_emit < text.len() {
267        parts.push(SpeechPart::Text(text[last_emit..].to_string()));
268    }
269    parts
270}
271
272fn valid_seconds_literal(value: &str) -> bool {
273    let mut split = value.split('.');
274    let whole = split.next().unwrap_or("");
275    let fraction = split.next();
276    if split.next().is_some() || whole.is_empty() || !whole.chars().all(|c| c.is_ascii_digit()) {
277        return false;
278    }
279    match fraction {
280        Some(fraction) => !fraction.is_empty() && fraction.chars().all(|c| c.is_ascii_digit()),
281        None => true,
282    }
283}
284
285fn merge_settings(
286    base: &ComposerSettings,
287    override_settings: &ComposerSettings,
288) -> ComposerSettings {
289    ComposerSettings {
290        voice_id: override_settings
291            .voice_id
292            .clone()
293            .or_else(|| base.voice_id.clone()),
294        model: override_settings.model.or(base.model),
295        language: override_settings
296            .language
297            .clone()
298            .or_else(|| base.language.clone()),
299        prompt: override_settings
300            .prompt
301            .clone()
302            .or_else(|| base.prompt.clone()),
303        output: merge_output(base.output.clone(), override_settings.output.clone()),
304        seed: override_settings.seed.or(base.seed),
305    }
306}
307
308fn merge_output(base: Option<Output>, override_output: Option<Output>) -> Option<Output> {
309    match (base, override_output) {
310        (None, None) => None,
311        (Some(output), None) | (None, Some(output)) => Some(output),
312        (Some(base), Some(override_output)) => Some(Output {
313            volume: override_output.volume.or(base.volume),
314            target_lufs: override_output.target_lufs.or(base.target_lufs),
315            audio_pitch: override_output.audio_pitch.or(base.audio_pitch),
316            audio_tempo: override_output.audio_tempo.or(base.audio_tempo),
317            audio_format: override_output.audio_format.or(base.audio_format),
318        }),
319    }
320}
321
322fn request_from_settings(
323    text: String,
324    settings: ComposerSettings,
325    format: AudioFormat,
326) -> Result<TTSRequest> {
327    Ok(TTSRequest {
328        voice_id: settings
329            .voice_id
330            .expect("build_plan validates composed speech voice_id"),
331        text,
332        model: settings
333            .model
334            .expect("build_plan validates composed speech model"),
335        language: settings.language,
336        prompt: settings.prompt,
337        output: merge_output(settings.output, Some(Output::new().audio_format(format))),
338        seed: settings.seed,
339    })
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345    use crate::client::ClientConfig;
346    use crate::models::{EmotionPreset, PresetPrompt};
347    use mockito::Server;
348    use std::time::Duration;
349
350    #[tokio::test]
351    async fn compose_speech_smoke_for_lib_binary_coverage() {
352        let mut server = Server::new_async().await;
353        let mock = server
354            .mock("POST", "/v1/text-to-speech/compose")
355            .match_body(mockito::Matcher::Regex(
356                r#"\"type\":\"pause\",\"duration_seconds\":0.001"#.to_string(),
357            ))
358            .with_status(200)
359            .with_header("content-type", "audio/wav")
360            .with_header("x-audio-duration", "1.25")
361            .with_body("composed-audio")
362            .create_async()
363            .await;
364
365        let config = ClientConfig::new("test-api-key")
366            .base_url(server.url())
367            .timeout(Duration::from_secs(5));
368        let client = TypecastClient::new(config).expect("client builds");
369        let response = client
370            .compose_speech()
371            .defaults(
372                ComposerSettings::new()
373                    .voice_id("voice-a")
374                    .model(TTSModel::SsfmV30)
375                    .language("eng")
376                    .prompt(PresetPrompt::new().emotion_preset(EmotionPreset::Normal))
377                    .output(Output::new().audio_format(AudioFormat::Wav))
378                    .seed(1),
379            )
380            .say("Hello<|0.001s|>there")
381            .say_with(
382                "World<|0.001s|>again",
383                ComposerSettings::new()
384                    .voice_id("voice-b")
385                    .model(TTSModel::SsfmV30),
386            )
387            .generate()
388            .await
389            .unwrap();
390
391        assert_eq!(response.format, AudioFormat::Wav);
392        assert_eq!(response.audio_data, b"composed-audio");
393        assert_eq!(response.duration, 1.25);
394        mock.assert_async().await;
395    }
396}