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 start: f64,
60 end: f64,
62 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 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 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#[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 #[serde(default = "default_backend_kind")]
160 pub backend_kind: BackendKind,
161 #[serde(default = "default_true")]
163 pub timestamps_reliable: bool,
164 #[serde(default)]
166 pub cleanup_style: crate::cleanup::CleanupStyle,
167 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub cleanup_provider: Option<crate::cleanup::CleanupProviderKind>,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub original_text: Option<String>,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub original_segments: Option<Vec<Segment>>,
176 #[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 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 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 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 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#[async_trait]
298pub trait TranscriptionProvider: Send + Sync {
299 fn name(&self) -> &'static str;
301
302 fn backend_kind(&self) -> BackendKind;
304
305 fn timestamps_reliable(&self) -> bool {
307 matches!(self.backend_kind(), BackendKind::Asr)
308 }
309
310 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 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}