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