use std::path::{Path, PathBuf};
use crate::audio::whisper::{path_component::single_path_component, result::TranscriptionResult};
#[cfg(test)]
mod tests;
pub fn format_time(seconds: f32, always_include_hours: bool, decimal_marker: char) -> String {
let hrs = (seconds / 3600.0) as i32;
let mins = ((seconds % 3600.0) / 60.0) as i32;
let secs = (seconds % 60.0) as i32;
let msec = ((seconds - seconds.floor()) * 1000.0) as i32;
if always_include_hours || hrs > 0 {
format!("{hrs:02}:{mins:02}:{secs:02}{decimal_marker}{msec:03}")
} else {
format!("{mins:02}:{secs:02}{decimal_marker}{msec:03}")
}
}
fn format_segment(index: usize, start: f32, end: f32, text: &str) -> String {
format!(
"{index}\n{} --> {}\n{text}\n\n",
format_time(start, true, ','),
format_time(end, true, ','),
)
}
fn format_timing(start: f32, end: f32, text: &str) -> String {
format!(
"{} --> {}\n{text}\n\n",
format_time(start, false, '.'),
format_time(end, false, '.'),
)
}
pub fn srt_content(result: &TranscriptionResult) -> String {
let mut content = String::new();
let mut index = 1usize;
for segment in result.segments_slice() {
let words = segment.words_slice();
if words.is_empty() {
content.push_str(&format_segment(
index,
segment.start(),
segment.end(),
segment.text(),
));
index += 1;
} else {
for word in words {
content.push_str(&format_segment(
index,
word.start(),
word.end(),
word.word(),
));
index += 1;
}
}
}
content
}
pub fn vtt_content(result: &TranscriptionResult) -> String {
let mut content = String::from("WEBVTT\n\n");
for segment in result.segments_slice() {
let words = segment.words_slice();
if words.is_empty() {
content.push_str(&format_timing(
segment.start(),
segment.end(),
segment.text(),
));
} else {
for word in words {
content.push_str(&format_timing(word.start(), word.end(), word.word()));
}
}
}
content
}
#[cfg(feature = "serde")]
pub fn json_content(result: &TranscriptionResult) -> Result<String, WriteError> {
Ok(serde_json::to_string_pretty(result)?)
}
#[derive(Debug, thiserror::Error)]
#[error("failed to write result file `{path}`: {source}", path = path.display())]
pub struct Write {
path: PathBuf,
source: std::io::Error,
}
impl Write {
#[inline(always)]
pub const fn new(path: PathBuf, source: std::io::Error) -> Self {
Self { path, source }
}
#[inline(always)]
pub fn path(&self) -> &Path {
&self.path
}
#[inline(always)]
pub const fn source(&self) -> &std::io::Error {
&self.source
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileStem {
stem: String,
reason: &'static str,
}
impl FileStem {
#[inline(always)]
pub const fn new(stem: String, reason: &'static str) -> Self {
Self { stem, reason }
}
#[inline(always)]
pub fn stem(&self) -> &str {
&self.stem
}
#[inline(always)]
pub const fn reason(&self) -> &'static str {
self.reason
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum WriteError {
#[error(transparent)]
Write(#[from] Write),
#[error("transcript file stem `{}` {}", .0.stem(), .0.reason())]
FileStem(FileStem),
#[cfg(feature = "serde")]
#[error("failed to serialize result: {0}")]
Serialize(#[from] serde_json::Error),
}
pub trait ResultWriter {
fn output_dir(&self) -> &Path;
fn write(&self, result: &TranscriptionResult, file_stem: &str) -> Result<PathBuf, WriteError>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SrtWriter {
output_dir: PathBuf,
}
impl SrtWriter {
pub fn new(output_dir: impl Into<PathBuf>) -> Self {
Self {
output_dir: output_dir.into(),
}
}
}
fn checked_stem(file_stem: &str) -> Result<&str, WriteError> {
single_path_component(file_stem)
.map_err(|defect| WriteError::FileStem(FileStem::new(file_stem.to_owned(), defect.reason())))
}
fn write_atomic(path: &Path, contents: &str) -> Result<(), WriteError> {
use std::io::Write as _;
let map = |source| WriteError::Write(Write::new(path.to_path_buf(), source));
let mut attempt = 0u32;
let (mut file, tmp) = loop {
let mut name = path.as_os_str().to_owned();
name.push(format!(".{}.{attempt}.tmp", std::process::id()));
let tmp = PathBuf::from(name);
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp)
{
Ok(file) => break (file, tmp),
Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists && attempt < 1024 => {
attempt += 1;
}
Err(source) => return Err(map(source)),
}
};
let written = file.write_all(contents.as_bytes());
drop(file);
let staged = written.and_then(|()| std::fs::rename(&tmp, path));
staged.map_err(|source| {
let _ = std::fs::remove_file(&tmp);
map(source)
})
}
impl ResultWriter for SrtWriter {
fn output_dir(&self) -> &Path {
&self.output_dir
}
fn write(&self, result: &TranscriptionResult, file_stem: &str) -> Result<PathBuf, WriteError> {
let path = self
.output_dir
.join(format!("{}.srt", checked_stem(file_stem)?));
write_atomic(&path, &srt_content(result))?;
Ok(path)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VttWriter {
output_dir: PathBuf,
}
impl VttWriter {
pub fn new(output_dir: impl Into<PathBuf>) -> Self {
Self {
output_dir: output_dir.into(),
}
}
}
impl ResultWriter for VttWriter {
fn output_dir(&self) -> &Path {
&self.output_dir
}
fn write(&self, result: &TranscriptionResult, file_stem: &str) -> Result<PathBuf, WriteError> {
let path = self
.output_dir
.join(format!("{}.vtt", checked_stem(file_stem)?));
write_atomic(&path, &vtt_content(result))?;
Ok(path)
}
}
#[cfg(feature = "serde")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JsonWriter {
output_dir: PathBuf,
}
#[cfg(feature = "serde")]
impl JsonWriter {
pub fn new(output_dir: impl Into<PathBuf>) -> Self {
Self {
output_dir: output_dir.into(),
}
}
}
#[cfg(feature = "serde")]
impl ResultWriter for JsonWriter {
fn output_dir(&self) -> &Path {
&self.output_dir
}
fn write(&self, result: &TranscriptionResult, file_stem: &str) -> Result<PathBuf, WriteError> {
let stem = checked_stem(file_stem)?;
let content = json_content(result)?;
let path = self.output_dir.join(format!("{stem}.json"));
write_atomic(&path, &content)?;
Ok(path)
}
}