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