1pub mod local;
4pub mod openrouter;
5
6use crate::audio::AudioInput;
7use crate::error::Result;
8use async_trait::async_trait;
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum BackendKind {
15 Asr,
17 LlmAssisted,
19}
20
21#[derive(Debug, Clone)]
23pub struct TranscriptionOptions {
24 pub model: String,
26 pub language: String,
28 pub timestamps: bool,
30 pub cancel: Option<crate::cancel::CancelFlag>,
32}
33
34impl Default for TranscriptionOptions {
35 fn default() -> Self {
36 Self {
37 model: crate::config::DEFAULT_LOCAL_MODEL.to_string(),
38 language: crate::config::DEFAULT_LANGUAGE.to_string(),
39 timestamps: false,
40 cancel: None,
41 }
42 }
43}
44
45impl TranscriptionOptions {
46 pub fn with_cancel(mut self, flag: crate::cancel::CancelFlag) -> Self {
47 self.cancel = Some(flag);
48 self
49 }
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
57pub struct Segment {
58 start: f64,
60 end: f64,
62 text: String,
63}
64
65impl Segment {
66 pub fn try_new(start: f64, end: f64, text: impl Into<String>) -> Result<Self> {
68 let s = Self {
69 start,
70 end,
71 text: text.into(),
72 };
73 s.validate()?;
74 Ok(s)
75 }
76
77 pub fn from_parts_unchecked(start: f64, end: f64, text: impl Into<String>) -> Self {
82 Self {
83 start,
84 end,
85 text: text.into(),
86 }
87 }
88
89 pub fn start(&self) -> f64 {
90 self.start
91 }
92
93 pub fn end(&self) -> f64 {
94 self.end
95 }
96
97 pub fn text(&self) -> &str {
98 &self.text
99 }
100
101 pub fn set_start(&mut self, start: f64) {
102 self.start = start;
103 }
104
105 pub fn set_end(&mut self, end: f64) {
106 self.end = end;
107 }
108
109 pub fn set_text(&mut self, text: impl Into<String>) {
110 self.text = text.into();
111 }
112
113 pub fn validate(&self) -> Result<()> {
115 if !self.start.is_finite() || !self.end.is_finite() {
116 return Err(crate::error::UserError::Other {
117 message: format!(
118 "segment timestamps must be finite (start={}, end={})",
119 self.start, self.end
120 ),
121 }
122 .into());
123 }
124 if self.start < 0.0 || self.end < 0.0 {
125 return Err(crate::error::UserError::Other {
126 message: format!(
127 "segment timestamps must be non-negative (start={}, end={})",
128 self.start, self.end
129 ),
130 }
131 .into());
132 }
133 if self.end < self.start {
134 return Err(crate::error::UserError::Other {
135 message: format!(
136 "segment end before start (start={}, end={})",
137 self.start, self.end
138 ),
139 }
140 .into());
141 }
142 Ok(())
143 }
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct TranscriptionResult {
154 text: String,
155 segments: Vec<Segment>,
156 language: Option<String>,
157 model: String,
158 provider: String,
159 duration_secs: f64,
160 #[serde(default = "default_backend_kind")]
162 backend_kind: BackendKind,
163 #[serde(default = "default_true")]
165 timestamps_reliable: bool,
166 #[serde(default)]
168 cleanup_style: crate::cleanup::CleanupStyle,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
171 cleanup_provider: Option<crate::cleanup::CleanupProviderKind>,
172 #[serde(default, skip_serializing_if = "Option::is_none")]
174 original_text: Option<String>,
175 #[serde(default, skip_serializing_if = "Option::is_none")]
177 original_segments: Option<Vec<Segment>>,
178 #[serde(default, skip_serializing_if = "Option::is_none")]
180 cleanup_segment_policy: Option<crate::cleanup::SegmentCleanupPolicy>,
181}
182
183fn default_backend_kind() -> BackendKind {
184 BackendKind::Asr
185}
186fn default_true() -> bool {
187 true
188}
189
190impl TranscriptionResult {
191 pub fn text(&self) -> &str {
192 &self.text
193 }
194
195 pub fn set_text(&mut self, text: impl Into<String>) {
196 self.text = text.into();
197 }
198
199 pub fn segments(&self) -> &[Segment] {
200 &self.segments
201 }
202
203 pub fn segments_mut(&mut self) -> &mut Vec<Segment> {
204 &mut self.segments
205 }
206
207 pub fn set_segments(&mut self, segments: Vec<Segment>) {
208 self.segments = segments;
209 }
210
211 pub fn language(&self) -> Option<&str> {
212 self.language.as_deref()
213 }
214
215 pub fn set_language(&mut self, language: Option<String>) {
216 self.language = language;
217 }
218
219 pub fn model(&self) -> &str {
220 &self.model
221 }
222
223 pub fn set_model(&mut self, model: impl Into<String>) {
224 self.model = model.into();
225 }
226
227 pub fn provider(&self) -> &str {
228 &self.provider
229 }
230
231 pub fn set_provider(&mut self, provider: impl Into<String>) {
232 self.provider = provider.into();
233 }
234
235 pub fn duration_secs(&self) -> f64 {
236 self.duration_secs
237 }
238
239 pub fn set_duration_secs(&mut self, duration_secs: f64) {
240 self.duration_secs = duration_secs;
241 }
242
243 pub fn backend_kind(&self) -> BackendKind {
244 self.backend_kind
245 }
246
247 pub fn set_backend_kind(&mut self, kind: BackendKind) {
248 self.backend_kind = kind;
249 }
250
251 pub fn timestamps_reliable(&self) -> bool {
252 self.timestamps_reliable
253 }
254
255 pub fn set_timestamps_reliable(&mut self, reliable: bool) {
256 self.timestamps_reliable = reliable;
257 }
258
259 pub fn cleanup_style(&self) -> crate::cleanup::CleanupStyle {
260 self.cleanup_style
261 }
262
263 pub fn set_cleanup_style(&mut self, style: crate::cleanup::CleanupStyle) {
264 self.cleanup_style = style;
265 }
266
267 pub fn cleanup_provider(&self) -> Option<crate::cleanup::CleanupProviderKind> {
268 self.cleanup_provider
269 }
270
271 pub fn set_cleanup_provider(&mut self, provider: Option<crate::cleanup::CleanupProviderKind>) {
272 self.cleanup_provider = provider;
273 }
274
275 pub fn original_text(&self) -> Option<&str> {
276 self.original_text.as_deref()
277 }
278
279 pub fn set_original_text(&mut self, text: Option<String>) {
280 self.original_text = text;
281 }
282
283 pub fn original_segments(&self) -> Option<&[Segment]> {
284 self.original_segments.as_deref()
285 }
286
287 pub fn set_original_segments(&mut self, segments: Option<Vec<Segment>>) {
288 self.original_segments = segments;
289 }
290
291 pub fn cleanup_segment_policy(&self) -> Option<crate::cleanup::SegmentCleanupPolicy> {
292 self.cleanup_segment_policy
293 }
294
295 pub fn set_cleanup_segment_policy(
296 &mut self,
297 policy: Option<crate::cleanup::SegmentCleanupPolicy>,
298 ) {
299 self.cleanup_segment_policy = policy;
300 }
301
302 pub fn validate_segments(&self) -> Result<()> {
304 for (i, seg) in self.segments.iter().enumerate() {
305 if let Err(e) = seg.validate() {
306 return Err(crate::error::UserError::Other {
307 message: format!("segment[{i}]: {e}"),
308 }
309 .into());
310 }
311 }
312 if !self.duration_secs.is_finite() || self.duration_secs < 0.0 {
313 return Err(crate::error::UserError::Other {
314 message: format!(
315 "duration_secs must be finite and non-negative (got {})",
316 self.duration_secs
317 ),
318 }
319 .into());
320 }
321 Ok(())
322 }
323
324 pub fn try_from_dto(dto: &crate::dto::SttResultDto) -> Result<Self> {
329 if dto.schema_version != crate::dto::STT_RESULT_SCHEMA_VERSION {
330 return Err(crate::error::UserError::Other {
331 message: format!(
332 "unsupported STT DTO schema_version {} (expected {})",
333 dto.schema_version,
334 crate::dto::STT_RESULT_SCHEMA_VERSION
335 ),
336 }
337 .into());
338 }
339 let mut r = Self {
340 text: dto.text.clone(),
341 segments: dto.segments.clone(),
342 language: dto.language.clone(),
343 model: dto.model.clone(),
344 provider: dto.provider.clone(),
345 duration_secs: dto.duration_secs,
346 backend_kind: dto.backend_kind,
347 timestamps_reliable: dto.timestamps_reliable,
348 cleanup_style: dto.cleanup_style,
349 cleanup_provider: dto.cleanup_provider,
350 original_text: dto.original_text.clone(),
351 original_segments: dto.original_segments.clone(),
352 cleanup_segment_policy: dto.cleanup_segment_policy,
353 };
354 if matches!(r.backend_kind, BackendKind::LlmAssisted) {
356 r.timestamps_reliable = false;
357 }
358 r.validate_segments()?;
359 if let Some(ref segs) = r.original_segments {
360 for (i, seg) in segs.iter().enumerate() {
361 if let Err(e) = seg.validate() {
362 return Err(crate::error::UserError::Other {
363 message: format!("original_segments[{i}]: {e}"),
364 }
365 .into());
366 }
367 }
368 }
369 Ok(r)
370 }
371
372 pub fn local(
373 text: String,
374 segments: Vec<Segment>,
375 language: Option<String>,
376 model: String,
377 duration_secs: f64,
378 ) -> Self {
379 Self {
380 text,
381 segments,
382 language,
383 model,
384 provider: "local".into(),
385 duration_secs,
386 backend_kind: BackendKind::Asr,
387 timestamps_reliable: true,
388 cleanup_style: crate::cleanup::CleanupStyle::Raw,
389 cleanup_provider: None,
390 original_text: None,
391 original_segments: None,
392 cleanup_segment_policy: None,
393 }
394 }
395
396 pub fn try_local(
398 text: String,
399 segments: Vec<Segment>,
400 language: Option<String>,
401 model: String,
402 duration_secs: f64,
403 ) -> Result<Self> {
404 let r = Self::local(text, segments, language, model, duration_secs);
405 r.validate_segments()?;
406 Ok(r)
407 }
408
409 pub fn openrouter(
410 text: String,
411 segments: Vec<Segment>,
412 language: Option<String>,
413 model: String,
414 duration_secs: f64,
415 _timestamps_requested: bool,
416 ) -> Self {
417 Self {
418 text,
419 segments,
420 language,
421 model,
422 provider: "openrouter".into(),
423 duration_secs,
424 backend_kind: BackendKind::LlmAssisted,
425 timestamps_reliable: false,
427 cleanup_style: crate::cleanup::CleanupStyle::Raw,
428 cleanup_provider: None,
429 original_text: None,
430 original_segments: None,
431 cleanup_segment_policy: None,
432 }
433 }
434
435 pub fn try_openrouter(
437 text: String,
438 segments: Vec<Segment>,
439 language: Option<String>,
440 model: String,
441 duration_secs: f64,
442 timestamps_requested: bool,
443 ) -> Result<Self> {
444 let r = Self::openrouter(
445 text,
446 segments,
447 language,
448 model,
449 duration_secs,
450 timestamps_requested,
451 );
452 r.validate_segments()?;
453 Ok(r)
454 }
455}
456
457#[async_trait]
459pub trait TranscriptionProvider: Send + Sync {
460 fn name(&self) -> &'static str;
462
463 fn backend_kind(&self) -> BackendKind;
465
466 fn timestamps_reliable(&self) -> bool {
468 matches!(self.backend_kind(), BackendKind::Asr)
469 }
470
471 async fn transcribe(
473 &self,
474 input: &AudioInput,
475 options: &TranscriptionOptions,
476 ) -> Result<TranscriptionResult>;
477}
478
479pub use local::LocalWhisperProvider;
480pub use openrouter::{OpenRouterProvider, OpenRouterSttMode, SttPath};
481
482#[cfg(test)]
483mod tests {
484 use super::*;
485
486 #[test]
487 fn segment_try_new_accepts_valid() {
488 let s = Segment::try_new(0.0, 1.5, "hello").unwrap();
489 assert_eq!(s.start, 0.0);
490 assert_eq!(s.end, 1.5);
491 assert_eq!(s.text, "hello");
492 }
493
494 #[test]
495 fn segment_try_new_rejects_nan() {
496 assert!(Segment::try_new(f64::NAN, 1.0, "x").is_err());
497 assert!(Segment::try_new(0.0, f64::INFINITY, "x").is_err());
498 }
499
500 #[test]
501 fn segment_try_new_rejects_negative_and_inverted() {
502 assert!(Segment::try_new(-0.1, 1.0, "x").is_err());
503 assert!(Segment::try_new(2.0, 1.0, "x").is_err());
504 }
505
506 #[test]
507 fn segment_validate_ok_on_zero_length() {
508 Segment::try_new(1.0, 1.0, "").unwrap();
510 }
511
512 #[test]
513 fn try_local_rejects_nan_segment() {
514 let segs = vec![Segment::from_parts_unchecked(
515 f64::NAN,
516 1.0,
517 "x".to_string(),
518 )];
519 assert!(TranscriptionResult::try_local("x".into(), segs, None, "m".into(), 1.0).is_err());
520 }
521
522 #[test]
523 fn try_local_accepts_valid() {
524 let segs = vec![Segment::try_new(0.0, 0.5, "hi").unwrap()];
525 let r =
526 TranscriptionResult::try_local("hi".into(), segs, Some("en".into()), "m".into(), 1.0)
527 .unwrap();
528 assert_eq!(r.provider(), "local");
529 }
530
531 #[test]
532 fn try_from_dto_rejects_nan_segment() {
533 let mut dto = crate::dto::SttResultDto::from_result(&TranscriptionResult::local(
534 "x".into(),
535 vec![Segment::try_new(0.0, 1.0, "x").unwrap()],
536 None,
537 "m".into(),
538 1.0,
539 ));
540 dto.segments = vec![Segment::from_parts_unchecked(
541 f64::NAN,
542 1.0,
543 "x".to_string(),
544 )];
545 assert!(TranscriptionResult::try_from_dto(&dto).is_err());
546 }
547
548 #[test]
549 fn try_from_dto_forces_llm_timestamps_unreliable() {
550 let mut dto = crate::dto::SttResultDto::from_result(&TranscriptionResult::openrouter(
551 "hi".into(),
552 vec![Segment::try_new(0.0, 1.0, "hi").unwrap()],
553 None,
554 "m".into(),
555 1.0,
556 true,
557 ));
558 dto.timestamps_reliable = true; let r = TranscriptionResult::try_from_dto(&dto).unwrap();
560 assert!(!r.timestamps_reliable());
561 assert_eq!(r.backend_kind(), BackendKind::LlmAssisted);
562 }
563}