1pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum BackendKind {
15 Asr,
17 LlmAssisted,
19}
20
21#[derive(Debug, Clone)]
23pub struct TranscriptionOptions {
24 pub model: String,
26 pub language: String,
28 pub timestamps: bool,
30 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
59pub struct Segment {
60 pub start: f64,
62 pub end: f64,
64 pub text: String,
65}
66
67impl Segment {
68 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 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#[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 #[serde(default = "default_backend_kind")]
126 pub backend_kind: BackendKind,
127 #[serde(default = "default_true")]
129 pub timestamps_reliable: bool,
130 #[serde(default)]
132 pub cleanup_style: crate::cleanup::CleanupStyle,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub cleanup_provider: Option<crate::cleanup::CleanupProviderKind>,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub original_text: Option<String>,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub original_segments: Option<Vec<Segment>>,
142 #[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 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 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 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 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#[async_trait]
264pub trait TranscriptionProvider: Send + Sync {
265 fn name(&self) -> &'static str;
267
268 fn backend_kind(&self) -> BackendKind;
270
271 fn timestamps_reliable(&self) -> bool {
273 matches!(self.backend_kind(), BackendKind::Asr)
274 }
275
276 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 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}