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}
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/// A single timed segment of transcript text.
53///
54/// Fields are **private** (JOE-1786). Construct with [`Segment::try_new`] (fail closed)
55/// or deserialize then [`Segment::validate`]. Prefer accessors over free mutation.
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
57pub struct Segment {
58    /// Start time in seconds.
59    start: f64,
60    /// End time in seconds.
61    end: f64,
62    text: String,
63    /// How timing was obtained (JOE-2219). Defaults to unavailable when absent.
64    #[serde(default)]
65    timestamp_source: crate::remote::TimestampSource,
66}
67
68impl Segment {
69    /// Construct a segment, rejecting NaN/Inf, negatives, and inverted ranges (JOE-1781).
70    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    /// Construct without validation (trusted provider/postprocess paths and tests).
82    ///
83    /// Prefer [`Segment::try_new`] for host-facing construction. Callers that
84    /// skip validation must treat the segment as untrusted until [`Segment::validate`].
85    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    /// Unchecked construct with explicit provenance (JOE-2219).
95    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    /// Validate timestamp finite-ness and ordering.
142    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/// Normalized result returned by every provider.
175///
176/// Fields are **private** (JOE-1809). Prefer builders
177/// [`TranscriptionResult::local`] / [`TranscriptionResult::openrouter`] and
178/// accessors. `Deserialize` is untrusted — use [`TranscriptionResult::try_from_dto`]
179/// or [`TranscriptionResult::validate_segments`] before relying on timings.
180#[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    /// Backend class — consumers should treat LLM timestamps as best-effort.
189    #[serde(default = "default_backend_kind")]
190    backend_kind: BackendKind,
191    /// Whether segment timestamps are considered reliable.
192    #[serde(default = "default_true")]
193    timestamps_reliable: bool,
194    /// Post-ASR cleanup style applied to [`Self::text`] (default: raw).
195    #[serde(default)]
196    cleanup_style: crate::cleanup::CleanupStyle,
197    /// Cleanup backend used, if any cleanup beyond raw was applied.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    cleanup_provider: Option<crate::cleanup::CleanupProviderKind>,
200    /// Pre-cleanup ASR text when cleanup rewrote [`Self::text`].
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    original_text: Option<String>,
203    /// Pre-cleanup ASR segments when cleanup rewrote or cleared timings.
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    original_segments: Option<Vec<Segment>>,
206    /// Segment policy that was applied during cleanup (when not raw).
207    #[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    /// True when any segment uses approximate/non-native timing (JOE-2219).
288    pub fn has_approximate_timestamps(&self) -> bool {
289        self.segments
290            .iter()
291            .any(|s| s.timestamp_source().is_approximate())
292    }
293
294    /// Collect segment provenance sources (JOE-2219).
295    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    /// Validate all segments (finite, ordered timestamps).
343    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    /// Build a domain result from a public DTO **with validation** (JOE-1809).
365    ///
366    /// Deserializing JSON into [`crate::dto::SttResultDto`] alone does not create
367    /// a trusted domain object — this path re-validates every segment and duration.
368    pub fn try_from_dto(dto: &crate::dto::SttResultDto) -> Result<Self> {
369        // Accept v1 (pre-provenance) and current v2 (JOE-2219).
370        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        // LLM-assisted paths cannot claim reliable timestamps through DTO injection.
396        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    /// Like [`Self::local`] but fail-closed when segments/duration are invalid (JOE-1781).
438    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            // LLM timestamps are never treated as reliable ASR timing.
467            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    /// Like [`Self::openrouter`] but fail-closed on invalid segments/duration (JOE-1781).
477    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/// Provider trait — the foundation for local and remote backends.
499#[async_trait]
500pub trait TranscriptionProvider: Send + Sync {
501    /// Human-readable provider name (e.g. `"local"`, `"openrouter"`).
502    fn name(&self) -> &'static str;
503
504    /// Backend classification.
505    fn backend_kind(&self) -> BackendKind;
506
507    /// Whether this provider can emit trustworthy media timestamps.
508    fn timestamps_reliable(&self) -> bool {
509        matches!(self.backend_kind(), BackendKind::Asr)
510    }
511
512    /// Transcribe audio according to `options`.
513    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/// Fail-closed: every product default model id must resolve in its reviewed registry.
568///
569/// Prevents shipping a dead default without an explicit demotion/replace PR (JOE-2213).
570#[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/// Default TTS model id when the operator selects a provider without `--model`.
609///
610/// Local uses the on-device catalogue default. Remote providers use their reviewed
611/// registry default so CLI/config local models (e.g. `kitten-nano-int8`) are never
612/// sent to OpenRouter/OpenAI/etc.
613#[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/// Default TTS voice for a provider when `--voice` is omitted.
626///
627/// ElevenLabs has no universal default voice id (account-specific); returns `None`.
628#[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/// Whether `model` is a reviewed id for the given TTS provider (fail closed).
641#[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/// Resolve effective TTS model: explicit CLI wins; otherwise config if valid for
654/// the selected provider; otherwise the provider registry default.
655#[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/// Resolve effective TTS voice: explicit CLI wins; then config if non-empty for
680/// local; otherwise provider default when available.
681#[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        // Zero-duration is allowed (start == end).
749        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; // injection attempt
799        let r = TranscriptionResult::try_from_dto(&dto).unwrap();
800        assert!(!r.timestamps_reliable());
801        assert_eq!(r.backend_kind(), BackendKind::LlmAssisted);
802    }
803}