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 {
start: f64,
end: f64,
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 from_parts_unchecked(start: f64, end: f64, text: impl Into<String>) -> Self {
Self {
start,
end,
text: text.into(),
}
}
pub fn start(&self) -> f64 {
self.start
}
pub fn end(&self) -> f64 {
self.end
}
pub fn text(&self) -> &str {
&self.text
}
pub fn set_start(&mut self, start: f64) {
self.start = start;
}
pub fn set_end(&mut self, end: f64) {
self.end = end;
}
pub fn set_text(&mut self, text: impl Into<String>) {
self.text = text.into();
}
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 {
text: String,
segments: Vec<Segment>,
language: Option<String>,
model: String,
provider: String,
duration_secs: f64,
#[serde(default = "default_backend_kind")]
backend_kind: BackendKind,
#[serde(default = "default_true")]
timestamps_reliable: bool,
#[serde(default)]
cleanup_style: crate::cleanup::CleanupStyle,
#[serde(default, skip_serializing_if = "Option::is_none")]
cleanup_provider: Option<crate::cleanup::CleanupProviderKind>,
#[serde(default, skip_serializing_if = "Option::is_none")]
original_text: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
original_segments: Option<Vec<Segment>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
cleanup_segment_policy: Option<crate::cleanup::SegmentCleanupPolicy>,
}
fn default_backend_kind() -> BackendKind {
BackendKind::Asr
}
fn default_true() -> bool {
true
}
impl TranscriptionResult {
pub fn text(&self) -> &str {
&self.text
}
pub fn set_text(&mut self, text: impl Into<String>) {
self.text = text.into();
}
pub fn segments(&self) -> &[Segment] {
&self.segments
}
pub fn segments_mut(&mut self) -> &mut Vec<Segment> {
&mut self.segments
}
pub fn set_segments(&mut self, segments: Vec<Segment>) {
self.segments = segments;
}
pub fn language(&self) -> Option<&str> {
self.language.as_deref()
}
pub fn set_language(&mut self, language: Option<String>) {
self.language = language;
}
pub fn model(&self) -> &str {
&self.model
}
pub fn set_model(&mut self, model: impl Into<String>) {
self.model = model.into();
}
pub fn provider(&self) -> &str {
&self.provider
}
pub fn set_provider(&mut self, provider: impl Into<String>) {
self.provider = provider.into();
}
pub fn duration_secs(&self) -> f64 {
self.duration_secs
}
pub fn set_duration_secs(&mut self, duration_secs: f64) {
self.duration_secs = duration_secs;
}
pub fn backend_kind(&self) -> BackendKind {
self.backend_kind
}
pub fn set_backend_kind(&mut self, kind: BackendKind) {
self.backend_kind = kind;
}
pub fn timestamps_reliable(&self) -> bool {
self.timestamps_reliable
}
pub fn set_timestamps_reliable(&mut self, reliable: bool) {
self.timestamps_reliable = reliable;
}
pub fn cleanup_style(&self) -> crate::cleanup::CleanupStyle {
self.cleanup_style
}
pub fn set_cleanup_style(&mut self, style: crate::cleanup::CleanupStyle) {
self.cleanup_style = style;
}
pub fn cleanup_provider(&self) -> Option<crate::cleanup::CleanupProviderKind> {
self.cleanup_provider
}
pub fn set_cleanup_provider(&mut self, provider: Option<crate::cleanup::CleanupProviderKind>) {
self.cleanup_provider = provider;
}
pub fn original_text(&self) -> Option<&str> {
self.original_text.as_deref()
}
pub fn set_original_text(&mut self, text: Option<String>) {
self.original_text = text;
}
pub fn original_segments(&self) -> Option<&[Segment]> {
self.original_segments.as_deref()
}
pub fn set_original_segments(&mut self, segments: Option<Vec<Segment>>) {
self.original_segments = segments;
}
pub fn cleanup_segment_policy(&self) -> Option<crate::cleanup::SegmentCleanupPolicy> {
self.cleanup_segment_policy
}
pub fn set_cleanup_segment_policy(
&mut self,
policy: Option<crate::cleanup::SegmentCleanupPolicy>,
) {
self.cleanup_segment_policy = policy;
}
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 try_from_dto(dto: &crate::dto::SttResultDto) -> Result<Self> {
if dto.schema_version != crate::dto::STT_RESULT_SCHEMA_VERSION {
return Err(crate::error::UserError::Other {
message: format!(
"unsupported STT DTO schema_version {} (expected {})",
dto.schema_version,
crate::dto::STT_RESULT_SCHEMA_VERSION
),
}
.into());
}
let mut r = Self {
text: dto.text.clone(),
segments: dto.segments.clone(),
language: dto.language.clone(),
model: dto.model.clone(),
provider: dto.provider.clone(),
duration_secs: dto.duration_secs,
backend_kind: dto.backend_kind,
timestamps_reliable: dto.timestamps_reliable,
cleanup_style: dto.cleanup_style,
cleanup_provider: dto.cleanup_provider,
original_text: dto.original_text.clone(),
original_segments: dto.original_segments.clone(),
cleanup_segment_policy: dto.cleanup_segment_policy,
};
if matches!(r.backend_kind, BackendKind::LlmAssisted) {
r.timestamps_reliable = false;
}
r.validate_segments()?;
if let Some(ref segs) = r.original_segments {
for (i, seg) in segs.iter().enumerate() {
if let Err(e) = seg.validate() {
return Err(crate::error::UserError::Other {
message: format!("original_segments[{i}]: {e}"),
}
.into());
}
}
}
Ok(r)
}
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::from_parts_unchecked(
f64::NAN,
1.0,
"x".to_string(),
)];
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");
}
#[test]
fn try_from_dto_rejects_nan_segment() {
let mut dto = crate::dto::SttResultDto::from_result(&TranscriptionResult::local(
"x".into(),
vec![Segment::try_new(0.0, 1.0, "x").unwrap()],
None,
"m".into(),
1.0,
));
dto.segments = vec![Segment::from_parts_unchecked(
f64::NAN,
1.0,
"x".to_string(),
)];
assert!(TranscriptionResult::try_from_dto(&dto).is_err());
}
#[test]
fn try_from_dto_forces_llm_timestamps_unreliable() {
let mut dto = crate::dto::SttResultDto::from_result(&TranscriptionResult::openrouter(
"hi".into(),
vec![Segment::try_new(0.0, 1.0, "hi").unwrap()],
None,
"m".into(),
1.0,
true,
));
dto.timestamps_reliable = true; let r = TranscriptionResult::try_from_dto(&dto).unwrap();
assert!(!r.timestamps_reliable());
assert_eq!(r.backend_kind(), BackendKind::LlmAssisted);
}
}