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/// Prefer [`Segment::try_new`] for validated construction. `Deserialize` is an
55/// **untrusted DTO path** — call [`Segment::validate`] before trusting timings.
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
57pub struct Segment {
58    /// Start time in seconds.
59    pub start: f64,
60    /// End time in seconds.
61    pub end: f64,
62    pub text: String,
63}
64
65impl Segment {
66    /// Construct a segment, rejecting NaN/Inf, negatives, and inverted ranges (JOE-1781).
67    pub fn try_new(start: f64, end: f64, text: impl Into<String>) -> Result<Self> {
68        let s = Self {
69            start,
70            end,
71            text: text.into(),
72        };
73        s.validate()?;
74        Ok(s)
75    }
76
77    /// Validate timestamp finite-ness and ordering.
78    pub fn validate(&self) -> Result<()> {
79        if !self.start.is_finite() || !self.end.is_finite() {
80            return Err(crate::error::UserError::Other {
81                message: format!(
82                    "segment timestamps must be finite (start={}, end={})",
83                    self.start, self.end
84                ),
85            }
86            .into());
87        }
88        if self.start < 0.0 || self.end < 0.0 {
89            return Err(crate::error::UserError::Other {
90                message: format!(
91                    "segment timestamps must be non-negative (start={}, end={})",
92                    self.start, self.end
93                ),
94            }
95            .into());
96        }
97        if self.end < self.start {
98            return Err(crate::error::UserError::Other {
99                message: format!(
100                    "segment end before start (start={}, end={})",
101                    self.start, self.end
102                ),
103            }
104            .into());
105        }
106        Ok(())
107    }
108}
109
110/// Normalized result returned by every provider.
111///
112/// Prefer builders [`TranscriptionResult::local`] / [`TranscriptionResult::openrouter`].
113/// `Deserialize` is untrusted — validate segments before relying on timings.
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct TranscriptionResult {
116    pub text: String,
117    pub segments: Vec<Segment>,
118    pub language: Option<String>,
119    pub model: String,
120    pub provider: String,
121    pub duration_secs: f64,
122    /// Backend class — consumers should treat LLM timestamps as best-effort.
123    #[serde(default = "default_backend_kind")]
124    pub backend_kind: BackendKind,
125    /// Whether segment timestamps are considered reliable.
126    #[serde(default = "default_true")]
127    pub timestamps_reliable: bool,
128    /// Post-ASR cleanup style applied to [`Self::text`] (default: raw).
129    #[serde(default)]
130    pub cleanup_style: crate::cleanup::CleanupStyle,
131    /// Cleanup backend used, if any cleanup beyond raw was applied.
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub cleanup_provider: Option<crate::cleanup::CleanupProviderKind>,
134    /// Pre-cleanup ASR text when cleanup rewrote [`Self::text`].
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub original_text: Option<String>,
137    /// Pre-cleanup ASR segments when cleanup rewrote or cleared timings.
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub original_segments: Option<Vec<Segment>>,
140    /// Segment policy that was applied during cleanup (when not raw).
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub cleanup_segment_policy: Option<crate::cleanup::SegmentCleanupPolicy>,
143}
144
145fn default_backend_kind() -> BackendKind {
146    BackendKind::Asr
147}
148fn default_true() -> bool {
149    true
150}
151
152impl TranscriptionResult {
153    /// Validate all segments (finite, ordered timestamps).
154    pub fn validate_segments(&self) -> Result<()> {
155        for (i, seg) in self.segments.iter().enumerate() {
156            if let Err(e) = seg.validate() {
157                return Err(crate::error::UserError::Other {
158                    message: format!("segment[{i}]: {e}"),
159                }
160                .into());
161            }
162        }
163        if !self.duration_secs.is_finite() || self.duration_secs < 0.0 {
164            return Err(crate::error::UserError::Other {
165                message: format!(
166                    "duration_secs must be finite and non-negative (got {})",
167                    self.duration_secs
168                ),
169            }
170            .into());
171        }
172        Ok(())
173    }
174
175    pub fn local(
176        text: String,
177        segments: Vec<Segment>,
178        language: Option<String>,
179        model: String,
180        duration_secs: f64,
181    ) -> Self {
182        Self {
183            text,
184            segments,
185            language,
186            model,
187            provider: "local".into(),
188            duration_secs,
189            backend_kind: BackendKind::Asr,
190            timestamps_reliable: true,
191            cleanup_style: crate::cleanup::CleanupStyle::Raw,
192            cleanup_provider: None,
193            original_text: None,
194            original_segments: None,
195            cleanup_segment_policy: None,
196        }
197    }
198
199    pub fn openrouter(
200        text: String,
201        segments: Vec<Segment>,
202        language: Option<String>,
203        model: String,
204        duration_secs: f64,
205        _timestamps_requested: bool,
206    ) -> Self {
207        Self {
208            text,
209            segments,
210            language,
211            model,
212            provider: "openrouter".into(),
213            duration_secs,
214            backend_kind: BackendKind::LlmAssisted,
215            // LLM timestamps are never treated as reliable ASR timing.
216            timestamps_reliable: false,
217            cleanup_style: crate::cleanup::CleanupStyle::Raw,
218            cleanup_provider: None,
219            original_text: None,
220            original_segments: None,
221            cleanup_segment_policy: None,
222        }
223    }
224}
225
226/// Provider trait — the foundation for local and remote backends.
227#[async_trait]
228pub trait TranscriptionProvider: Send + Sync {
229    /// Human-readable provider name (e.g. `"local"`, `"openrouter"`).
230    fn name(&self) -> &'static str;
231
232    /// Backend classification.
233    fn backend_kind(&self) -> BackendKind;
234
235    /// Whether this provider can emit trustworthy media timestamps.
236    fn timestamps_reliable(&self) -> bool {
237        matches!(self.backend_kind(), BackendKind::Asr)
238    }
239
240    /// Transcribe audio according to `options`.
241    async fn transcribe(
242        &self,
243        input: &AudioInput,
244        options: &TranscriptionOptions,
245    ) -> Result<TranscriptionResult>;
246}
247
248pub use local::LocalWhisperProvider;
249pub use openrouter::{OpenRouterProvider, OpenRouterSttMode, SttPath};
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn segment_try_new_accepts_valid() {
257        let s = Segment::try_new(0.0, 1.5, "hello").unwrap();
258        assert_eq!(s.start, 0.0);
259        assert_eq!(s.end, 1.5);
260        assert_eq!(s.text, "hello");
261    }
262
263    #[test]
264    fn segment_try_new_rejects_nan() {
265        assert!(Segment::try_new(f64::NAN, 1.0, "x").is_err());
266        assert!(Segment::try_new(0.0, f64::INFINITY, "x").is_err());
267    }
268
269    #[test]
270    fn segment_try_new_rejects_negative_and_inverted() {
271        assert!(Segment::try_new(-0.1, 1.0, "x").is_err());
272        assert!(Segment::try_new(2.0, 1.0, "x").is_err());
273    }
274
275    #[test]
276    fn segment_validate_ok_on_zero_length() {
277        // Zero-duration is allowed (start == end).
278        Segment::try_new(1.0, 1.0, "").unwrap();
279    }
280}