crates/veilvoice-conversation/src/subtitles.rs
what this file is for · veilvoice-conversation · 276 lines · the same file on GitHub
The file as it is in the tree, in the colours you chose. A line number is a link, and so is every box in this file’s diagram: it opens here with the function it names marked.
// SPDX-License-Identifier: GPL-3.0-or-later
//! Subtitles, from the same plan the audio is rendered from.
//!
//! # Two formats, both written out here
//!
//! **WebVTT** is what a browser plays alongside a `<video>`, and it is the one
//! to use with anything this project renders. **SubRip** (`.srt`) is what every
//! other player on earth reads. They differ in three small ways, being a header,
//! a cue counter, and a comma instead of a full stop in the timestamp, so both
//! come from one function with a flag rather than from two that drift.
//!
//! No library. The workspace carries no subtitle crate and this is forty lines;
//! adding a dependency to the graph for it would cost more than it saves, and
//! the `offline` CI job that checks what is in that graph is one of the things
//! this project is worth trusting for.
//!
//! # What goes in a cue when nobody wrote down the words
//!
//! VeilVoice does not transcribe. Where a turn has no text, the cue carries the
//! **speaker's name and nothing else**, which is still worth having: after
//! every voice has been replaced, a caption track saying who is talking is
//! often the only way to follow a recording at all.
//!
//! # A name in a caption is not veiled
//!
//! Worth saying twice, because it is the mistake this feature invites. The
//! audio has had its voiceprints destroyed. The subtitle file is a text file
//! containing whatever names were typed into it, sitting next to the recording.
//! If the names matter, use labels rather than names, because the plan does not care
//! which, and [`crate::SCOPE`] says so where a user will read it.
//!
//! # In plain words
//!
//! Writes the subtitles, from the same plan the audio came from.
//!
//! Both files come out of one source, so the words on screen and the voice you
//! hear cannot drift apart or disagree about who is speaking.
//!
//! Two formats are written: the one browsers use for video on a web page, and the
//! older one nearly every video player and editor understands.
//!
//! The names in the subtitles are the ones you typed. Nothing veils those, and the
//! application says so where you type them.
use crate::Conversation;
/// Which subtitle format to write.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Format {
/// WebVTT, for a browser. Header, no cue numbers, `.` in timestamps.
WebVtt,
/// SubRip, for everything else. No header, numbered cues, `,` in
/// timestamps.
SubRip,
}
impl Format {
/// The conventional file extension, without the dot.
pub fn extension(&self) -> &'static str {
match self {
Format::WebVtt => "vtt",
Format::SubRip => "srt",
}
}
}
/// A timestamp as the subtitle formats want it: `HH:MM:SS.mmm`.
///
/// Negative and non-finite inputs become zero rather than producing a cue no
/// player will accept. A plan cannot contain either, because
/// [`Conversation::add_turn`] refuses both, so this is the belt to that braces, and it is here because
/// the alternative failure is a subtitle file that silently does not load.
fn timestamp(seconds: f64, comma: bool) -> String {
let seconds = if seconds.is_finite() && seconds > 0.0 {
seconds
} else {
0.0
};
let total_ms = (seconds * 1000.0).round() as u64;
let ms = total_ms % 1000;
let total_secs = total_ms / 1000;
let s = total_secs % 60;
let m = (total_secs / 60) % 60;
let h = total_secs / 3600;
let separator = if comma { ',' } else { '.' };
format!("{h:02}:{m:02}:{s:02}{separator}{ms:03}")
}
/// Render the plan as subtitles.
pub fn write(conversation: &Conversation, format: Format) -> String {
let comma = format == Format::SubRip;
let mut out = String::new();
if format == Format::WebVtt {
out.push_str("WEBVTT\n");
if let Some(title) = &conversation.title {
// A NOTE block, which every player ignores and every text editor
// shows. The title belongs in the file it describes.
out.push_str(&format!("\nNOTE {}\n", one_line(title)));
}
out.push('\n');
}
for (index, turn) in conversation.turns().iter().enumerate() {
if format == Format::SubRip {
out.push_str(&format!("{}\n", index + 1));
}
out.push_str(&format!(
"{} --> {}\n",
timestamp(turn.start, comma),
timestamp(turn.end, comma)
));
let name = conversation
.speakers()
.get(turn.speaker)
.map(|speaker| speaker.name.as_str())
.unwrap_or("unknown speaker");
match &turn.text {
Some(text) => out.push_str(&format!("{}: {}\n", one_line(name), one_line(text))),
// No transcript, so the cue is the one fact there is. After every
// voice has been replaced this is often the only way to follow a
// recording.
None => out.push_str(&format!("{}\n", one_line(name))),
}
out.push('\n');
}
out
}
/// Flatten anything that would break a cue into one line.
///
/// A cue ends at a blank line and a timestamp line is found by its arrow, so a
/// name or a line of text containing either could end one cue early and start
/// something a player would try to read as a timestamp. The plan already
/// refuses line breaks; the arrow is caught here.
fn one_line(text: &str) -> String {
text.replace(['\n', '\r'], " ").replace("-->", "->")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Speaker, Turn};
fn two_people() -> Conversation {
let mut conversation = Conversation::new();
conversation.title = Some("Two people".into());
conversation.add_speaker(Speaker::named("Alex")).unwrap();
conversation.add_speaker(Speaker::named("Sam")).unwrap();
conversation
.add_turn(Turn {
start: 0.0,
end: 4.2,
speaker: 0,
text: Some("Hello, how did it go?".into()),
})
.unwrap();
conversation
.add_turn(Turn {
start: 4.5,
end: 3671.5,
speaker: 1,
text: None,
})
.unwrap();
conversation
}
#[test]
fn webvtt_opens_with_its_header_and_subrip_does_not() {
assert!(write(&two_people(), Format::WebVtt).starts_with("WEBVTT\n"));
assert!(!write(&two_people(), Format::SubRip).starts_with("WEBVTT"));
}
#[test]
fn subrip_numbers_its_cues_and_webvtt_does_not() {
let srt = write(&two_people(), Format::SubRip);
assert!(srt.starts_with("1\n"), "{srt}");
assert!(srt.contains("\n2\n"), "{srt}");
let vtt = write(&two_people(), Format::WebVtt);
assert!(!vtt.contains("\n1\n00:"), "{vtt}");
}
#[test]
fn the_timestamp_separator_differs_between_the_two() {
assert!(write(&two_people(), Format::WebVtt).contains("00:00:00.000"));
assert!(write(&two_people(), Format::SubRip).contains("00:00:00,000"));
}
/// Over an hour, so the hour field is exercised rather than assumed.
#[test]
fn hours_minutes_seconds_and_milliseconds_are_all_right() {
assert_eq!(timestamp(0.0, false), "00:00:00.000");
assert_eq!(timestamp(4.2, false), "00:00:04.200");
assert_eq!(timestamp(61.5, false), "00:01:01.500");
assert_eq!(timestamp(3671.5, false), "01:01:11.500");
assert_eq!(timestamp(3671.5, true), "01:01:11,500");
assert!(write(&two_people(), Format::WebVtt).contains("01:01:11.500"));
}
/// A cue no player accepts is worse than a wrong one, because it fails
/// silently on somebody else's machine.
#[test]
fn an_impossible_time_becomes_zero_rather_than_a_broken_cue() {
assert_eq!(timestamp(-5.0, false), "00:00:00.000");
assert_eq!(timestamp(f64::NAN, false), "00:00:00.000");
assert_eq!(timestamp(f64::INFINITY, false), "00:00:00.000");
}
/// The whole point of a subtitle track when every voice has been replaced.
#[test]
fn a_turn_with_no_words_still_says_who_was_speaking() {
let vtt = write(&two_people(), Format::WebVtt);
assert!(vtt.contains("Sam\n"), "{vtt}");
assert!(vtt.contains("Alex: Hello, how did it go?"), "{vtt}");
}
/// An arrow inside a name or a line of text would start something a player
/// reads as a timestamp.
#[test]
fn an_arrow_in_the_text_cannot_forge_a_timestamp_line() {
let mut conversation = Conversation::new();
conversation
.add_speaker(Speaker::named("00:00:00.000 --> 00:00:09.000"))
.unwrap();
conversation
.add_turn(Turn {
start: 0.0,
end: 1.0,
speaker: 0,
text: Some("and then --> that happened".into()),
})
.unwrap();
let vtt = write(&conversation, Format::WebVtt);
assert_eq!(
vtt.matches(" --> ").count(),
1,
"exactly one real timestamp line: {vtt}"
);
}
#[test]
fn the_title_is_carried_into_webvtt_as_a_note() {
let vtt = write(&two_people(), Format::WebVtt);
assert!(vtt.contains("NOTE Two people"), "{vtt}");
// And SubRip has nowhere to put one, so it does not pretend to.
assert!(!write(&two_people(), Format::SubRip).contains("Two people"));
}
#[test]
fn an_empty_plan_produces_a_valid_empty_file() {
let empty = Conversation::new();
let vtt = write(&empty, Format::WebVtt);
assert_eq!(vtt.trim(), "WEBVTT");
assert!(write(&empty, Format::SubRip).is_empty());
}
#[test]
fn the_extensions_are_the_conventional_ones() {
assert_eq!(Format::WebVtt.extension(), "vtt");
assert_eq!(Format::SubRip.extension(), "srt");
}
/// Every cue must be separated by a blank line, or players merge them.
#[test]
fn every_cue_is_terminated() {
for format in [Format::WebVtt, Format::SubRip] {
let text = write(&two_people(), format);
assert!(text.ends_with("\n\n"), "{format:?}: {text:?}");
assert_eq!(
text.matches(" --> ").count(),
2,
"{format:?} lost a cue: {text}"
);
}
}
}