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 pub op: Option<crate::runtime::OpContext>,
37}
38
39impl Default for TranscriptionOptions {
40 fn default() -> Self {
41 Self {
42 model: crate::config::DEFAULT_LOCAL_MODEL.to_string(),
43 language: crate::config::DEFAULT_LANGUAGE.to_string(),
44 timestamps: false,
45 cancel: None,
46 op: None,
47 }
48 }
49}
50
51impl TranscriptionOptions {
52 pub fn with_cancel(mut self, flag: crate::cancel::CancelFlag) -> Self {
53 self.cancel = Some(flag);
54 self
55 }
56
57 pub fn with_op(mut self, op: crate::runtime::OpContext) -> Self {
58 self.cancel = Some(op.cancel.clone());
59 self.op = Some(op);
60 self
61 }
62
63 pub fn resolve_op_context(&self) -> crate::runtime::OpContext {
68 if let Some(ref op) = self.op {
69 return op.clone();
70 }
71 crate::runtime::OpContext::from_optional_cancel(self.cancel.clone())
72 }
73}
74
75#[cfg(test)]
76mod op_context_tests {
77 use super::*;
78 use crate::runtime::OpContext;
79 use std::time::{Duration, Instant};
80
81 #[test]
82 fn resolve_prefers_parent_deadline_over_cancel_only() {
83 let parent =
84 OpContext::new().with_absolute_deadline(Instant::now() + Duration::from_secs(30));
85 let opts = TranscriptionOptions {
86 op: Some(parent.clone()),
87 cancel: Some(parent.cancel.clone()),
88 ..Default::default()
89 };
90 let resolved = opts.resolve_op_context();
91 assert!(resolved.deadline().is_some());
92 assert_eq!(resolved.request_id, parent.request_id);
93 }
94
95 #[test]
96 fn resolve_falls_back_to_cancel_only() {
97 let opts = TranscriptionOptions::default();
98 let resolved = opts.resolve_op_context();
99 assert!(resolved.deadline().is_none());
100 }
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
108pub struct Segment {
109 start: f64,
111 end: f64,
113 text: String,
114 #[serde(default)]
116 timestamp_source: crate::remote::TimestampSource,
117}
118
119impl Segment {
120 pub fn try_new(start: f64, end: f64, text: impl Into<String>) -> Result<Self> {
122 let s = Self {
123 start,
124 end,
125 text: text.into(),
126 timestamp_source: crate::remote::TimestampSource::Unavailable,
127 };
128 s.validate()?;
129 Ok(s)
130 }
131
132 pub fn from_parts_unchecked(start: f64, end: f64, text: impl Into<String>) -> Self {
137 Self {
138 start,
139 end,
140 text: text.into(),
141 timestamp_source: crate::remote::TimestampSource::Unavailable,
142 }
143 }
144
145 pub fn from_parts_with_source(
147 start: f64,
148 end: f64,
149 text: impl Into<String>,
150 timestamp_source: crate::remote::TimestampSource,
151 ) -> Self {
152 Self {
153 start,
154 end,
155 text: text.into(),
156 timestamp_source,
157 }
158 }
159
160 pub fn start(&self) -> f64 {
161 self.start
162 }
163
164 pub fn end(&self) -> f64 {
165 self.end
166 }
167
168 pub fn text(&self) -> &str {
169 &self.text
170 }
171
172 pub fn timestamp_source(&self) -> crate::remote::TimestampSource {
173 self.timestamp_source
174 }
175
176 pub fn set_timestamp_source(&mut self, source: crate::remote::TimestampSource) {
177 self.timestamp_source = source;
178 }
179
180 pub fn set_start(&mut self, start: f64) {
181 self.start = start;
182 }
183
184 pub fn set_end(&mut self, end: f64) {
185 self.end = end;
186 }
187
188 pub fn set_text(&mut self, text: impl Into<String>) {
189 self.text = text.into();
190 }
191
192 pub fn validate(&self) -> Result<()> {
194 if !self.start.is_finite() || !self.end.is_finite() {
195 return Err(crate::error::UserError::Other {
196 message: format!(
197 "segment timestamps must be finite (start={}, end={})",
198 self.start, self.end
199 ),
200 }
201 .into());
202 }
203 if self.start < 0.0 || self.end < 0.0 {
204 return Err(crate::error::UserError::Other {
205 message: format!(
206 "segment timestamps must be non-negative (start={}, end={})",
207 self.start, self.end
208 ),
209 }
210 .into());
211 }
212 if self.end < self.start {
213 return Err(crate::error::UserError::Other {
214 message: format!(
215 "segment end before start (start={}, end={})",
216 self.start, self.end
217 ),
218 }
219 .into());
220 }
221 Ok(())
222 }
223}
224
225#[derive(Debug, Clone, Serialize, Deserialize)]
232pub struct TranscriptionResult {
233 text: String,
234 segments: Vec<Segment>,
235 language: Option<String>,
236 model: String,
237 provider: String,
238 duration_secs: f64,
239 #[serde(default = "default_backend_kind")]
241 backend_kind: BackendKind,
242 #[serde(default = "default_true")]
244 timestamps_reliable: bool,
245 #[serde(default)]
247 cleanup_style: crate::cleanup::CleanupStyle,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
250 cleanup_provider: Option<crate::cleanup::CleanupProviderKind>,
251 #[serde(default, skip_serializing_if = "Option::is_none")]
253 original_text: Option<String>,
254 #[serde(default, skip_serializing_if = "Option::is_none")]
256 original_segments: Option<Vec<Segment>>,
257 #[serde(default, skip_serializing_if = "Option::is_none")]
259 cleanup_segment_policy: Option<crate::cleanup::SegmentCleanupPolicy>,
260 #[serde(default, skip_serializing_if = "Vec::is_empty")]
262 warnings: Vec<String>,
263}
264
265fn default_backend_kind() -> BackendKind {
266 BackendKind::Asr
267}
268fn default_true() -> bool {
269 true
270}
271
272impl TranscriptionResult {
273 pub fn text(&self) -> &str {
274 &self.text
275 }
276
277 pub fn set_text(&mut self, text: impl Into<String>) {
278 self.text = text.into();
279 }
280
281 pub fn segments(&self) -> &[Segment] {
282 &self.segments
283 }
284
285 pub fn segments_mut(&mut self) -> &mut Vec<Segment> {
286 &mut self.segments
287 }
288
289 pub fn set_segments(&mut self, segments: Vec<Segment>) {
290 self.segments = segments;
291 }
292
293 pub fn language(&self) -> Option<&str> {
294 self.language.as_deref()
295 }
296
297 pub fn set_language(&mut self, language: Option<String>) {
298 self.language = language;
299 }
300
301 pub fn model(&self) -> &str {
302 &self.model
303 }
304
305 pub fn set_model(&mut self, model: impl Into<String>) {
306 self.model = model.into();
307 }
308
309 pub fn provider(&self) -> &str {
310 &self.provider
311 }
312
313 pub fn set_provider(&mut self, provider: impl Into<String>) {
314 self.provider = provider.into();
315 }
316
317 pub fn duration_secs(&self) -> f64 {
318 self.duration_secs
319 }
320
321 pub fn set_duration_secs(&mut self, duration_secs: f64) {
322 self.duration_secs = duration_secs;
323 }
324
325 pub fn backend_kind(&self) -> BackendKind {
326 self.backend_kind
327 }
328
329 pub fn set_backend_kind(&mut self, kind: BackendKind) {
330 self.backend_kind = kind;
331 }
332
333 pub fn timestamps_reliable(&self) -> bool {
334 self.timestamps_reliable
335 }
336
337 pub fn set_timestamps_reliable(&mut self, reliable: bool) {
338 self.timestamps_reliable = reliable;
339 }
340
341 pub fn warnings(&self) -> &[String] {
343 &self.warnings
344 }
345
346 pub fn set_warnings(&mut self, warnings: Vec<String>) {
347 self.warnings = warnings;
348 }
349
350 pub fn push_warning(&mut self, warning: impl Into<String>) {
351 self.warnings.push(warning.into());
352 }
353
354 pub fn has_approximate_timestamps(&self) -> bool {
356 self.segments
357 .iter()
358 .any(|s| s.timestamp_source().is_approximate())
359 }
360
361 pub fn timestamp_sources(&self) -> Vec<crate::remote::TimestampSource> {
363 self.segments.iter().map(|s| s.timestamp_source()).collect()
364 }
365
366 pub fn cleanup_style(&self) -> crate::cleanup::CleanupStyle {
367 self.cleanup_style
368 }
369
370 pub fn set_cleanup_style(&mut self, style: crate::cleanup::CleanupStyle) {
371 self.cleanup_style = style;
372 }
373
374 pub fn cleanup_provider(&self) -> Option<crate::cleanup::CleanupProviderKind> {
375 self.cleanup_provider
376 }
377
378 pub fn set_cleanup_provider(&mut self, provider: Option<crate::cleanup::CleanupProviderKind>) {
379 self.cleanup_provider = provider;
380 }
381
382 pub fn original_text(&self) -> Option<&str> {
383 self.original_text.as_deref()
384 }
385
386 pub fn set_original_text(&mut self, text: Option<String>) {
387 self.original_text = text;
388 }
389
390 pub fn original_segments(&self) -> Option<&[Segment]> {
391 self.original_segments.as_deref()
392 }
393
394 pub fn set_original_segments(&mut self, segments: Option<Vec<Segment>>) {
395 self.original_segments = segments;
396 }
397
398 pub fn cleanup_segment_policy(&self) -> Option<crate::cleanup::SegmentCleanupPolicy> {
399 self.cleanup_segment_policy
400 }
401
402 pub fn set_cleanup_segment_policy(
403 &mut self,
404 policy: Option<crate::cleanup::SegmentCleanupPolicy>,
405 ) {
406 self.cleanup_segment_policy = policy;
407 }
408
409 pub fn validate_segments(&self) -> Result<()> {
411 for (i, seg) in self.segments.iter().enumerate() {
412 if let Err(e) = seg.validate() {
413 return Err(crate::error::UserError::Other {
414 message: format!("segment[{i}]: {e}"),
415 }
416 .into());
417 }
418 }
419 if !self.duration_secs.is_finite() || self.duration_secs < 0.0 {
420 return Err(crate::error::UserError::Other {
421 message: format!(
422 "duration_secs must be finite and non-negative (got {})",
423 self.duration_secs
424 ),
425 }
426 .into());
427 }
428 Ok(())
429 }
430
431 pub fn try_from_dto(dto: &crate::dto::SttResultDto) -> Result<Self> {
436 if dto.schema_version != crate::dto::STT_RESULT_SCHEMA_VERSION && dto.schema_version != 1 {
438 return Err(crate::error::UserError::Other {
439 message: format!(
440 "unsupported STT DTO schema_version {} (expected 1 or {})",
441 dto.schema_version,
442 crate::dto::STT_RESULT_SCHEMA_VERSION
443 ),
444 }
445 .into());
446 }
447 let mut r = Self {
448 text: dto.text.clone(),
449 segments: dto.segments.clone(),
450 language: dto.language.clone(),
451 model: dto.model.clone(),
452 provider: dto.provider.clone(),
453 duration_secs: dto.duration_secs,
454 backend_kind: dto.backend_kind,
455 timestamps_reliable: dto.timestamps_reliable,
456 cleanup_style: dto.cleanup_style,
457 cleanup_provider: dto.cleanup_provider,
458 original_text: dto.original_text.clone(),
459 original_segments: dto.original_segments.clone(),
460 cleanup_segment_policy: dto.cleanup_segment_policy,
461 warnings: dto.normalization_warnings.clone(),
462 };
463 if matches!(r.backend_kind, BackendKind::LlmAssisted) {
465 r.timestamps_reliable = false;
466 }
467 r.validate_segments()?;
468 if let Some(ref segs) = r.original_segments {
469 for (i, seg) in segs.iter().enumerate() {
470 if let Err(e) = seg.validate() {
471 return Err(crate::error::UserError::Other {
472 message: format!("original_segments[{i}]: {e}"),
473 }
474 .into());
475 }
476 }
477 }
478 Ok(r)
479 }
480
481 pub fn local(
482 text: String,
483 segments: Vec<Segment>,
484 language: Option<String>,
485 model: String,
486 duration_secs: f64,
487 ) -> Self {
488 Self {
489 text,
490 segments,
491 language,
492 model,
493 provider: "local".into(),
494 duration_secs,
495 backend_kind: BackendKind::Asr,
496 timestamps_reliable: true,
497 cleanup_style: crate::cleanup::CleanupStyle::Raw,
498 cleanup_provider: None,
499 original_text: None,
500 original_segments: None,
501 cleanup_segment_policy: None,
502 warnings: Vec::new(),
503 }
504 }
505
506 pub fn try_local(
508 text: String,
509 segments: Vec<Segment>,
510 language: Option<String>,
511 model: String,
512 duration_secs: f64,
513 ) -> Result<Self> {
514 let r = Self::local(text, segments, language, model, duration_secs);
515 r.validate_segments()?;
516 Ok(r)
517 }
518
519 pub fn openrouter(
520 text: String,
521 segments: Vec<Segment>,
522 language: Option<String>,
523 model: String,
524 duration_secs: f64,
525 _timestamps_requested: bool,
526 ) -> Self {
527 Self {
528 text,
529 segments,
530 language,
531 model,
532 provider: "openrouter".into(),
533 duration_secs,
534 backend_kind: BackendKind::LlmAssisted,
535 timestamps_reliable: false,
537 cleanup_style: crate::cleanup::CleanupStyle::Raw,
538 cleanup_provider: None,
539 original_text: None,
540 original_segments: None,
541 cleanup_segment_policy: None,
542 warnings: Vec::new(),
543 }
544 }
545
546 pub fn try_openrouter(
548 text: String,
549 segments: Vec<Segment>,
550 language: Option<String>,
551 model: String,
552 duration_secs: f64,
553 timestamps_requested: bool,
554 ) -> Result<Self> {
555 let r = Self::openrouter(
556 text,
557 segments,
558 language,
559 model,
560 duration_secs,
561 timestamps_requested,
562 );
563 r.validate_segments()?;
564 Ok(r)
565 }
566}
567
568#[async_trait]
570pub trait TranscriptionProvider: Send + Sync {
571 fn name(&self) -> &'static str;
573
574 fn backend_kind(&self) -> BackendKind;
576
577 fn timestamps_reliable(&self) -> bool {
579 matches!(self.backend_kind(), BackendKind::Asr)
580 }
581
582 async fn transcribe(
584 &self,
585 input: &AudioInput,
586 options: &TranscriptionOptions,
587 ) -> Result<TranscriptionResult>;
588}
589
590pub use local::LocalWhisperProvider;
591pub use openrouter::{OpenRouterProvider, OpenRouterSttMode, SttPath};
592#[cfg(feature = "tts")]
593pub mod openrouter_tts;
594#[cfg(feature = "tts")]
595pub use openrouter_tts::{
596 lookup_openrouter_tts, openrouter_tts_model_in_discovery, OpenRouterTtsProvider,
597 OpenRouterTtsRecord, OpenRouterTtsTier, DEFAULT_OPENROUTER_TTS_MODEL,
598 DEFAULT_OPENROUTER_TTS_VOICE, OPENROUTER_TTS_EVIDENCE_DATE, OPENROUTER_TTS_REGISTRY,
599};
600
601pub mod openai_stt;
602pub use openai_stt::{
603 lookup_openai_stt, OpenAiSttProvider, OpenAiSttRecord, DEFAULT_OPENAI_STT_MODEL,
604 OPENAI_STT_REGISTRY,
605};
606
607#[cfg(feature = "tts")]
608pub mod openai_tts;
609#[cfg(feature = "tts")]
610pub use openai_tts::{
611 lookup_openai_tts, OpenAiTtsProvider, OpenAiTtsRecord, DEFAULT_OPENAI_TTS_MODEL,
612 DEFAULT_OPENAI_TTS_VOICE, OPENAI_TTS_REGISTRY,
613};
614
615#[cfg(feature = "tts")]
616pub mod elevenlabs_tts;
617#[cfg(feature = "tts")]
618pub use elevenlabs_tts::{
619 lookup_elevenlabs_tts, validate_elevenlabs_voice_id, ElevenLabsTtsProvider,
620 ElevenLabsTtsRecord, DEFAULT_ELEVENLABS_TTS_MODEL, ELEVENLABS_TTS_REGISTRY,
621 EXAMPLE_ELEVENLABS_VOICE_ID,
622};
623
624pub mod xai_stt;
625pub use xai_stt::{
626 lookup_xai_stt, XaiSttProvider, XaiSttRecord, DEFAULT_XAI_STT_MODEL, XAI_STT_REGISTRY,
627};
628
629#[cfg(feature = "tts")]
630pub mod xai_tts;
631#[cfg(feature = "tts")]
632pub use xai_tts::{
633 lookup_xai_tts, XaiTtsProvider, XaiTtsRecord, DEFAULT_XAI_TTS_MODEL, DEFAULT_XAI_TTS_VOICE,
634 XAI_TTS_REGISTRY,
635};
636
637#[cfg(test)]
641mod registry_defaults_tests {
642 use super::*;
643
644 #[test]
645 fn product_stt_defaults_resolve_in_reviewed_registries() {
646 assert!(
647 lookup_openai_stt(DEFAULT_OPENAI_STT_MODEL).is_some(),
648 "OpenAI STT default missing from OPENAI_STT_REGISTRY"
649 );
650 assert!(
651 lookup_xai_stt(DEFAULT_XAI_STT_MODEL).is_some(),
652 "xAI STT default missing from XAI_STT_REGISTRY"
653 );
654 }
655
656 #[cfg(feature = "tts")]
657 #[test]
658 fn product_tts_defaults_resolve_in_reviewed_registries() {
659 assert!(
660 lookup_openrouter_tts(DEFAULT_OPENROUTER_TTS_MODEL).is_some(),
661 "OpenRouter TTS default missing from OPENROUTER_TTS_REGISTRY"
662 );
663 assert!(
664 lookup_openai_tts(DEFAULT_OPENAI_TTS_MODEL).is_some(),
665 "OpenAI TTS default missing from OPENAI_TTS_REGISTRY"
666 );
667 assert!(
668 lookup_elevenlabs_tts(DEFAULT_ELEVENLABS_TTS_MODEL).is_some(),
669 "ElevenLabs TTS default missing from ELEVENLABS_TTS_REGISTRY"
670 );
671 assert!(
672 lookup_xai_tts(DEFAULT_XAI_TTS_MODEL).is_some(),
673 "xAI TTS default missing from XAI_TTS_REGISTRY"
674 );
675 }
676}
677
678#[cfg(feature = "tts")]
684pub fn default_tts_model_for_provider(provider: &str) -> Option<&'static str> {
685 match provider {
686 "local" => Some(crate::tts::DEFAULT_TTS_MODEL),
687 "openrouter" => Some(DEFAULT_OPENROUTER_TTS_MODEL),
688 "openai" => Some(DEFAULT_OPENAI_TTS_MODEL),
689 "elevenlabs" => Some(DEFAULT_ELEVENLABS_TTS_MODEL),
690 "xai" | "grok" => Some(DEFAULT_XAI_TTS_MODEL),
691 _ => None,
692 }
693}
694
695#[cfg(feature = "tts")]
699pub fn default_tts_voice_for_provider(provider: &str) -> Option<&'static str> {
700 match provider {
701 "local" => Some(crate::tts::DEFAULT_TTS_VOICE),
702 "openrouter" => Some(DEFAULT_OPENROUTER_TTS_VOICE),
703 "openai" => Some(DEFAULT_OPENAI_TTS_VOICE),
704 "elevenlabs" => None,
705 "xai" | "grok" => Some(DEFAULT_XAI_TTS_VOICE),
706 _ => None,
707 }
708}
709
710#[cfg(feature = "tts")]
712pub fn tts_model_known_for_provider(provider: &str, model: &str) -> bool {
713 match provider {
714 "local" => crate::tts::lookup_model(model).is_ok(),
715 "openrouter" => lookup_openrouter_tts(model).is_some(),
716 "openai" => lookup_openai_tts(model).is_some(),
717 "elevenlabs" => lookup_elevenlabs_tts(model).is_some(),
718 "xai" | "grok" => lookup_xai_tts(model).is_some(),
719 _ => false,
720 }
721}
722
723#[cfg(feature = "tts")]
726pub fn resolve_tts_model(
727 provider: &str,
728 cli_model: Option<&str>,
729 config_model: &str,
730) -> Result<String> {
731 if let Some(m) = cli_model.map(str::trim).filter(|s| !s.is_empty()) {
732 return Ok(m.to_string());
733 }
734 if tts_model_known_for_provider(provider, config_model) {
735 return Ok(config_model.to_string());
736 }
737 if let Some(d) = default_tts_model_for_provider(provider) {
738 return Ok(d.to_string());
739 }
740 Err(crate::error::UserError::UnsupportedCapability {
741 provider: provider.into(),
742 model: config_model.into(),
743 reason: "no reviewed default TTS model for this provider".into(),
744 hint: "pass --model with a reviewed id for the selected provider".into(),
745 }
746 .into())
747}
748
749#[cfg(feature = "tts")]
752pub fn resolve_tts_voice(
753 provider: &str,
754 cli_voice: Option<&str>,
755 config_voice: &str,
756) -> Result<String> {
757 if let Some(v) = cli_voice.map(str::trim).filter(|s| !s.is_empty()) {
758 return Ok(v.to_string());
759 }
760 match provider {
761 "local" => {
762 let v = config_voice.trim();
763 if !v.is_empty() {
764 Ok(v.to_string())
765 } else {
766 Ok(crate::tts::DEFAULT_TTS_VOICE.to_string())
767 }
768 }
769 "elevenlabs" => Err(crate::error::UserError::Other {
770 message: "ElevenLabs requires an explicit --voice <voice_id> (no local alias remap)"
771 .into(),
772 }
773 .into()),
774 other => {
775 if let Some(d) = default_tts_voice_for_provider(other) {
776 Ok(d.to_string())
777 } else {
778 let v = config_voice.trim();
779 if !v.is_empty() {
780 Ok(v.to_string())
781 } else {
782 Err(crate::error::UserError::Other {
783 message: format!("TTS voice is required for provider '{other}'"),
784 }
785 .into())
786 }
787 }
788 }
789 }
790}
791
792#[cfg(test)]
793mod tests {
794 use super::*;
795
796 #[test]
797 fn segment_try_new_accepts_valid() {
798 let s = Segment::try_new(0.0, 1.5, "hello").unwrap();
799 assert_eq!(s.start, 0.0);
800 assert_eq!(s.end, 1.5);
801 assert_eq!(s.text, "hello");
802 }
803
804 #[test]
805 fn segment_try_new_rejects_nan() {
806 assert!(Segment::try_new(f64::NAN, 1.0, "x").is_err());
807 assert!(Segment::try_new(0.0, f64::INFINITY, "x").is_err());
808 }
809
810 #[test]
811 fn segment_try_new_rejects_negative_and_inverted() {
812 assert!(Segment::try_new(-0.1, 1.0, "x").is_err());
813 assert!(Segment::try_new(2.0, 1.0, "x").is_err());
814 }
815
816 #[test]
817 fn segment_validate_ok_on_zero_length() {
818 Segment::try_new(1.0, 1.0, "").unwrap();
820 }
821
822 #[test]
823 fn try_local_rejects_nan_segment() {
824 let segs = vec![Segment::from_parts_unchecked(
825 f64::NAN,
826 1.0,
827 "x".to_string(),
828 )];
829 assert!(TranscriptionResult::try_local("x".into(), segs, None, "m".into(), 1.0).is_err());
830 }
831
832 #[test]
833 fn try_local_accepts_valid() {
834 let segs = vec![Segment::try_new(0.0, 0.5, "hi").unwrap()];
835 let r =
836 TranscriptionResult::try_local("hi".into(), segs, Some("en".into()), "m".into(), 1.0)
837 .unwrap();
838 assert_eq!(r.provider(), "local");
839 }
840
841 #[test]
842 fn try_from_dto_rejects_nan_segment() {
843 let mut dto = crate::dto::SttResultDto::from_result(&TranscriptionResult::local(
844 "x".into(),
845 vec![Segment::try_new(0.0, 1.0, "x").unwrap()],
846 None,
847 "m".into(),
848 1.0,
849 ));
850 dto.segments = vec![Segment::from_parts_unchecked(
851 f64::NAN,
852 1.0,
853 "x".to_string(),
854 )];
855 assert!(TranscriptionResult::try_from_dto(&dto).is_err());
856 }
857
858 #[test]
859 fn try_from_dto_forces_llm_timestamps_unreliable() {
860 let mut dto = crate::dto::SttResultDto::from_result(&TranscriptionResult::openrouter(
861 "hi".into(),
862 vec![Segment::try_new(0.0, 1.0, "hi").unwrap()],
863 None,
864 "m".into(),
865 1.0,
866 true,
867 ));
868 dto.timestamps_reliable = true; let r = TranscriptionResult::try_from_dto(&dto).unwrap();
870 assert!(!r.timestamps_reliable());
871 }
872
873 #[test]
874 fn unchecked_segment_defaults_unavailable_is_approximate() {
875 let s = Segment::from_parts_unchecked(0.0, 1.0, "hi");
876 assert_eq!(
877 s.timestamp_source(),
878 crate::remote::TimestampSource::Unavailable
879 );
880 assert!(s.timestamp_source().is_approximate());
881 }
882
883 #[test]
884 fn native_model_segment_allows_srt_gate() {
885 let segs = vec![Segment::from_parts_with_source(
886 0.0,
887 1.0,
888 "hello",
889 crate::remote::TimestampSource::NativeModel,
890 )];
891 let r =
892 TranscriptionResult::local("hello".into(), segs, Some("en".into()), "base".into(), 1.0);
893 assert!(r.timestamps_reliable());
894 assert!(!r.has_approximate_timestamps());
895 assert!(r.timestamps_reliable() && !r.has_approximate_timestamps());
897 }
898
899 #[test]
900 fn provider_segment_and_word_allow_srt_gate() {
901 for source in [
902 crate::remote::TimestampSource::ProviderSegment,
903 crate::remote::TimestampSource::ProviderWord,
904 crate::remote::TimestampSource::ChunkOffset,
905 ] {
906 let segs = vec![Segment::from_parts_with_source(0.0, 1.0, "x", source)];
907 let mut r =
908 TranscriptionResult::openrouter("x".into(), segs, None, "m".into(), 1.0, true);
909 r.set_backend_kind(BackendKind::Asr);
910 r.set_timestamps_reliable(true);
911 assert!(
912 !r.has_approximate_timestamps(),
913 "source {source:?} should not be approximate"
914 );
915 }
916 }
917
918 #[test]
919 fn synthetic_and_unavailable_block_srt_gate() {
920 for source in [
921 crate::remote::TimestampSource::SyntheticSpan,
922 crate::remote::TimestampSource::Unavailable,
923 crate::remote::TimestampSource::Interpolated,
924 ] {
925 let segs = vec![Segment::from_parts_with_source(0.0, 1.0, "x", source)];
926 let mut r = TranscriptionResult::local("x".into(), segs, None, "m".into(), 1.0);
927 r.set_timestamps_reliable(true);
928 assert!(
929 r.has_approximate_timestamps(),
930 "source {source:?} must be approximate"
931 );
932 }
933 }
934
935 #[test]
936 fn warnings_merge_into_stt_dto() {
937 let mut r = TranscriptionResult::local(
938 "hi".into(),
939 vec![Segment::from_parts_with_source(
940 0.0,
941 1.0,
942 "hi",
943 crate::remote::TimestampSource::NativeModel,
944 )],
945 None,
946 "base".into(),
947 1.0,
948 );
949 r.push_warning("segment overlap not confidently deduped; retained later segments");
950 let dto = crate::dto::SttResultDto::from_result(&r);
951 assert_eq!(dto.normalization_warnings.len(), 1);
952 assert!(dto.segments[0].timestamp_source() == crate::remote::TimestampSource::NativeModel);
953 let json = dto.to_json_pretty().unwrap();
954 assert!(json.contains("native_model"));
955 }
956}