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    /// Like [`Self::local`] but fail-closed when segments/duration are invalid (JOE-1781).
200    pub fn try_local(
201        text: String,
202        segments: Vec<Segment>,
203        language: Option<String>,
204        model: String,
205        duration_secs: f64,
206    ) -> Result<Self> {
207        let r = Self::local(text, segments, language, model, duration_secs);
208        r.validate_segments()?;
209        Ok(r)
210    }
211
212    pub fn openrouter(
213        text: String,
214        segments: Vec<Segment>,
215        language: Option<String>,
216        model: String,
217        duration_secs: f64,
218        _timestamps_requested: bool,
219    ) -> Self {
220        Self {
221            text,
222            segments,
223            language,
224            model,
225            provider: "openrouter".into(),
226            duration_secs,
227            backend_kind: BackendKind::LlmAssisted,
228            // LLM timestamps are never treated as reliable ASR timing.
229            timestamps_reliable: false,
230            cleanup_style: crate::cleanup::CleanupStyle::Raw,
231            cleanup_provider: None,
232            original_text: None,
233            original_segments: None,
234            cleanup_segment_policy: None,
235        }
236    }
237
238    /// Like [`Self::openrouter`] but fail-closed on invalid segments/duration (JOE-1781).
239    pub fn try_openrouter(
240        text: String,
241        segments: Vec<Segment>,
242        language: Option<String>,
243        model: String,
244        duration_secs: f64,
245        timestamps_requested: bool,
246    ) -> Result<Self> {
247        let r = Self::openrouter(
248            text,
249            segments,
250            language,
251            model,
252            duration_secs,
253            timestamps_requested,
254        );
255        r.validate_segments()?;
256        Ok(r)
257    }
258}
259
260/// Provider trait — the foundation for local and remote backends.
261#[async_trait]
262pub trait TranscriptionProvider: Send + Sync {
263    /// Human-readable provider name (e.g. `"local"`, `"openrouter"`).
264    fn name(&self) -> &'static str;
265
266    /// Backend classification.
267    fn backend_kind(&self) -> BackendKind;
268
269    /// Whether this provider can emit trustworthy media timestamps.
270    fn timestamps_reliable(&self) -> bool {
271        matches!(self.backend_kind(), BackendKind::Asr)
272    }
273
274    /// Transcribe audio according to `options`.
275    async fn transcribe(
276        &self,
277        input: &AudioInput,
278        options: &TranscriptionOptions,
279    ) -> Result<TranscriptionResult>;
280}
281
282pub use local::LocalWhisperProvider;
283pub use openrouter::{OpenRouterProvider, OpenRouterSttMode, SttPath};
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[test]
290    fn segment_try_new_accepts_valid() {
291        let s = Segment::try_new(0.0, 1.5, "hello").unwrap();
292        assert_eq!(s.start, 0.0);
293        assert_eq!(s.end, 1.5);
294        assert_eq!(s.text, "hello");
295    }
296
297    #[test]
298    fn segment_try_new_rejects_nan() {
299        assert!(Segment::try_new(f64::NAN, 1.0, "x").is_err());
300        assert!(Segment::try_new(0.0, f64::INFINITY, "x").is_err());
301    }
302
303    #[test]
304    fn segment_try_new_rejects_negative_and_inverted() {
305        assert!(Segment::try_new(-0.1, 1.0, "x").is_err());
306        assert!(Segment::try_new(2.0, 1.0, "x").is_err());
307    }
308
309    #[test]
310    fn segment_validate_ok_on_zero_length() {
311        // Zero-duration is allowed (start == end).
312        Segment::try_new(1.0, 1.0, "").unwrap();
313    }
314
315    #[test]
316    fn try_local_rejects_nan_segment() {
317        let segs = vec![Segment {
318            start: f64::NAN,
319            end: 1.0,
320            text: "x".into(),
321        }];
322        assert!(TranscriptionResult::try_local("x".into(), segs, None, "m".into(), 1.0).is_err());
323    }
324
325    #[test]
326    fn try_local_accepts_valid() {
327        let segs = vec![Segment::try_new(0.0, 0.5, "hi").unwrap()];
328        let r =
329            TranscriptionResult::try_local("hi".into(), segs, Some("en".into()), "m".into(), 1.0)
330                .unwrap();
331        assert_eq!(r.provider, "local");
332    }
333}