1use crate::errors::{Result, TypecastError};
9use base64::{engine::general_purpose::STANDARD as B64, Engine};
10use serde::{Deserialize, Serialize};
11use std::fs;
12use std::path::Path;
13
14#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
20pub struct AlignmentSegmentWord {
21 pub text: String,
23 pub start: f64,
25 pub end: f64,
27}
28
29#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
31pub struct AlignmentSegmentCharacter {
32 pub text: String,
34 pub start: f64,
36 pub end: f64,
38}
39
40#[derive(Debug, Clone, Serialize)]
42pub struct TTSRequestWithTimestamps {
43 pub voice_id: String,
46 pub text: String,
48 pub model: crate::models::TTSModel,
50 #[serde(skip_serializing_if = "Option::is_none")]
52 pub language: Option<String>,
53 #[serde(skip_serializing_if = "Option::is_none")]
56 pub prompt: Option<serde_json::Value>,
57 #[serde(skip_serializing_if = "Option::is_none")]
60 pub output: Option<serde_json::Value>,
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub seed: Option<u32>,
64}
65
66impl TTSRequestWithTimestamps {
67 pub fn new(
69 voice_id: impl Into<String>,
70 text: impl Into<String>,
71 model: crate::models::TTSModel,
72 ) -> Self {
73 Self {
74 voice_id: voice_id.into(),
75 text: text.into(),
76 model,
77 language: None,
78 prompt: None,
79 output: None,
80 seed: None,
81 }
82 }
83
84 pub fn language(mut self, language: impl Into<String>) -> Self {
86 self.language = Some(language.into());
87 self
88 }
89
90 pub fn prompt(mut self, prompt: serde_json::Value) -> Self {
92 self.prompt = Some(prompt);
93 self
94 }
95
96 pub fn output(mut self, output: serde_json::Value) -> Self {
98 self.output = Some(output);
99 self
100 }
101
102 pub fn seed(mut self, seed: u32) -> Self {
104 self.seed = Some(seed);
105 self
106 }
107}
108
109#[derive(Debug, Clone, Deserialize, Serialize)]
119pub struct TTSWithTimestampsResponse {
120 pub audio: String,
122 pub audio_format: String,
124 pub audio_duration: f64,
126 pub words: Option<Vec<AlignmentSegmentWord>>,
129 pub characters: Option<Vec<AlignmentSegmentCharacter>>,
132}
133
134impl TTSWithTimestampsResponse {
135 pub fn audio_bytes(&self) -> Result<Vec<u8>> {
137 B64.decode(&self.audio)
138 .map_err(|e| TypecastError::DecodeError(e.to_string()))
139 }
140
141 pub fn save_audio<P: AsRef<Path>>(&self, path: P) -> Result<()> {
143 let bytes = self.audio_bytes()?;
144 fs::write(path, bytes).map_err(|e| TypecastError::IoError(e.to_string()))
145 }
146
147 pub fn to_srt(&self) -> Result<String> {
152 format_captions(self, true)
153 }
154
155 pub fn to_vtt(&self) -> Result<String> {
160 format_captions(self, false)
161 }
162}
163
164const MAX_CAPTION_SECONDS: f64 = 7.0;
169const MAX_CAPTION_CHARS: usize = 42;
170const SENTENCE_TERMINATORS: &[&str] = &[".", "?", "!", "\u{3002}", "\u{ff1f}", "\u{ff01}"];
171
172struct Segment {
173 text: String,
174 start: f64,
175 end: f64,
176}
177
178struct Cue {
179 text: String,
180 start: f64,
181 end: f64,
182}
183
184fn pick_segments(resp: &TTSWithTimestampsResponse) -> Result<(Vec<Segment>, bool)> {
189 let word_segs = |words: &[crate::timestamps::AlignmentSegmentWord]| -> Vec<Segment> {
190 words
191 .iter()
192 .map(|w| Segment {
193 text: w.text.clone(),
194 start: w.start,
195 end: w.end,
196 })
197 .collect()
198 };
199 let char_segs = |chars: &[crate::timestamps::AlignmentSegmentCharacter]| -> Vec<Segment> {
200 chars
201 .iter()
202 .map(|c| Segment {
203 text: c.text.clone(),
204 start: c.start,
205 end: c.end,
206 })
207 .collect()
208 };
209
210 let multi_words = resp.words.as_deref().filter(|w| w.len() >= 2);
212 let chars = resp.characters.as_deref().filter(|c| !c.is_empty());
214 let single_word = resp.words.as_deref().filter(|w| w.len() == 1);
216
217 if let Some(words) = multi_words {
218 Ok((word_segs(words), true))
219 } else if let Some(c) = chars {
220 Ok((char_segs(c), false))
221 } else if let Some(words) = single_word {
222 Ok((word_segs(words), true))
223 } else {
224 Err(TypecastError::CaptioningError(
225 "no alignment segments to caption from".into(),
226 ))
227 }
228}
229
230fn join_parts(parts: &[String], word_mode: bool) -> String {
233 let sep = if word_mode { " " } else { "" };
234 parts.join(sep).trim().to_string()
235}
236
237fn ends_in_sentence(text: &str) -> bool {
239 let trimmed = text.trim_end();
240 SENTENCE_TERMINATORS.iter().any(|t| trimmed.ends_with(t))
241}
242
243fn group_into_cues(segs: &[Segment], word_mode: bool) -> Vec<Cue> {
249 let mut cues: Vec<Cue> = Vec::new();
250 let mut parts: Vec<String> = Vec::new();
251 let mut cur_start: f64 = 0.0;
254 let mut last_end: f64 = 0.0;
255
256 fn emit(cues: &mut Vec<Cue>, text: String, start: f64, end: f64) {
258 if !text.is_empty() {
259 cues.push(Cue { text, start, end });
260 }
261 }
262
263 for seg in segs {
264 if !parts.is_empty() {
267 let mut tentative = parts.clone();
268 tentative.push(seg.text.clone());
269 let would_be = join_parts(&tentative, word_mode);
270 let too_long_secs = (seg.end - cur_start) > MAX_CAPTION_SECONDS;
271 let too_long_chars = would_be.chars().count() > MAX_CAPTION_CHARS;
272 if too_long_secs || too_long_chars {
273 emit(
275 &mut cues,
276 join_parts(&parts, word_mode),
277 cur_start,
278 last_end,
279 );
280 parts.clear();
281 }
282 }
283
284 if parts.is_empty() {
286 cur_start = seg.start;
287 }
288 parts.push(seg.text.clone());
289 last_end = seg.end;
290
291 if ends_in_sentence(&seg.text) {
293 emit(&mut cues, join_parts(&parts, word_mode), cur_start, seg.end);
294 parts.clear();
295 }
296 }
297
298 if !parts.is_empty() {
300 emit(
301 &mut cues,
302 join_parts(&parts, word_mode),
303 cur_start,
304 last_end,
305 );
306 }
307
308 cues
309}
310
311fn format_srt_time(seconds: f64) -> String {
313 let total_ms = (seconds * 1000.0).round() as i64;
314 let ms = total_ms % 1000;
315 let total_sec = total_ms / 1000;
316 let ss = total_sec % 60;
317 let total_min = total_sec / 60;
318 let mm = total_min % 60;
319 let hh = total_min / 60;
320 format!("{:02}:{:02}:{:02},{:03}", hh, mm, ss, ms)
321}
322
323fn format_vtt_time(seconds: f64) -> String {
325 format_srt_time(seconds).replace(',', ".")
326}
327
328fn format_captions(resp: &TTSWithTimestampsResponse, srt: bool) -> Result<String> {
330 let (segs, word_mode) = pick_segments(resp)?;
331 let cues = group_into_cues(&segs, word_mode);
332 if cues.is_empty() {
333 return Err(TypecastError::CaptioningError(
334 "no alignment segments to caption from".into(),
335 ));
336 }
337
338 let mut out = String::new();
339 if !srt {
340 out.push_str("WEBVTT\n\n");
341 }
342 for (i, cue) in cues.iter().enumerate() {
343 if srt {
344 out.push_str(&format!("{}\n", i + 1));
345 }
346 let (s, e) = if srt {
347 (format_srt_time(cue.start), format_srt_time(cue.end))
348 } else {
349 (format_vtt_time(cue.start), format_vtt_time(cue.end))
350 };
351 out.push_str(&format!("{} --> {}\n", s, e));
352 out.push_str(&cue.text);
353 out.push_str("\n\n");
354 }
355 Ok(out)
356}