use crate::error::GigasttError;
use crate::inference::{TranscribeResult, WordInfo};
use serde::Serialize;
use std::str::FromStr;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ExportFormat {
#[default]
Json,
Txt,
Srt,
Vtt,
Md,
}
impl FromStr for ExportFormat {
type Err = GigasttError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_ascii_lowercase().as_str() {
"json" => Ok(Self::Json),
"txt" | "text" => Ok(Self::Txt),
"srt" => Ok(Self::Srt),
"vtt" => Ok(Self::Vtt),
"md" | "markdown" => Ok(Self::Md),
_ => Err(GigasttError::InvalidInput {
message: format!("unsupported export format: {s}"),
}),
}
}
}
impl std::fmt::Display for ExportFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Json => write!(f, "json"),
Self::Txt => write!(f, "txt"),
Self::Srt => write!(f, "srt"),
Self::Vtt => write!(f, "vtt"),
Self::Md => write!(f, "md"),
}
}
}
impl ExportFormat {
pub fn content_type(&self) -> &'static str {
match self {
Self::Json => "application/json; charset=utf-8",
Self::Txt => "text/plain; charset=utf-8",
Self::Srt => "application/x-subrip; charset=utf-8",
Self::Vtt => "text/vtt; charset=utf-8",
Self::Md => "text/markdown; charset=utf-8",
}
}
pub fn extension(&self) -> &'static str {
match self {
Self::Json => "json",
Self::Txt => "txt",
Self::Srt => "srt",
Self::Vtt => "vtt",
Self::Md => "md",
}
}
pub fn render(&self, result: &TranscribeResult, opts: &RenderOpts) -> String {
match self {
Self::Json => to_json(result),
Self::Txt => to_txt(result),
Self::Srt => to_srt(
&result.words,
opts.max_chars_per_line,
opts.max_words_per_line,
),
Self::Vtt => to_vtt(
&result.words,
opts.max_chars_per_line,
opts.max_words_per_line,
),
Self::Md => to_md(result, opts.include_word_timestamps),
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct RenderOpts {
pub max_chars_per_line: usize,
pub max_words_per_line: usize,
pub include_word_timestamps: bool,
}
impl Default for RenderOpts {
fn default() -> Self {
Self {
max_chars_per_line: 80,
max_words_per_line: 14,
include_word_timestamps: false,
}
}
}
pub fn to_json(result: &TranscribeResult) -> String {
serde_json::json!({
"text": result.text,
"words": result.words,
"duration": result.duration_s,
})
.to_string()
}
pub fn to_txt(result: &TranscribeResult) -> String {
result.text.clone()
}
pub fn to_srt(words: &[WordInfo], max_chars_per_line: usize, max_words_per_line: usize) -> String {
let cues = build_cues(words, max_chars_per_line, max_words_per_line);
let mut out = String::new();
for (i, cue) in cues.iter().enumerate() {
if i > 0 {
out.push('\n');
}
out.push_str(&(i + 1).to_string());
out.push('\n');
out.push_str(&format_srt_time(cue.start));
out.push_str(" --> ");
out.push_str(&format_srt_time(cue.end));
out.push('\n');
out.push_str(&cue.text);
out.push('\n');
}
out
}
pub fn to_vtt(words: &[WordInfo], max_chars_per_line: usize, max_words_per_line: usize) -> String {
let cues = build_cues(words, max_chars_per_line, max_words_per_line);
let mut out = String::from("WEBVTT\n\n");
for cue in &cues {
out.push_str(&format_vtt_time(cue.start));
out.push_str(" --> ");
out.push_str(&format_vtt_time(cue.end));
out.push('\n');
out.push_str(&cue.text);
out.push('\n');
out.push('\n');
}
out
}
pub fn to_md(result: &TranscribeResult, include_word_timestamps: bool) -> String {
let speaker_count = result
.words
.iter()
.filter_map(|w| w.speaker)
.max()
.map(|m| m + 1)
.unwrap_or(0);
let mut out = String::new();
out.push_str("---\n");
out.push_str(&format!("duration: {}\n", result.duration_s));
out.push_str("language: ru\n");
out.push_str(&format!("speakers: {speaker_count}\n"));
out.push_str("---\n\n");
out.push_str("# Transcript\n\n");
out.push_str(&result.text);
out.push_str("\n\n");
if include_word_timestamps && !result.words.is_empty() {
out.push_str("# Word timings\n\n");
out.push_str("| Word | Start | End | Confidence | Speaker |\n");
out.push_str("|------|-------|-----|------------|---------|\n");
for w in &result.words {
let speaker = w
.speaker
.map(|s| format!("SPEAKER_{s}"))
.unwrap_or_else(|| "-".to_string());
out.push_str(&format!(
"| {} | {:.3}s | {:.3}s | {:.3} | {speaker} |\n",
w.word.replace('|', "\\|"),
w.start,
w.end,
w.confidence
));
}
}
out
}
#[derive(Clone, Debug)]
struct Cue {
start: f64,
end: f64,
text: String,
words: Vec<WordInfo>,
}
#[derive(Clone, Debug, Serialize)]
pub struct Segment {
pub start: f64,
pub end: f64,
pub text: String,
pub words: Vec<WordInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub speaker: Option<u32>,
}
const SEGMENT_PAUSE_THRESHOLD_S: f64 = 0.9;
const MAX_SEGMENT_DURATION_S: f64 = 30.0;
const SEGMENT_SENTENCE_END_PUNCTUATION: &[char] = &['.', '!', '?'];
fn segment_speaker(words: &[WordInfo]) -> Option<u32> {
let first = words.first()?.speaker?;
if words.iter().all(|w| w.speaker == Some(first)) {
Some(first)
} else {
None
}
}
fn build_cues(words: &[WordInfo], max_chars: usize, max_words: usize) -> Vec<Cue> {
if words.is_empty() {
return Vec::new();
}
let mut cues = Vec::new();
let mut current = Cue {
start: words[0].start,
end: words[0].end,
text: String::new(),
words: Vec::new(),
};
let mut current_speaker: Option<u32> = None;
let mut word_count = 0;
let flush = |cue: &mut Cue, cues: &mut Vec<Cue>| {
if !cue.text.is_empty() {
cue.text = cue.text.trim_end().to_string();
cues.push(cue.clone());
cue.text.clear();
cue.words.clear();
}
};
for word in words {
let speaker_changed = word.speaker != current_speaker;
if speaker_changed {
flush(&mut current, &mut cues);
current.start = word.start;
current_speaker = word.speaker;
word_count = 0;
if let Some(speaker) = word.speaker {
current.text.push_str(&format!("[SPEAKER_{speaker}] "));
}
}
let would_chars = if current.text.is_empty() {
word.word.len()
} else {
current.text.len() + 1 + word.word.len()
};
let would_words = word_count + 1;
let break_line = !current.text.is_empty()
&& ((max_chars > 0 && would_chars > max_chars)
|| (max_words > 0 && would_words > max_words));
if break_line {
flush(&mut current, &mut cues);
current.start = word.start;
current.end = word.end;
word_count = 0;
if let Some(speaker) = word.speaker {
current.text.push_str(&format!("[SPEAKER_{speaker}] "));
}
}
if !current.text.is_empty() && !current.text.ends_with(' ') {
current.text.push(' ');
}
current.text.push_str(&word.word);
current.end = word.end;
current.words.push(word.clone());
word_count += 1;
}
flush(&mut current, &mut cues);
cues
}
pub fn to_segments(words: &[WordInfo], max_chars: usize, max_words: usize) -> Vec<Segment> {
build_cues(words, max_chars, max_words)
.into_iter()
.map(|cue| Segment {
start: cue.start,
end: cue.end,
text: cue.text,
words: cue.words.clone(),
speaker: segment_speaker(&cue.words),
})
.collect()
}
pub fn to_transcript_segments(words: &[WordInfo]) -> Vec<Segment> {
build_segments(words)
}
fn build_segments(words: &[WordInfo]) -> Vec<Segment> {
if words.is_empty() {
return Vec::new();
}
let mut segments = Vec::new();
let mut current = Segment {
start: words[0].start,
end: words[0].end,
text: words[0].word.clone(),
words: vec![words[0].clone()],
speaker: words[0].speaker,
};
for i in 1..words.len() {
let word = &words[i];
let prev = &words[i - 1];
let pause = word.start - prev.end;
let speaker_changed = word.speaker != prev.speaker;
let prev_ended_sentence = prev
.word
.trim_end()
.ends_with(SEGMENT_SENTENCE_END_PUNCTUATION);
let would_exceed_duration = word.end - current.start > MAX_SEGMENT_DURATION_S;
if pause > SEGMENT_PAUSE_THRESHOLD_S
|| speaker_changed
|| prev_ended_sentence
|| would_exceed_duration
{
current.speaker = segment_speaker(¤t.words);
segments.push(current);
current = Segment {
start: word.start,
end: word.end,
text: word.word.clone(),
words: vec![word.clone()],
speaker: word.speaker,
};
} else {
current.text.push(' ');
current.text.push_str(&word.word);
current.end = word.end;
current.words.push(word.clone());
}
}
current.speaker = segment_speaker(¤t.words);
segments.push(current);
segments
}
pub fn to_md_segments(result: &TranscribeResult, max_chars: usize, max_words: usize) -> String {
let segments = to_segments(&result.words, max_chars, max_words);
let speaker_count = result
.words
.iter()
.filter_map(|w| w.speaker)
.max()
.map(|m| m + 1)
.unwrap_or(0);
let mut out = String::new();
out.push_str("---\n");
out.push_str(&format!("duration: {}\n", result.duration_s));
out.push_str("language: ru\n");
out.push_str(&format!("speakers: {speaker_count}\n"));
out.push_str("---\n\n");
for segment in &segments {
out.push_str(&format!(
"### [{}]\n\n",
format_timestamp_hms(segment.start)
));
out.push_str(&segment.text);
out.push_str("\n\n");
}
out
}
fn format_timestamp_hms(seconds: f64) -> String {
let total_s = seconds.max(0.0).round() as u64;
let s = total_s % 60;
let total_m = total_s / 60;
let m = total_m % 60;
let h = total_m / 60;
if h > 0 {
format!("{h:02}:{m:02}:{s:02}")
} else {
format!("{m:02}:{s:02}")
}
}
fn format_srt_time(seconds: f64) -> String {
let total_ms = (seconds.max(0.0) * 1000.0).round() as u64;
let ms = total_ms % 1000;
let total_s = total_ms / 1000;
let s = total_s % 60;
let total_m = total_s / 60;
let m = total_m % 60;
let h = total_m / 60;
format!("{h:02}:{m:02}:{s:02},{ms:03}")
}
fn format_vtt_time(seconds: f64) -> String {
let total_ms = (seconds.max(0.0) * 1000.0).round() as u64;
let ms = total_ms % 1000;
let total_s = total_ms / 1000;
let s = total_s % 60;
let total_m = total_s / 60;
let m = total_m % 60;
let h = total_m / 60;
format!("{h:02}:{m:02}:{s:02}.{ms:03}")
}
#[cfg(test)]
mod tests;