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 #[serde(default)]
65 timestamp_source: crate::remote::TimestampSource,
66}
67
68impl Segment {
69 pub fn try_new(start: f64, end: f64, text: impl Into<String>) -> Result<Self> {
71 let s = Self {
72 start,
73 end,
74 text: text.into(),
75 timestamp_source: crate::remote::TimestampSource::Unavailable,
76 };
77 s.validate()?;
78 Ok(s)
79 }
80
81 pub fn from_parts_unchecked(start: f64, end: f64, text: impl Into<String>) -> Self {
86 Self {
87 start,
88 end,
89 text: text.into(),
90 timestamp_source: crate::remote::TimestampSource::Unavailable,
91 }
92 }
93
94 pub fn from_parts_with_source(
96 start: f64,
97 end: f64,
98 text: impl Into<String>,
99 timestamp_source: crate::remote::TimestampSource,
100 ) -> Self {
101 Self {
102 start,
103 end,
104 text: text.into(),
105 timestamp_source,
106 }
107 }
108
109 pub fn start(&self) -> f64 {
110 self.start
111 }
112
113 pub fn end(&self) -> f64 {
114 self.end
115 }
116
117 pub fn text(&self) -> &str {
118 &self.text
119 }
120
121 pub fn timestamp_source(&self) -> crate::remote::TimestampSource {
122 self.timestamp_source
123 }
124
125 pub fn set_timestamp_source(&mut self, source: crate::remote::TimestampSource) {
126 self.timestamp_source = source;
127 }
128
129 pub fn set_start(&mut self, start: f64) {
130 self.start = start;
131 }
132
133 pub fn set_end(&mut self, end: f64) {
134 self.end = end;
135 }
136
137 pub fn set_text(&mut self, text: impl Into<String>) {
138 self.text = text.into();
139 }
140
141 pub fn validate(&self) -> Result<()> {
143 if !self.start.is_finite() || !self.end.is_finite() {
144 return Err(crate::error::UserError::Other {
145 message: format!(
146 "segment timestamps must be finite (start={}, end={})",
147 self.start, self.end
148 ),
149 }
150 .into());
151 }
152 if self.start < 0.0 || self.end < 0.0 {
153 return Err(crate::error::UserError::Other {
154 message: format!(
155 "segment timestamps must be non-negative (start={}, end={})",
156 self.start, self.end
157 ),
158 }
159 .into());
160 }
161 if self.end < self.start {
162 return Err(crate::error::UserError::Other {
163 message: format!(
164 "segment end before start (start={}, end={})",
165 self.start, self.end
166 ),
167 }
168 .into());
169 }
170 Ok(())
171 }
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct TranscriptionResult {
182 text: String,
183 segments: Vec<Segment>,
184 language: Option<String>,
185 model: String,
186 provider: String,
187 duration_secs: f64,
188 #[serde(default = "default_backend_kind")]
190 backend_kind: BackendKind,
191 #[serde(default = "default_true")]
193 timestamps_reliable: bool,
194 #[serde(default)]
196 cleanup_style: crate::cleanup::CleanupStyle,
197 #[serde(default, skip_serializing_if = "Option::is_none")]
199 cleanup_provider: Option<crate::cleanup::CleanupProviderKind>,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
202 original_text: Option<String>,
203 #[serde(default, skip_serializing_if = "Option::is_none")]
205 original_segments: Option<Vec<Segment>>,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
208 cleanup_segment_policy: Option<crate::cleanup::SegmentCleanupPolicy>,
209}
210
211fn default_backend_kind() -> BackendKind {
212 BackendKind::Asr
213}
214fn default_true() -> bool {
215 true
216}
217
218impl TranscriptionResult {
219 pub fn text(&self) -> &str {
220 &self.text
221 }
222
223 pub fn set_text(&mut self, text: impl Into<String>) {
224 self.text = text.into();
225 }
226
227 pub fn segments(&self) -> &[Segment] {
228 &self.segments
229 }
230
231 pub fn segments_mut(&mut self) -> &mut Vec<Segment> {
232 &mut self.segments
233 }
234
235 pub fn set_segments(&mut self, segments: Vec<Segment>) {
236 self.segments = segments;
237 }
238
239 pub fn language(&self) -> Option<&str> {
240 self.language.as_deref()
241 }
242
243 pub fn set_language(&mut self, language: Option<String>) {
244 self.language = language;
245 }
246
247 pub fn model(&self) -> &str {
248 &self.model
249 }
250
251 pub fn set_model(&mut self, model: impl Into<String>) {
252 self.model = model.into();
253 }
254
255 pub fn provider(&self) -> &str {
256 &self.provider
257 }
258
259 pub fn set_provider(&mut self, provider: impl Into<String>) {
260 self.provider = provider.into();
261 }
262
263 pub fn duration_secs(&self) -> f64 {
264 self.duration_secs
265 }
266
267 pub fn set_duration_secs(&mut self, duration_secs: f64) {
268 self.duration_secs = duration_secs;
269 }
270
271 pub fn backend_kind(&self) -> BackendKind {
272 self.backend_kind
273 }
274
275 pub fn set_backend_kind(&mut self, kind: BackendKind) {
276 self.backend_kind = kind;
277 }
278
279 pub fn timestamps_reliable(&self) -> bool {
280 self.timestamps_reliable
281 }
282
283 pub fn set_timestamps_reliable(&mut self, reliable: bool) {
284 self.timestamps_reliable = reliable;
285 }
286
287 pub fn has_approximate_timestamps(&self) -> bool {
289 self.segments
290 .iter()
291 .any(|s| s.timestamp_source().is_approximate())
292 }
293
294 pub fn timestamp_sources(&self) -> Vec<crate::remote::TimestampSource> {
296 self.segments.iter().map(|s| s.timestamp_source()).collect()
297 }
298
299 pub fn cleanup_style(&self) -> crate::cleanup::CleanupStyle {
300 self.cleanup_style
301 }
302
303 pub fn set_cleanup_style(&mut self, style: crate::cleanup::CleanupStyle) {
304 self.cleanup_style = style;
305 }
306
307 pub fn cleanup_provider(&self) -> Option<crate::cleanup::CleanupProviderKind> {
308 self.cleanup_provider
309 }
310
311 pub fn set_cleanup_provider(&mut self, provider: Option<crate::cleanup::CleanupProviderKind>) {
312 self.cleanup_provider = provider;
313 }
314
315 pub fn original_text(&self) -> Option<&str> {
316 self.original_text.as_deref()
317 }
318
319 pub fn set_original_text(&mut self, text: Option<String>) {
320 self.original_text = text;
321 }
322
323 pub fn original_segments(&self) -> Option<&[Segment]> {
324 self.original_segments.as_deref()
325 }
326
327 pub fn set_original_segments(&mut self, segments: Option<Vec<Segment>>) {
328 self.original_segments = segments;
329 }
330
331 pub fn cleanup_segment_policy(&self) -> Option<crate::cleanup::SegmentCleanupPolicy> {
332 self.cleanup_segment_policy
333 }
334
335 pub fn set_cleanup_segment_policy(
336 &mut self,
337 policy: Option<crate::cleanup::SegmentCleanupPolicy>,
338 ) {
339 self.cleanup_segment_policy = policy;
340 }
341
342 pub fn validate_segments(&self) -> Result<()> {
344 for (i, seg) in self.segments.iter().enumerate() {
345 if let Err(e) = seg.validate() {
346 return Err(crate::error::UserError::Other {
347 message: format!("segment[{i}]: {e}"),
348 }
349 .into());
350 }
351 }
352 if !self.duration_secs.is_finite() || self.duration_secs < 0.0 {
353 return Err(crate::error::UserError::Other {
354 message: format!(
355 "duration_secs must be finite and non-negative (got {})",
356 self.duration_secs
357 ),
358 }
359 .into());
360 }
361 Ok(())
362 }
363
364 pub fn try_from_dto(dto: &crate::dto::SttResultDto) -> Result<Self> {
369 if dto.schema_version != crate::dto::STT_RESULT_SCHEMA_VERSION && dto.schema_version != 1 {
371 return Err(crate::error::UserError::Other {
372 message: format!(
373 "unsupported STT DTO schema_version {} (expected 1 or {})",
374 dto.schema_version,
375 crate::dto::STT_RESULT_SCHEMA_VERSION
376 ),
377 }
378 .into());
379 }
380 let mut r = Self {
381 text: dto.text.clone(),
382 segments: dto.segments.clone(),
383 language: dto.language.clone(),
384 model: dto.model.clone(),
385 provider: dto.provider.clone(),
386 duration_secs: dto.duration_secs,
387 backend_kind: dto.backend_kind,
388 timestamps_reliable: dto.timestamps_reliable,
389 cleanup_style: dto.cleanup_style,
390 cleanup_provider: dto.cleanup_provider,
391 original_text: dto.original_text.clone(),
392 original_segments: dto.original_segments.clone(),
393 cleanup_segment_policy: dto.cleanup_segment_policy,
394 };
395 if matches!(r.backend_kind, BackendKind::LlmAssisted) {
397 r.timestamps_reliable = false;
398 }
399 r.validate_segments()?;
400 if let Some(ref segs) = r.original_segments {
401 for (i, seg) in segs.iter().enumerate() {
402 if let Err(e) = seg.validate() {
403 return Err(crate::error::UserError::Other {
404 message: format!("original_segments[{i}]: {e}"),
405 }
406 .into());
407 }
408 }
409 }
410 Ok(r)
411 }
412
413 pub fn local(
414 text: String,
415 segments: Vec<Segment>,
416 language: Option<String>,
417 model: String,
418 duration_secs: f64,
419 ) -> Self {
420 Self {
421 text,
422 segments,
423 language,
424 model,
425 provider: "local".into(),
426 duration_secs,
427 backend_kind: BackendKind::Asr,
428 timestamps_reliable: true,
429 cleanup_style: crate::cleanup::CleanupStyle::Raw,
430 cleanup_provider: None,
431 original_text: None,
432 original_segments: None,
433 cleanup_segment_policy: None,
434 }
435 }
436
437 pub fn try_local(
439 text: String,
440 segments: Vec<Segment>,
441 language: Option<String>,
442 model: String,
443 duration_secs: f64,
444 ) -> Result<Self> {
445 let r = Self::local(text, segments, language, model, duration_secs);
446 r.validate_segments()?;
447 Ok(r)
448 }
449
450 pub fn openrouter(
451 text: String,
452 segments: Vec<Segment>,
453 language: Option<String>,
454 model: String,
455 duration_secs: f64,
456 _timestamps_requested: bool,
457 ) -> Self {
458 Self {
459 text,
460 segments,
461 language,
462 model,
463 provider: "openrouter".into(),
464 duration_secs,
465 backend_kind: BackendKind::LlmAssisted,
466 timestamps_reliable: false,
468 cleanup_style: crate::cleanup::CleanupStyle::Raw,
469 cleanup_provider: None,
470 original_text: None,
471 original_segments: None,
472 cleanup_segment_policy: None,
473 }
474 }
475
476 pub fn try_openrouter(
478 text: String,
479 segments: Vec<Segment>,
480 language: Option<String>,
481 model: String,
482 duration_secs: f64,
483 timestamps_requested: bool,
484 ) -> Result<Self> {
485 let r = Self::openrouter(
486 text,
487 segments,
488 language,
489 model,
490 duration_secs,
491 timestamps_requested,
492 );
493 r.validate_segments()?;
494 Ok(r)
495 }
496}
497
498#[async_trait]
500pub trait TranscriptionProvider: Send + Sync {
501 fn name(&self) -> &'static str;
503
504 fn backend_kind(&self) -> BackendKind;
506
507 fn timestamps_reliable(&self) -> bool {
509 matches!(self.backend_kind(), BackendKind::Asr)
510 }
511
512 async fn transcribe(
514 &self,
515 input: &AudioInput,
516 options: &TranscriptionOptions,
517 ) -> Result<TranscriptionResult>;
518}
519
520pub use local::LocalWhisperProvider;
521pub use openrouter::{OpenRouterProvider, OpenRouterSttMode, SttPath};
522#[cfg(feature = "tts")]
523pub mod openrouter_tts;
524#[cfg(feature = "tts")]
525pub use openrouter_tts::{
526 lookup_openrouter_tts, openrouter_tts_model_in_discovery, OpenRouterTtsProvider,
527 OpenRouterTtsRecord, OpenRouterTtsTier, DEFAULT_OPENROUTER_TTS_MODEL,
528 DEFAULT_OPENROUTER_TTS_VOICE, OPENROUTER_TTS_EVIDENCE_DATE, OPENROUTER_TTS_REGISTRY,
529};
530
531pub mod openai_stt;
532pub use openai_stt::{
533 lookup_openai_stt, OpenAiSttProvider, OpenAiSttRecord, DEFAULT_OPENAI_STT_MODEL,
534 OPENAI_STT_REGISTRY,
535};
536
537#[cfg(feature = "tts")]
538pub mod openai_tts;
539#[cfg(feature = "tts")]
540pub use openai_tts::{
541 lookup_openai_tts, OpenAiTtsProvider, OpenAiTtsRecord, DEFAULT_OPENAI_TTS_MODEL,
542 DEFAULT_OPENAI_TTS_VOICE, OPENAI_TTS_REGISTRY,
543};
544
545#[cfg(feature = "tts")]
546pub mod elevenlabs_tts;
547#[cfg(feature = "tts")]
548pub use elevenlabs_tts::{
549 lookup_elevenlabs_tts, validate_elevenlabs_voice_id, ElevenLabsTtsProvider,
550 ElevenLabsTtsRecord, DEFAULT_ELEVENLABS_TTS_MODEL, ELEVENLABS_TTS_REGISTRY,
551 EXAMPLE_ELEVENLABS_VOICE_ID,
552};
553
554pub mod xai_stt;
555pub use xai_stt::{
556 lookup_xai_stt, XaiSttProvider, XaiSttRecord, DEFAULT_XAI_STT_MODEL, XAI_STT_REGISTRY,
557};
558
559#[cfg(feature = "tts")]
560pub mod xai_tts;
561#[cfg(feature = "tts")]
562pub use xai_tts::{
563 lookup_xai_tts, XaiTtsProvider, XaiTtsRecord, DEFAULT_XAI_TTS_MODEL, DEFAULT_XAI_TTS_VOICE,
564 XAI_TTS_REGISTRY,
565};
566
567#[cfg(test)]
571mod registry_defaults_tests {
572 use super::*;
573
574 #[test]
575 fn product_stt_defaults_resolve_in_reviewed_registries() {
576 assert!(
577 lookup_openai_stt(DEFAULT_OPENAI_STT_MODEL).is_some(),
578 "OpenAI STT default missing from OPENAI_STT_REGISTRY"
579 );
580 assert!(
581 lookup_xai_stt(DEFAULT_XAI_STT_MODEL).is_some(),
582 "xAI STT default missing from XAI_STT_REGISTRY"
583 );
584 }
585
586 #[cfg(feature = "tts")]
587 #[test]
588 fn product_tts_defaults_resolve_in_reviewed_registries() {
589 assert!(
590 lookup_openrouter_tts(DEFAULT_OPENROUTER_TTS_MODEL).is_some(),
591 "OpenRouter TTS default missing from OPENROUTER_TTS_REGISTRY"
592 );
593 assert!(
594 lookup_openai_tts(DEFAULT_OPENAI_TTS_MODEL).is_some(),
595 "OpenAI TTS default missing from OPENAI_TTS_REGISTRY"
596 );
597 assert!(
598 lookup_elevenlabs_tts(DEFAULT_ELEVENLABS_TTS_MODEL).is_some(),
599 "ElevenLabs TTS default missing from ELEVENLABS_TTS_REGISTRY"
600 );
601 assert!(
602 lookup_xai_tts(DEFAULT_XAI_TTS_MODEL).is_some(),
603 "xAI TTS default missing from XAI_TTS_REGISTRY"
604 );
605 }
606}
607
608#[cfg(feature = "tts")]
614pub fn default_tts_model_for_provider(provider: &str) -> Option<&'static str> {
615 match provider {
616 "local" => Some(crate::tts::DEFAULT_TTS_MODEL),
617 "openrouter" => Some(DEFAULT_OPENROUTER_TTS_MODEL),
618 "openai" => Some(DEFAULT_OPENAI_TTS_MODEL),
619 "elevenlabs" => Some(DEFAULT_ELEVENLABS_TTS_MODEL),
620 "xai" | "grok" => Some(DEFAULT_XAI_TTS_MODEL),
621 _ => None,
622 }
623}
624
625#[cfg(feature = "tts")]
629pub fn default_tts_voice_for_provider(provider: &str) -> Option<&'static str> {
630 match provider {
631 "local" => Some(crate::tts::DEFAULT_TTS_VOICE),
632 "openrouter" => Some(DEFAULT_OPENROUTER_TTS_VOICE),
633 "openai" => Some(DEFAULT_OPENAI_TTS_VOICE),
634 "elevenlabs" => None,
635 "xai" | "grok" => Some(DEFAULT_XAI_TTS_VOICE),
636 _ => None,
637 }
638}
639
640#[cfg(feature = "tts")]
642pub fn tts_model_known_for_provider(provider: &str, model: &str) -> bool {
643 match provider {
644 "local" => crate::tts::lookup_model(model).is_ok(),
645 "openrouter" => lookup_openrouter_tts(model).is_some(),
646 "openai" => lookup_openai_tts(model).is_some(),
647 "elevenlabs" => lookup_elevenlabs_tts(model).is_some(),
648 "xai" | "grok" => lookup_xai_tts(model).is_some(),
649 _ => false,
650 }
651}
652
653#[cfg(feature = "tts")]
656pub fn resolve_tts_model(
657 provider: &str,
658 cli_model: Option<&str>,
659 config_model: &str,
660) -> Result<String> {
661 if let Some(m) = cli_model.map(str::trim).filter(|s| !s.is_empty()) {
662 return Ok(m.to_string());
663 }
664 if tts_model_known_for_provider(provider, config_model) {
665 return Ok(config_model.to_string());
666 }
667 if let Some(d) = default_tts_model_for_provider(provider) {
668 return Ok(d.to_string());
669 }
670 Err(crate::error::UserError::UnsupportedCapability {
671 provider: provider.into(),
672 model: config_model.into(),
673 reason: "no reviewed default TTS model for this provider".into(),
674 hint: "pass --model with a reviewed id for the selected provider".into(),
675 }
676 .into())
677}
678
679#[cfg(feature = "tts")]
682pub fn resolve_tts_voice(
683 provider: &str,
684 cli_voice: Option<&str>,
685 config_voice: &str,
686) -> Result<String> {
687 if let Some(v) = cli_voice.map(str::trim).filter(|s| !s.is_empty()) {
688 return Ok(v.to_string());
689 }
690 match provider {
691 "local" => {
692 let v = config_voice.trim();
693 if !v.is_empty() {
694 Ok(v.to_string())
695 } else {
696 Ok(crate::tts::DEFAULT_TTS_VOICE.to_string())
697 }
698 }
699 "elevenlabs" => Err(crate::error::UserError::Other {
700 message: "ElevenLabs requires an explicit --voice <voice_id> (no local alias remap)"
701 .into(),
702 }
703 .into()),
704 other => {
705 if let Some(d) = default_tts_voice_for_provider(other) {
706 Ok(d.to_string())
707 } else {
708 let v = config_voice.trim();
709 if !v.is_empty() {
710 Ok(v.to_string())
711 } else {
712 Err(crate::error::UserError::Other {
713 message: format!("TTS voice is required for provider '{other}'"),
714 }
715 .into())
716 }
717 }
718 }
719 }
720}
721
722#[cfg(test)]
723mod tests {
724 use super::*;
725
726 #[test]
727 fn segment_try_new_accepts_valid() {
728 let s = Segment::try_new(0.0, 1.5, "hello").unwrap();
729 assert_eq!(s.start, 0.0);
730 assert_eq!(s.end, 1.5);
731 assert_eq!(s.text, "hello");
732 }
733
734 #[test]
735 fn segment_try_new_rejects_nan() {
736 assert!(Segment::try_new(f64::NAN, 1.0, "x").is_err());
737 assert!(Segment::try_new(0.0, f64::INFINITY, "x").is_err());
738 }
739
740 #[test]
741 fn segment_try_new_rejects_negative_and_inverted() {
742 assert!(Segment::try_new(-0.1, 1.0, "x").is_err());
743 assert!(Segment::try_new(2.0, 1.0, "x").is_err());
744 }
745
746 #[test]
747 fn segment_validate_ok_on_zero_length() {
748 Segment::try_new(1.0, 1.0, "").unwrap();
750 }
751
752 #[test]
753 fn try_local_rejects_nan_segment() {
754 let segs = vec![Segment::from_parts_unchecked(
755 f64::NAN,
756 1.0,
757 "x".to_string(),
758 )];
759 assert!(TranscriptionResult::try_local("x".into(), segs, None, "m".into(), 1.0).is_err());
760 }
761
762 #[test]
763 fn try_local_accepts_valid() {
764 let segs = vec![Segment::try_new(0.0, 0.5, "hi").unwrap()];
765 let r =
766 TranscriptionResult::try_local("hi".into(), segs, Some("en".into()), "m".into(), 1.0)
767 .unwrap();
768 assert_eq!(r.provider(), "local");
769 }
770
771 #[test]
772 fn try_from_dto_rejects_nan_segment() {
773 let mut dto = crate::dto::SttResultDto::from_result(&TranscriptionResult::local(
774 "x".into(),
775 vec![Segment::try_new(0.0, 1.0, "x").unwrap()],
776 None,
777 "m".into(),
778 1.0,
779 ));
780 dto.segments = vec![Segment::from_parts_unchecked(
781 f64::NAN,
782 1.0,
783 "x".to_string(),
784 )];
785 assert!(TranscriptionResult::try_from_dto(&dto).is_err());
786 }
787
788 #[test]
789 fn try_from_dto_forces_llm_timestamps_unreliable() {
790 let mut dto = crate::dto::SttResultDto::from_result(&TranscriptionResult::openrouter(
791 "hi".into(),
792 vec![Segment::try_new(0.0, 1.0, "hi").unwrap()],
793 None,
794 "m".into(),
795 1.0,
796 true,
797 ));
798 dto.timestamps_reliable = true; let r = TranscriptionResult::try_from_dto(&dto).unwrap();
800 assert!(!r.timestamps_reliable());
801 assert_eq!(r.backend_kind(), BackendKind::LlmAssisted);
802 }
803}