pub mod local;
pub mod openrouter;
use crate::audio::AudioInput;
use crate::error::Result;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BackendKind {
Asr,
LlmAssisted,
}
#[derive(Debug, Clone)]
pub struct TranscriptionOptions {
pub model: String,
pub language: String,
pub timestamps: bool,
pub cancel: Option<crate::cancel::CancelFlag>,
}
impl Default for TranscriptionOptions {
fn default() -> Self {
Self {
model: crate::config::DEFAULT_LOCAL_MODEL.to_string(),
language: crate::config::DEFAULT_LANGUAGE.to_string(),
timestamps: false,
cancel: None,
}
}
}
impl TranscriptionOptions {
pub fn with_cancel(mut self, flag: crate::cancel::CancelFlag) -> Self {
self.cancel = Some(flag);
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Segment {
pub start: f64,
pub end: f64,
pub text: String,
}
impl Segment {
pub fn try_new(start: f64, end: f64, text: impl Into<String>) -> Result<Self> {
let s = Self {
start,
end,
text: text.into(),
};
s.validate()?;
Ok(s)
}
pub fn validate(&self) -> Result<()> {
if !self.start.is_finite() || !self.end.is_finite() {
return Err(crate::error::UserError::Other {
message: format!(
"segment timestamps must be finite (start={}, end={})",
self.start, self.end
),
}
.into());
}
if self.start < 0.0 || self.end < 0.0 {
return Err(crate::error::UserError::Other {
message: format!(
"segment timestamps must be non-negative (start={}, end={})",
self.start, self.end
),
}
.into());
}
if self.end < self.start {
return Err(crate::error::UserError::Other {
message: format!(
"segment end before start (start={}, end={})",
self.start, self.end
),
}
.into());
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TranscriptionResult {
pub text: String,
pub segments: Vec<Segment>,
pub language: Option<String>,
pub model: String,
pub provider: String,
pub duration_secs: f64,
#[serde(default = "default_backend_kind")]
pub backend_kind: BackendKind,
#[serde(default = "default_true")]
pub timestamps_reliable: bool,
#[serde(default)]
pub cleanup_style: crate::cleanup::CleanupStyle,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cleanup_provider: Option<crate::cleanup::CleanupProviderKind>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub original_text: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub original_segments: Option<Vec<Segment>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cleanup_segment_policy: Option<crate::cleanup::SegmentCleanupPolicy>,
}
fn default_backend_kind() -> BackendKind {
BackendKind::Asr
}
fn default_true() -> bool {
true
}
impl TranscriptionResult {
pub fn validate_segments(&self) -> Result<()> {
for (i, seg) in self.segments.iter().enumerate() {
if let Err(e) = seg.validate() {
return Err(crate::error::UserError::Other {
message: format!("segment[{i}]: {e}"),
}
.into());
}
}
if !self.duration_secs.is_finite() || self.duration_secs < 0.0 {
return Err(crate::error::UserError::Other {
message: format!(
"duration_secs must be finite and non-negative (got {})",
self.duration_secs
),
}
.into());
}
Ok(())
}
pub fn local(
text: String,
segments: Vec<Segment>,
language: Option<String>,
model: String,
duration_secs: f64,
) -> Self {
Self {
text,
segments,
language,
model,
provider: "local".into(),
duration_secs,
backend_kind: BackendKind::Asr,
timestamps_reliable: true,
cleanup_style: crate::cleanup::CleanupStyle::Raw,
cleanup_provider: None,
original_text: None,
original_segments: None,
cleanup_segment_policy: None,
}
}
pub fn try_local(
text: String,
segments: Vec<Segment>,
language: Option<String>,
model: String,
duration_secs: f64,
) -> Result<Self> {
let r = Self::local(text, segments, language, model, duration_secs);
r.validate_segments()?;
Ok(r)
}
pub fn openrouter(
text: String,
segments: Vec<Segment>,
language: Option<String>,
model: String,
duration_secs: f64,
_timestamps_requested: bool,
) -> Self {
Self {
text,
segments,
language,
model,
provider: "openrouter".into(),
duration_secs,
backend_kind: BackendKind::LlmAssisted,
timestamps_reliable: false,
cleanup_style: crate::cleanup::CleanupStyle::Raw,
cleanup_provider: None,
original_text: None,
original_segments: None,
cleanup_segment_policy: None,
}
}
pub fn try_openrouter(
text: String,
segments: Vec<Segment>,
language: Option<String>,
model: String,
duration_secs: f64,
timestamps_requested: bool,
) -> Result<Self> {
let r = Self::openrouter(
text,
segments,
language,
model,
duration_secs,
timestamps_requested,
);
r.validate_segments()?;
Ok(r)
}
}
#[async_trait]
pub trait TranscriptionProvider: Send + Sync {
fn name(&self) -> &'static str;
fn backend_kind(&self) -> BackendKind;
fn timestamps_reliable(&self) -> bool {
matches!(self.backend_kind(), BackendKind::Asr)
}
async fn transcribe(
&self,
input: &AudioInput,
options: &TranscriptionOptions,
) -> Result<TranscriptionResult>;
}
pub use local::LocalWhisperProvider;
pub use openrouter::{OpenRouterProvider, OpenRouterSttMode, SttPath};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn segment_try_new_accepts_valid() {
let s = Segment::try_new(0.0, 1.5, "hello").unwrap();
assert_eq!(s.start, 0.0);
assert_eq!(s.end, 1.5);
assert_eq!(s.text, "hello");
}
#[test]
fn segment_try_new_rejects_nan() {
assert!(Segment::try_new(f64::NAN, 1.0, "x").is_err());
assert!(Segment::try_new(0.0, f64::INFINITY, "x").is_err());
}
#[test]
fn segment_try_new_rejects_negative_and_inverted() {
assert!(Segment::try_new(-0.1, 1.0, "x").is_err());
assert!(Segment::try_new(2.0, 1.0, "x").is_err());
}
#[test]
fn segment_validate_ok_on_zero_length() {
Segment::try_new(1.0, 1.0, "").unwrap();
}
#[test]
fn try_local_rejects_nan_segment() {
let segs = vec![Segment {
start: f64::NAN,
end: 1.0,
text: "x".into(),
}];
assert!(TranscriptionResult::try_local("x".into(), segs, None, "m".into(), 1.0).is_err());
}
#[test]
fn try_local_accepts_valid() {
let segs = vec![Segment::try_new(0.0, 0.5, "hi").unwrap()];
let r =
TranscriptionResult::try_local("hi".into(), segs, Some("en".into()), "m".into(), 1.0)
.unwrap();
assert_eq!(r.provider, "local");
}
}