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 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}