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}
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    /// Construct without validation (trusted provider/postprocess paths and tests).
78    ///
79    /// Prefer [`Segment::try_new`] for host-facing construction. Callers that
80    /// skip validation must treat the segment as untrusted until [`Segment::validate`].
81    pub fn from_parts_unchecked(start: f64, end: f64, text: impl Into<String>) -> Self {
82        Self {
83            start,
84            end,
85            text: text.into(),
86        }
87    }
88
89    pub fn start(&self) -> f64 {
90        self.start
91    }
92
93    pub fn end(&self) -> f64 {
94        self.end
95    }
96
97    pub fn text(&self) -> &str {
98        &self.text
99    }
100
101    pub fn set_start(&mut self, start: f64) {
102        self.start = start;
103    }
104
105    pub fn set_end(&mut self, end: f64) {
106        self.end = end;
107    }
108
109    pub fn set_text(&mut self, text: impl Into<String>) {
110        self.text = text.into();
111    }
112
113    /// Validate timestamp finite-ness and ordering.
114    pub fn validate(&self) -> Result<()> {
115        if !self.start.is_finite() || !self.end.is_finite() {
116            return Err(crate::error::UserError::Other {
117                message: format!(
118                    "segment timestamps must be finite (start={}, end={})",
119                    self.start, self.end
120                ),
121            }
122            .into());
123        }
124        if self.start < 0.0 || self.end < 0.0 {
125            return Err(crate::error::UserError::Other {
126                message: format!(
127                    "segment timestamps must be non-negative (start={}, end={})",
128                    self.start, self.end
129                ),
130            }
131            .into());
132        }
133        if self.end < self.start {
134            return Err(crate::error::UserError::Other {
135                message: format!(
136                    "segment end before start (start={}, end={})",
137                    self.start, self.end
138                ),
139            }
140            .into());
141        }
142        Ok(())
143    }
144}
145
146/// Normalized result returned by every provider.
147///
148/// Prefer builders [`TranscriptionResult::local`] / [`TranscriptionResult::openrouter`].
149/// `Deserialize` is untrusted — validate segments before relying on timings.
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct TranscriptionResult {
152    pub text: String,
153    pub segments: Vec<Segment>,
154    pub language: Option<String>,
155    pub model: String,
156    pub provider: String,
157    pub duration_secs: f64,
158    /// Backend class — consumers should treat LLM timestamps as best-effort.
159    #[serde(default = "default_backend_kind")]
160    pub backend_kind: BackendKind,
161    /// Whether segment timestamps are considered reliable.
162    #[serde(default = "default_true")]
163    pub timestamps_reliable: bool,
164    /// Post-ASR cleanup style applied to [`Self::text`] (default: raw).
165    #[serde(default)]
166    pub cleanup_style: crate::cleanup::CleanupStyle,
167    /// Cleanup backend used, if any cleanup beyond raw was applied.
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub cleanup_provider: Option<crate::cleanup::CleanupProviderKind>,
170    /// Pre-cleanup ASR text when cleanup rewrote [`Self::text`].
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub original_text: Option<String>,
173    /// Pre-cleanup ASR segments when cleanup rewrote or cleared timings.
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub original_segments: Option<Vec<Segment>>,
176    /// Segment policy that was applied during cleanup (when not raw).
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub cleanup_segment_policy: Option<crate::cleanup::SegmentCleanupPolicy>,
179}
180
181fn default_backend_kind() -> BackendKind {
182    BackendKind::Asr
183}
184fn default_true() -> bool {
185    true
186}
187
188impl TranscriptionResult {
189    /// Validate all segments (finite, ordered timestamps).
190    pub fn validate_segments(&self) -> Result<()> {
191        for (i, seg) in self.segments.iter().enumerate() {
192            if let Err(e) = seg.validate() {
193                return Err(crate::error::UserError::Other {
194                    message: format!("segment[{i}]: {e}"),
195                }
196                .into());
197            }
198        }
199        if !self.duration_secs.is_finite() || self.duration_secs < 0.0 {
200            return Err(crate::error::UserError::Other {
201                message: format!(
202                    "duration_secs must be finite and non-negative (got {})",
203                    self.duration_secs
204                ),
205            }
206            .into());
207        }
208        Ok(())
209    }
210
211    pub fn local(
212        text: String,
213        segments: Vec<Segment>,
214        language: Option<String>,
215        model: String,
216        duration_secs: f64,
217    ) -> Self {
218        Self {
219            text,
220            segments,
221            language,
222            model,
223            provider: "local".into(),
224            duration_secs,
225            backend_kind: BackendKind::Asr,
226            timestamps_reliable: true,
227            cleanup_style: crate::cleanup::CleanupStyle::Raw,
228            cleanup_provider: None,
229            original_text: None,
230            original_segments: None,
231            cleanup_segment_policy: None,
232        }
233    }
234
235    /// Like [`Self::local`] but fail-closed when segments/duration are invalid (JOE-1781).
236    pub fn try_local(
237        text: String,
238        segments: Vec<Segment>,
239        language: Option<String>,
240        model: String,
241        duration_secs: f64,
242    ) -> Result<Self> {
243        let r = Self::local(text, segments, language, model, duration_secs);
244        r.validate_segments()?;
245        Ok(r)
246    }
247
248    pub fn openrouter(
249        text: String,
250        segments: Vec<Segment>,
251        language: Option<String>,
252        model: String,
253        duration_secs: f64,
254        _timestamps_requested: bool,
255    ) -> Self {
256        Self {
257            text,
258            segments,
259            language,
260            model,
261            provider: "openrouter".into(),
262            duration_secs,
263            backend_kind: BackendKind::LlmAssisted,
264            // LLM timestamps are never treated as reliable ASR timing.
265            timestamps_reliable: false,
266            cleanup_style: crate::cleanup::CleanupStyle::Raw,
267            cleanup_provider: None,
268            original_text: None,
269            original_segments: None,
270            cleanup_segment_policy: None,
271        }
272    }
273
274    /// Like [`Self::openrouter`] but fail-closed on invalid segments/duration (JOE-1781).
275    pub fn try_openrouter(
276        text: String,
277        segments: Vec<Segment>,
278        language: Option<String>,
279        model: String,
280        duration_secs: f64,
281        timestamps_requested: bool,
282    ) -> Result<Self> {
283        let r = Self::openrouter(
284            text,
285            segments,
286            language,
287            model,
288            duration_secs,
289            timestamps_requested,
290        );
291        r.validate_segments()?;
292        Ok(r)
293    }
294}
295
296/// Provider trait — the foundation for local and remote backends.
297#[async_trait]
298pub trait TranscriptionProvider: Send + Sync {
299    /// Human-readable provider name (e.g. `"local"`, `"openrouter"`).
300    fn name(&self) -> &'static str;
301
302    /// Backend classification.
303    fn backend_kind(&self) -> BackendKind;
304
305    /// Whether this provider can emit trustworthy media timestamps.
306    fn timestamps_reliable(&self) -> bool {
307        matches!(self.backend_kind(), BackendKind::Asr)
308    }
309
310    /// Transcribe audio according to `options`.
311    async fn transcribe(
312        &self,
313        input: &AudioInput,
314        options: &TranscriptionOptions,
315    ) -> Result<TranscriptionResult>;
316}
317
318pub use local::LocalWhisperProvider;
319pub use openrouter::{OpenRouterProvider, OpenRouterSttMode, SttPath};
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    #[test]
326    fn segment_try_new_accepts_valid() {
327        let s = Segment::try_new(0.0, 1.5, "hello").unwrap();
328        assert_eq!(s.start, 0.0);
329        assert_eq!(s.end, 1.5);
330        assert_eq!(s.text, "hello");
331    }
332
333    #[test]
334    fn segment_try_new_rejects_nan() {
335        assert!(Segment::try_new(f64::NAN, 1.0, "x").is_err());
336        assert!(Segment::try_new(0.0, f64::INFINITY, "x").is_err());
337    }
338
339    #[test]
340    fn segment_try_new_rejects_negative_and_inverted() {
341        assert!(Segment::try_new(-0.1, 1.0, "x").is_err());
342        assert!(Segment::try_new(2.0, 1.0, "x").is_err());
343    }
344
345    #[test]
346    fn segment_validate_ok_on_zero_length() {
347        // Zero-duration is allowed (start == end).
348        Segment::try_new(1.0, 1.0, "").unwrap();
349    }
350
351    #[test]
352    fn try_local_rejects_nan_segment() {
353        let segs = vec![Segment::from_parts_unchecked(
354            f64::NAN,
355            1.0,
356            "x".to_string(),
357        )];
358        assert!(TranscriptionResult::try_local("x".into(), segs, None, "m".into(), 1.0).is_err());
359    }
360
361    #[test]
362    fn try_local_accepts_valid() {
363        let segs = vec![Segment::try_new(0.0, 0.5, "hi").unwrap()];
364        let r =
365            TranscriptionResult::try_local("hi".into(), segs, Some("en".into()), "m".into(), 1.0)
366                .unwrap();
367        assert_eq!(r.provider, "local");
368    }
369}