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)]
57pub struct Segment {
58 pub start: f64,
60 pub end: f64,
62 pub text: String,
63}
64
65impl Segment {
66 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 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#[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 #[serde(default = "default_backend_kind")]
124 pub backend_kind: BackendKind,
125 #[serde(default = "default_true")]
127 pub timestamps_reliable: bool,
128 #[serde(default)]
130 pub cleanup_style: crate::cleanup::CleanupStyle,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub cleanup_provider: Option<crate::cleanup::CleanupProviderKind>,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub original_text: Option<String>,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub original_segments: Option<Vec<Segment>>,
140 #[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 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 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 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 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#[async_trait]
262pub trait TranscriptionProvider: Send + Sync {
263 fn name(&self) -> &'static str;
265
266 fn backend_kind(&self) -> BackendKind;
268
269 fn timestamps_reliable(&self) -> bool {
271 matches!(self.backend_kind(), BackendKind::Asr)
272 }
273
274 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 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}