Skip to main content

aurum_core/providers/
mod.rs

1//! Transcription provider abstraction.
2
3pub 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/// How a backend produces transcripts — affects timestamp trust and UX.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum BackendKind {
15    /// Dedicated ASR (e.g. whisper.cpp). Timestamps are engine-derived.
16    Asr,
17    /// Multimodal LLM asked to transcribe. Text may paraphrase; timestamps are unreliable.
18    LlmAssisted,
19}
20
21/// Options controlling a single transcription request.
22#[derive(Debug, Clone)]
23pub struct TranscriptionOptions {
24    /// Model name (local ggml name or remote model id).
25    pub model: String,
26    /// BCP-47 / ISO language code, or `"auto"`.
27    pub language: String,
28    /// Request segment-level timestamps when the provider supports them.
29    pub timestamps: bool,
30    /// Optional cooperative cancel flag (honoured by local whisper decode).
31    pub cancel: Option<crate::cancel::CancelFlag>,
32    /// Full operation context when supplied by the engine (deadline / progress /
33    /// request id). Providers must prefer this over synthesizing a fresh context
34    /// from [`Self::cancel`] alone so one parent deadline is shared across
35    /// long-form chunks (v0.0.23 A).
36    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    /// Resolve the operation context for this request.
64    ///
65    /// Prefer the engine-supplied [`Self::op`] (shared cancel + absolute deadline
66    /// + progress). Fall back to a fresh context from [`Self::cancel`].
67    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/// A single timed segment of transcript text.
104///
105/// Fields are **private** (JOE-1786). Construct with [`Segment::try_new`] (fail closed)
106/// or deserialize then [`Segment::validate`]. Prefer accessors over free mutation.
107#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
108pub struct Segment {
109    /// Start time in seconds.
110    start: f64,
111    /// End time in seconds.
112    end: f64,
113    text: String,
114    /// How timing was obtained (JOE-2219). Defaults to unavailable when absent.
115    #[serde(default)]
116    timestamp_source: crate::remote::TimestampSource,
117}
118
119impl Segment {
120    /// Construct a segment, rejecting NaN/Inf, negatives, and inverted ranges (JOE-1781).
121    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    /// Construct without validation (trusted provider/postprocess paths and tests).
133    ///
134    /// Prefer [`Segment::try_new`] for host-facing construction. Callers that
135    /// skip validation must treat the segment as untrusted until [`Segment::validate`].
136    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    /// Unchecked construct with explicit provenance (JOE-2219).
146    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    /// Validate timestamp finite-ness and ordering.
193    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/// Normalized result returned by every provider.
226///
227/// Fields are **private** (JOE-1809). Prefer builders
228/// [`TranscriptionResult::local`] / [`TranscriptionResult::openrouter`] and
229/// accessors. `Deserialize` is untrusted — use [`TranscriptionResult::try_from_dto`]
230/// or [`TranscriptionResult::validate_segments`] before relying on timings.
231#[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    /// Backend class — consumers should treat LLM timestamps as best-effort.
240    #[serde(default = "default_backend_kind")]
241    backend_kind: BackendKind,
242    /// Whether segment timestamps are considered reliable.
243    #[serde(default = "default_true")]
244    timestamps_reliable: bool,
245    /// Post-ASR cleanup style applied to [`Self::text`] (default: raw).
246    #[serde(default)]
247    cleanup_style: crate::cleanup::CleanupStyle,
248    /// Cleanup backend used, if any cleanup beyond raw was applied.
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    cleanup_provider: Option<crate::cleanup::CleanupProviderKind>,
251    /// Pre-cleanup ASR text when cleanup rewrote [`Self::text`].
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    original_text: Option<String>,
254    /// Pre-cleanup ASR segments when cleanup rewrote or cleared timings.
255    #[serde(default, skip_serializing_if = "Option::is_none")]
256    original_segments: Option<Vec<Segment>>,
257    /// Segment policy that was applied during cleanup (when not raw).
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    cleanup_segment_policy: Option<crate::cleanup::SegmentCleanupPolicy>,
260    /// Non-fatal operation warnings (e.g. long-form overlap low confidence).
261    #[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    /// Non-fatal warnings collected during the operation (stitch, overlap, …).
342    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    /// True when any segment uses approximate/non-native timing (JOE-2219).
355    pub fn has_approximate_timestamps(&self) -> bool {
356        self.segments
357            .iter()
358            .any(|s| s.timestamp_source().is_approximate())
359    }
360
361    /// Collect segment provenance sources (JOE-2219).
362    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    /// Validate all segments (finite, ordered timestamps).
410    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    /// Build a domain result from a public DTO **with validation** (JOE-1809).
432    ///
433    /// Deserializing JSON into [`crate::dto::SttResultDto`] alone does not create
434    /// a trusted domain object — this path re-validates every segment and duration.
435    pub fn try_from_dto(dto: &crate::dto::SttResultDto) -> Result<Self> {
436        // Accept v1 (pre-provenance) and current v2 (JOE-2219).
437        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        // LLM-assisted paths cannot claim reliable timestamps through DTO injection.
464        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    /// Like [`Self::local`] but fail-closed when segments/duration are invalid (JOE-1781).
507    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            // LLM timestamps are never treated as reliable ASR timing.
536            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    /// Like [`Self::openrouter`] but fail-closed on invalid segments/duration (JOE-1781).
547    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/// Provider trait — the foundation for local and remote backends.
569#[async_trait]
570pub trait TranscriptionProvider: Send + Sync {
571    /// Human-readable provider name (e.g. `"local"`, `"openrouter"`).
572    fn name(&self) -> &'static str;
573
574    /// Backend classification.
575    fn backend_kind(&self) -> BackendKind;
576
577    /// Whether this provider can emit trustworthy media timestamps.
578    fn timestamps_reliable(&self) -> bool {
579        matches!(self.backend_kind(), BackendKind::Asr)
580    }
581
582    /// Transcribe audio according to `options`.
583    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/// Fail-closed: every product default model id must resolve in its reviewed registry.
638///
639/// Prevents shipping a dead default without an explicit demotion/replace PR (JOE-2213).
640#[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/// Default TTS model id when the operator selects a provider without `--model`.
679///
680/// Local uses the on-device catalogue default. Remote providers use their reviewed
681/// registry default so CLI/config local models (e.g. `kitten-nano-int8`) are never
682/// sent to OpenRouter/OpenAI/etc.
683#[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/// Default TTS voice for a provider when `--voice` is omitted.
696///
697/// ElevenLabs has no universal default voice id (account-specific); returns `None`.
698#[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/// Whether `model` is a reviewed id for the given TTS provider (fail closed).
711#[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/// Resolve effective TTS model: explicit CLI wins; otherwise config if valid for
724/// the selected provider; otherwise the provider registry default.
725#[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/// Resolve effective TTS voice: explicit CLI wins; then config if non-empty for
750/// local; otherwise provider default when available.
751#[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        // Zero-duration is allowed (start == end).
819        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; // injection attempt
869        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        // CLI SRT gate condition: both must pass.
896        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}