crates/veilvoice-conversation/src/plan.rs
what this file is for · veilvoice-conversation · 1201 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
//! Who is in the recording, and who is speaking when.
//!
//! # VeilVoice does not work out who is talking, and will not guess
//!
//! Deciding which person is speaking at each moment is *speaker diarisation*,
//! and doing it from the audio alone needs a trained model. This project ships
//! no model, talks to no server, and is not about to start doing either, so
//! the turns come from the user, and there are exactly two honest ways to get
//! them:
//!
//! * **One microphone each.** If the recording has a channel per person, the
//! split is already there and is exact. [`Conversation::from_channels`]
//! builds the plan from that.
//! * **A list of turns.** Times and speakers, in a text file, written by
//! whoever was there or produced by whatever tool they already use for
//! transcripts.
//!
//! What would be worse than either is guessing. A wrong guess maps two people
//! onto one voice, which is a privacy *improvement* and a usability disaster,
//! or splits one person across two voices, which invites a listener to believe
//! there was somebody in the room who was not. Neither failure would be visible
//! in the output, and both would be blamed on the recording rather than on the
//! tool.
//!
//! # Format
//!
//! Text, one record per line, for the same reason everything else here is text:
//! a file describing who said what is worth more if it can be read, checked and
//! edited without this program.
//!
//! ```text
//! VEILCONV1
//! title Two people, one microphone
//! speaker 0 Alex
//! speaker 1 Sam portrait.png
//! turn 0.000 4.200 0 Hello -- how did it go?
//! turn 4.100 9.050 1
//! ```
//!
//! Times are seconds with a decimal point. The text on a turn is optional: with
//! it, subtitles carry the words; without it they carry the speaker's name and
//! nothing else, which is still enough to follow a conversation whose voices
//! have all been replaced.
//!
//! Overlapping turns are allowed, because people talk over each other, and
//! [`crate::render`] mixes them rather than picking a winner.
//!
//! # In plain words
//!
//! A list of who is in a recording and when each of them speaks.
//!
//! VeilVoice does not work this out for itself. Deciding who is talking at any
//! moment is a hard problem that needs a trained model, and this project does not
//! ship one, so it asks instead. You either write the times down, or you record
//! each person on their own microphone.
//!
//! That is less convenient and it is honest. A program that guessed would
//! sometimes put one person's words in another person's voice, and you would not
//! find out by listening, because the result would sound perfectly fine.
use crate::Error;
use std::path::PathBuf;
use veilvoice_core::voices::{Voice, MAX_VOICES};
/// Magic first line. The digit is a format version.
const MAGIC: &str = "VEILCONV1";
/// One person in the recording.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Speaker {
/// What to call them in subtitles and in an interface.
///
/// A label for a human, and **not** part of the de-identification: writing
/// somebody's real name here puts their real name in the subtitle file. The
/// audio is veiled; a name typed into a caption is not.
pub name: String,
/// A picture to show for them in a rendered video, if there is one.
///
/// `None` means a plain filled circle in their colour, which is what most
/// people will use, and which reveals nothing, unlike a photograph.
pub picture: Option<PathBuf>,
/// The colour to draw them in, as `#rrggbb`, if somebody chose one.
///
/// `None` is the ordinary case and means the colour their slot gets from
/// the palette, which is what every speaker starts with and what nine out
/// of ten will keep. A chosen colour is for the cases the palette cannot
/// know about: two speakers whose default colours are hard to tell apart
/// for a particular reader, or a recording that has to match something
/// else's branding.
///
/// **A colour is not de-identification.** It labels a circle, exactly as
/// [`Speaker::name`] labels it, and neither is part of what the veiling
/// does. Choosing the colour somebody always uses is as identifying as
/// writing their name in.
pub colour: Option<String>,
}
impl Speaker {
/// A speaker with a name and no picture.
pub fn named(name: &str) -> Self {
Self {
name: name.trim().to_string(),
picture: None,
colour: None,
}
}
}
/// A span of the recording belonging to one speaker.
#[derive(Clone, Debug, PartialEq)]
pub struct Turn {
/// When it starts, in seconds from the beginning of the recording.
pub start: f64,
/// When it ends, in seconds.
pub end: f64,
/// Which speaker, as an index into [`Conversation::speakers`].
pub speaker: usize,
/// What was said, if anybody wrote it down.
///
/// VeilVoice does not transcribe: there is no model here and there is no
/// server to ask. This is carried through to the subtitles when it is
/// supplied and left out when it is not.
pub text: Option<String>,
}
impl Turn {
/// How long this turn lasts, in seconds.
pub fn duration(&self) -> f64 {
(self.end - self.start).max(0.0)
}
}
/// The whole plan: who is in the recording, and when each of them speaks.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Conversation {
/// A title for the recording, shown on a rendered video.
pub title: Option<String>,
speakers: Vec<Speaker>,
turns: Vec<Turn>,
/// Whether each speaker gets their own voice, or one between them.
///
/// Private, and **not written to the plan file**. A plan says who is in the
/// recording and when they speak; how they are rendered is decided at
/// render time by whoever is rendering. A mode stored in a shared file
/// would silently change what somebody else's render sounds like.
mode: crate::mode::VoiceMode,
}
/// The first whitespace-separated word, and everything after it.
///
/// `None` when the line has no whitespace at all, which is a line with only a
/// keyword on it and nothing for the keyword to act on.
fn split_word(line: &str) -> Option<(&str, &str)> {
let at = line.find(char::is_whitespace)?;
let (word, rest) = line.split_at(at);
Some((word, rest.trim_start_matches(char::is_whitespace)))
}
impl Conversation {
/// An empty plan.
pub fn new() -> Self {
Self::default()
}
/// Add a speaker, and return the index they were given.
///
/// The index is also the destination-voice slot, so the first speaker added
/// gets [`veilvoice_core::voices::voice`] 0 and the second gets voice 1,
/// which are the two furthest apart in the table, because two people is the
/// common case.
pub fn add_speaker(&mut self, speaker: Speaker) -> Result<usize, Error> {
if self.speakers.len() >= MAX_VOICES {
return Err(Error::TooManySpeakers(MAX_VOICES));
}
if speaker.name.trim().is_empty() {
return Err(Error::Malformed("a speaker needs a name".into()));
}
if speaker.name.contains('\n') || speaker.name.contains('\r') {
return Err(Error::Malformed(
"a speaker's name may not contain a line break: it would be able to \
forge a record"
.into(),
));
}
// The colour is validated on the way in, exactly as the name is, so a
// plan cannot hold a value that would land in an SVG attribute as text.
let colour = speaker
.colour
.as_deref()
.map(crate::edit::check_colour)
.transpose()?;
self.speakers.push(Speaker {
name: speaker.name.trim().to_string(),
picture: speaker.picture,
colour,
});
Ok(self.speakers.len() - 1)
}
/// Add a turn.
pub fn add_turn(&mut self, turn: Turn) -> Result<(), Error> {
if !turn.start.is_finite() || !turn.end.is_finite() {
return Err(Error::Malformed(
"a turn needs real start and end times".into(),
));
}
if turn.start < 0.0 {
return Err(Error::Malformed(format!(
"a turn cannot start at {} seconds",
turn.start
)));
}
if turn.end <= turn.start {
return Err(Error::Malformed(format!(
"a turn from {} to {} seconds has no length. A zero-length turn is \
almost always a typo, and silently dropping it would leave that \
speech in whichever voice happened to be next.",
turn.start, turn.end
)));
}
if turn.speaker >= self.speakers.len() {
return Err(Error::Malformed(format!(
"turn at {} s names speaker {}, and only {} are declared",
turn.start,
turn.speaker,
self.speakers.len()
)));
}
if let Some(text) = &turn.text {
if text.contains('\n') || text.contains('\r') {
return Err(Error::Malformed(
"a turn's text may not contain a line break".into(),
));
}
}
self.turns.push(turn);
// Kept in time order so rendering, subtitles and every report agree
// about what comes first without each having to sort for itself.
self.turns.sort_by(|a, b| {
a.start
.partial_cmp(&b.start)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.speaker.cmp(&b.speaker))
});
Ok(())
}
/// Rename everybody, in slot order, keeping every turn where it is.
///
/// For a front end that holds the names and reads the turns from a plan
/// file somebody wrote earlier. The names are the ones just typed; the
/// turns are the plan's, and nothing here touches them.
///
/// # Refused rather than reconciled
///
/// The count has to match exactly. A plan naming three speakers renamed
/// from a list of two would either leave one person with a stale name or
/// silently drop a slot, and a dropped slot means somebody's audio comes
/// out in another person's voice, which is the one mistake here that cannot be
/// heard in the result, because both voices are unfamiliar.
///
/// Every name is validated exactly as [`Conversation::add_speaker`]
/// validates one, and for the same reasons: an empty name labels nobody,
/// and a name containing a line break can forge a record in the plan file.
/// Nothing is changed unless every name passes, so a refusal leaves the
/// plan exactly as it was rather than half-renamed.
pub fn rename_speakers(&mut self, names: &[String]) -> Result<(), Error> {
if names.len() != self.speakers.len() {
return Err(Error::Malformed(format!(
"this plan has {} speaker(s) and {} name(s) were given",
self.speakers.len(),
names.len()
)));
}
let mut checked = Vec::with_capacity(names.len());
for name in names {
let trimmed = name.trim();
if trimmed.is_empty() {
return Err(Error::Malformed("a speaker needs a name".into()));
}
if trimmed.contains('\n') || trimmed.contains('\r') {
return Err(Error::Malformed(
"a speaker's name may not contain a line break: it would be able to \
forge a record"
.into(),
));
}
checked.push(trimmed.to_string());
}
for (speaker, name) in self.speakers.iter_mut().zip(checked) {
speaker.name = name;
}
Ok(())
}
/// The speakers, in the order they were added.
pub fn speakers(&self) -> &[Speaker] {
&self.speakers
}
/// The turns, in time order.
pub fn turns(&self) -> &[Turn] {
&self.turns
}
/// How many speakers there are.
pub fn len(&self) -> usize {
self.speakers.len()
}
/// Whether there is nobody in the plan.
pub fn is_empty(&self) -> bool {
self.speakers.is_empty()
}
/// The speakers, for an edit that has already checked what it is doing.
///
/// `pub(crate)` on purpose. Everything outside this crate reaches speakers
/// through [`Conversation::speakers`], which cannot change them, or through
/// [`crate::edit`], which validates first. Handing out a mutable slice
/// would let a caller write an empty name or a colour that is not one, and
/// the point of the validation is that there is no way round it.
pub(crate) fn speakers_mut(&mut self) -> &mut Vec<Speaker> {
&mut self.speakers
}
/// The spans, for an edit that has already checked what it is doing.
///
/// `pub(crate)` for the same reason, and with one extra rule: **anything
/// that changes a start time has to keep the list sorted**, because
/// rendering, the subtitles and every report read it in order and none of
/// them sorts for itself. Going through [`Conversation::add_turn`] does
/// that; changing `start` in place does not.
pub(crate) fn turns_mut(&mut self) -> &mut Vec<Turn> {
&mut self.turns
}
/// The colour to draw a speaker in, chosen or from the palette.
///
/// `palette` is the slot colour to fall back to, which the caller supplies
/// because this crate draws nothing and does not know which scheme is in
/// force.
pub fn colour_of(&self, speaker: usize, palette: &str) -> String {
self.speakers
.get(speaker)
.and_then(|s| s.colour.clone())
.unwrap_or_else(|| palette.to_string())
}
/// The destination voice for a speaker.
pub fn voice(&self, speaker: usize) -> Voice {
self.mode.voice_for(speaker)
}
/// Whether every speaker gets their own voice, or one between them.
///
/// Not persisted in the plan file, and deliberately so. A plan says *who is
/// in the recording and when they speak*; how they are rendered is a
/// decision made at render time, by whoever is doing the rendering. Writing
/// it into the file would mean a plan somebody shared could silently change
/// what a later render sounds like.
pub fn mode(&self) -> crate::mode::VoiceMode {
self.mode
}
/// Render every speaker as the same voice, or as their own.
///
/// Refuses when this plan holds more speakers than the mode can carry --
/// which for [`crate::mode::VoiceMode::Distinct`] is how many voices are
/// far enough apart to be told apart, measured under `config`.
pub fn set_mode(
&mut self,
mode: crate::mode::VoiceMode,
config: &veilvoice_core::DeidConfig,
) -> Result<(), crate::mode::TooMany> {
crate::mode::check(self.speakers.len(), mode, config)?;
self.mode = mode;
Ok(())
}
/// When the last turn ends, in seconds.
pub fn duration(&self) -> f64 {
self.turns.iter().map(|turn| turn.end).fold(0.0, f64::max)
}
/// Turns where two people are speaking at once.
///
/// Reported rather than refused: people talk over each other, and a plan
/// that forbade it would be a plan that cannot describe a real
/// conversation. [`crate::render`] mixes the overlap.
pub fn overlaps(&self) -> Vec<(usize, usize)> {
let mut found = Vec::new();
for (i, a) in self.turns.iter().enumerate() {
for (j, b) in self.turns.iter().enumerate().skip(i + 1) {
if b.start >= a.end {
// Sorted by start, so nothing later can overlap this one.
break;
}
if a.speaker != b.speaker {
found.push((i, j));
}
}
}
found
}
/// Spans where a speaker's turns overlap **their own** other turns.
///
/// Distinct from [`Conversation::overlaps`] and much more likely to be a
/// mistake: one person cannot be in two places in their own recording, so
/// this is usually a typed time rather than an interruption.
pub fn self_overlaps(&self) -> Vec<(usize, usize)> {
let mut found = Vec::new();
for (i, a) in self.turns.iter().enumerate() {
for (j, b) in self.turns.iter().enumerate().skip(i + 1) {
if b.start >= a.end {
break;
}
if a.speaker == b.speaker {
found.push((i, j));
}
}
}
found
}
/// A plan for a recording with one microphone per person.
///
/// The only split VeilVoice can make on its own, because it is not a guess:
/// if each person had their own channel then each channel *is* one person,
/// and the whole recording is one turn per channel.
///
/// The names are the ones supplied; there must be one per channel.
pub fn from_channels(names: &[&str], duration_secs: f64) -> Result<Self, Error> {
if names.is_empty() {
return Err(Error::Malformed("no channels were named".into()));
}
if !duration_secs.is_finite() || duration_secs <= 0.0 {
return Err(Error::Malformed(format!(
"a recording cannot be {duration_secs} seconds long"
)));
}
let mut conversation = Self::new();
for name in names {
conversation.add_speaker(Speaker::named(name))?;
}
for speaker in 0..conversation.speakers.len() {
conversation.add_turn(Turn {
start: 0.0,
end: duration_secs,
speaker,
text: None,
})?;
}
Ok(conversation)
}
/// Serialise to the text format described at the top of this module.
pub fn to_text(&self) -> String {
let mut out = String::from(MAGIC);
out.push('\n');
if let Some(title) = &self.title {
out.push_str(&format!("title {title}\n"));
}
for (index, speaker) in self.speakers.iter().enumerate() {
match &speaker.picture {
Some(picture) => out.push_str(&format!(
"speaker {index} {} {}\n",
speaker.name,
picture.display().to_string().replace('\\', "/")
)),
None => out.push_str(&format!("speaker {index} {}\n", speaker.name)),
}
// A line of its own rather than a fourth field on `speaker`. A name
// may contain two spaces, which is what separates the fields there,
// so a fourth one would be ambiguous exactly for the names most
// likely to have been typed by hand.
if let Some(colour) = &speaker.colour {
out.push_str(&format!("colour {index} {colour}\n"));
}
}
for turn in &self.turns {
match &turn.text {
Some(text) => out.push_str(&format!(
"turn {:.3} {:.3} {} {}\n",
turn.start, turn.end, turn.speaker, text
)),
None => out.push_str(&format!(
"turn {:.3} {:.3} {}\n",
turn.start, turn.end, turn.speaker
)),
}
}
out
}
/// Parse the text format.
///
/// An unknown keyword is refused rather than skipped. A plan is a statement
/// about who is in a recording, and honouring half of one written by a
/// newer build would put somebody's speech in the wrong voice without
/// saying anything.
pub fn parse(text: &str) -> Result<Self, Error> {
let mut lines = text.lines();
match lines.next() {
Some(first) if first.trim() == MAGIC => {}
Some(other) => {
return Err(Error::Malformed(format!(
"expected {MAGIC} on the first line, found {other:?}"
)))
}
None => return Err(Error::Malformed("the plan is empty".into())),
}
let mut conversation = Self::new();
let mut pending: Vec<(usize, Turn)> = Vec::new();
for (index, line) in lines.enumerate() {
let number = index + 2;
if line.trim().is_empty() {
continue;
}
// F-110. The keyword is one word, so any run of whitespace ends it.
//
// This used to be `split_once(" ")`, requiring exactly two spaces
// after the keyword, and the example printed in `docs/USER_GUIDE.md`
// does not have two on every line: `turn 19.000 22.400 0` lines
// its columns up with one, because the number is a digit wider.
// Anybody writing their first plan by copying the guide was told
// `unknown keyword "turn 19.000"`.
//
// Two spaces still separate the fields that may contain single
// spaces, which is a speaker's name and a turn's words. It never
// needed to separate the fields that cannot: a keyword, an index
// and a pair of timestamps.
let Some((keyword, rest)) = split_word(line) else {
return Err(Error::Malformed(format!(
"line {number}: no keyword, found {line:?}"
)));
};
match keyword {
"title" => conversation.title = Some(rest.trim().to_string()),
"colour" => {
// Follows the speaker it names, so the index is checked
// against the speakers already read rather than against a
// count nobody has yet. A colour for a speaker that has not
// been declared is a hand-edited file with the lines the
// wrong way round, and saying so beats applying it to
// whoever ends up in that slot.
let (declared, value) = split_word(rest).unwrap_or((rest, ""));
let declared: usize = declared.trim().parse().map_err(|_| {
Error::Malformed(format!(
"line {number}: bad speaker index {:?} on a colour",
declared.trim()
))
})?;
let speaker = conversation.speakers.get_mut(declared).ok_or_else(|| {
Error::Malformed(format!(
"line {number}: colour for speaker {declared}, who has not \
been declared. A colour line follows its speaker."
))
})?;
speaker.colour = Some(
crate::edit::check_colour(value.trim())
.map_err(|e| Error::Malformed(format!("line {number}: {e}")))?,
);
}
"speaker" => {
// The index is a number and cannot hold a space; the
// name can, so the name and the optional picture path are
// still separated from each other by two.
let (declared, rest) = split_word(rest).unwrap_or((rest, ""));
let declared = declared.trim();
let mut parts = rest.splitn(2, " ");
let name = parts.next().unwrap_or_default().trim();
let picture = parts
.next()
.map(|p| p.trim())
.filter(|p| !p.is_empty())
.map(PathBuf::from);
let declared: usize = declared.parse().map_err(|_| {
Error::Malformed(format!("line {number}: bad index {declared:?}"))
})?;
// The index is written down and checked rather than
// implied by position: a hand-edited file with two
// speakers numbered 0 would otherwise silently give one of
// them the other's voice.
if declared != conversation.speakers.len() {
return Err(Error::Malformed(format!(
"line {number}: speaker {declared} appears where speaker {} \
was expected. Speakers are numbered from zero, in order.",
conversation.speakers.len()
)));
}
conversation.add_speaker(Speaker {
name: name.to_string(),
picture,
colour: None,
})?;
}
"turn" => {
// Three numbers, then whatever words were written. None
// of the three can contain a space, so each ends at the
// first run of whitespace and the rest of the line is the
// subtitle, single spaces and all.
let (start, rest) = split_word(rest).unwrap_or((rest, ""));
let (end, rest) = split_word(rest).unwrap_or((rest, ""));
let (speaker, rest) = split_word(rest).unwrap_or((rest, ""));
let start = start.trim();
let end = end.trim();
let speaker = speaker.trim();
let text = Some(rest.trim().to_string());
let start: f64 = start.parse().map_err(|_| {
Error::Malformed(format!("line {number}: bad start {start:?}"))
})?;
let end: f64 = end
.parse()
.map_err(|_| Error::Malformed(format!("line {number}: bad end {end:?}")))?;
let speaker: usize = speaker.parse().map_err(|_| {
Error::Malformed(format!("line {number}: bad speaker {speaker:?}"))
})?;
pending.push((
number,
Turn {
start,
end,
speaker,
text: text.filter(|t| !t.is_empty()),
},
));
}
other => {
return Err(Error::Malformed(format!(
"line {number}: unknown keyword {other:?}"
)))
}
}
}
// Turns are added after every speaker is known, so a file that lists
// its turns first is still readable and a turn naming an undeclared
// speaker is still refused.
for (number, turn) in pending {
conversation
.add_turn(turn)
.map_err(|error| Error::Malformed(format!("line {number}: {error}")))?;
}
Ok(conversation)
}
/// Write the plan to `path`.
pub fn save(&self, path: &std::path::Path) -> Result<(), Error> {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
// Owner-only. A plan holds every speaker's name and every word
// somebody typed into it, which is precisely the content that made the
// subtitle tracks worth protecting. Writing the subtitles 0600 and the
// file they are generated from 0644 would protect the copy and leave
// the original.
veilvoice_crypto::privatefile::write_owner_only(path, self.to_text().as_bytes())?;
Ok(())
}
/// Read a plan written by [`Conversation::save`].
pub fn load(path: &std::path::Path) -> Result<Self, Error> {
Self::parse(&std::fs::read_to_string(path)?)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn two_people() -> Conversation {
let mut conversation = Conversation::new();
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: 9.05,
speaker: 1,
text: None,
})
.unwrap();
conversation
}
#[test]
fn speakers_are_numbered_in_the_order_they_are_added() {
let mut conversation = Conversation::new();
assert_eq!(conversation.add_speaker(Speaker::named("Alex")).unwrap(), 0);
assert_eq!(conversation.add_speaker(Speaker::named("Sam")).unwrap(), 1);
assert_eq!(conversation.len(), 2);
assert!(!conversation.is_empty());
}
/// The first two speakers must get the two voices furthest apart in the
/// table, because two people is the common case.
#[test]
fn the_first_two_speakers_get_the_two_most_distinct_voices() {
let conversation = two_people();
assert_eq!(conversation.voice(0), veilvoice_core::voices::voice(0));
assert_eq!(conversation.voice(1), veilvoice_core::voices::voice(1));
assert_ne!(conversation.voice(0), conversation.voice(1));
}
#[test]
fn there_can_be_no_more_speakers_than_there_are_voices() {
let mut conversation = Conversation::new();
for index in 0..MAX_VOICES {
conversation
.add_speaker(Speaker::named(&format!("Person {index}")))
.unwrap();
}
let error = conversation
.add_speaker(Speaker::named("One too many"))
.expect_err("the eleventh must be refused, not given a reused voice");
assert!(matches!(error, Error::TooManySpeakers(MAX_VOICES)));
assert!(error.to_string().contains("10"), "{error}");
}
#[test]
fn a_nameless_speaker_is_refused() {
let mut conversation = Conversation::new();
assert!(conversation.add_speaker(Speaker::named(" ")).is_err());
assert!(conversation.add_speaker(Speaker::named("a\nb")).is_err());
assert!(conversation.is_empty());
}
#[test]
fn a_turn_naming_an_undeclared_speaker_is_refused() {
let mut conversation = Conversation::new();
conversation.add_speaker(Speaker::named("Alex")).unwrap();
let error = conversation
.add_turn(Turn {
start: 0.0,
end: 1.0,
speaker: 4,
text: None,
})
.expect_err("speaker 4 does not exist");
assert!(error.to_string().contains("only 1"), "{error}");
}
/// A zero-length turn would leave that speech in whichever voice was next,
/// silently. Refused.
#[test]
fn an_impossible_turn_is_refused() {
let mut conversation = Conversation::new();
conversation.add_speaker(Speaker::named("Alex")).unwrap();
for (start, end) in [(1.0, 1.0), (2.0, 1.0), (-1.0, 1.0)] {
assert!(
conversation
.add_turn(Turn {
start,
end,
speaker: 0,
text: None,
})
.is_err(),
"{start} to {end} should be refused"
);
}
assert!(conversation
.add_turn(Turn {
start: f64::NAN,
end: 1.0,
speaker: 0,
text: None,
})
.is_err());
assert!(conversation.turns().is_empty());
}
#[test]
fn turns_are_kept_in_time_order_however_they_arrive() {
let mut conversation = Conversation::new();
conversation.add_speaker(Speaker::named("Alex")).unwrap();
for start in [8.0, 2.0, 5.0, 0.0] {
conversation
.add_turn(Turn {
start,
end: start + 1.0,
speaker: 0,
text: None,
})
.unwrap();
}
let starts: Vec<f64> = conversation.turns().iter().map(|t| t.start).collect();
assert_eq!(starts, vec![0.0, 2.0, 5.0, 8.0]);
assert_eq!(conversation.duration(), 9.0);
}
/// People talk over each other, so an overlap is reported and not refused.
#[test]
fn an_overlap_between_two_people_is_reported_rather_than_refused() {
let mut conversation = two_people();
// Sam cuts in while Alex is still talking, and well before Sam's own
// later turn -- an interruption, and not Sam overlapping Sam.
conversation
.add_turn(Turn {
start: 2.0,
end: 3.0,
speaker: 1,
text: None,
})
.expect("an interruption is a real thing that happens");
let overlaps = conversation.overlaps();
assert!(!overlaps.is_empty(), "the interruption should be reported");
assert!(conversation.self_overlaps().is_empty());
}
/// One person cannot be in two places in their own recording, so this is
/// almost always a typed time -- reported separately, and more loudly.
#[test]
fn a_speaker_overlapping_themselves_is_reported_separately() {
let mut conversation = Conversation::new();
conversation.add_speaker(Speaker::named("Alex")).unwrap();
conversation
.add_turn(Turn {
start: 0.0,
end: 5.0,
speaker: 0,
text: None,
})
.unwrap();
conversation
.add_turn(Turn {
start: 3.0,
end: 8.0,
speaker: 0,
text: None,
})
.unwrap();
assert_eq!(conversation.self_overlaps().len(), 1);
assert!(conversation.overlaps().is_empty());
}
/// The one split VeilVoice can make on its own, because it is not a guess.
#[test]
fn a_channel_per_person_needs_no_diarisation() {
let conversation = Conversation::from_channels(&["Alex", "Sam"], 30.0).unwrap();
assert_eq!(conversation.len(), 2);
assert_eq!(conversation.turns().len(), 2);
assert_eq!(conversation.duration(), 30.0);
// Both talk for the whole recording, which is what a channel each
// means -- and which is an overlap by construction.
assert!(!conversation.overlaps().is_empty());
assert!(Conversation::from_channels(&[], 30.0).is_err());
assert!(Conversation::from_channels(&["Alex"], 0.0).is_err());
assert!(Conversation::from_channels(&["Alex"], f64::NAN).is_err());
}
#[test]
fn a_plan_survives_a_round_trip_through_text() {
let mut conversation = two_people();
conversation.title = Some("Two people, one microphone".into());
let text = conversation.to_text();
let read_back = Conversation::parse(&text).expect("its own output must parse");
assert_eq!(read_back.title, conversation.title);
assert_eq!(read_back.speakers(), conversation.speakers());
assert_eq!(read_back.turns().len(), conversation.turns().len());
assert_eq!(read_back.to_text(), text, "and byte for byte");
}
#[test]
fn a_plan_survives_a_round_trip_through_a_file() {
let dir = tempfile::tempdir().unwrap();
let conversation = two_people();
let path = dir.path().join("deeper").join("plan.txt");
conversation.save(&path).unwrap();
assert_eq!(Conversation::load(&path).unwrap(), conversation);
}
/// A saved plan is readable only by the account that saved it.
///
/// A plan holds every speaker's name and every word typed into it, which
/// is the same content as the subtitle tracks a render produces from it.
/// Those are written owner-only; writing the source of them 0644 would
/// protect the copy and leave the original.
#[cfg(unix)]
#[test]
fn a_saved_plan_is_readable_only_by_this_account() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("plan.txt");
two_people().save(&path).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode, 0o600,
"a plan is {mode:o}, so anyone with an account here can read the names in it"
);
assert_eq!(Conversation::load(&path).unwrap(), two_people());
}
#[test]
fn a_picture_survives_the_round_trip() {
let mut conversation = Conversation::new();
conversation
.add_speaker(Speaker {
name: "Sam".into(),
picture: Some(PathBuf::from("portraits/sam.png")),
colour: None,
})
.unwrap();
let read_back = Conversation::parse(&conversation.to_text()).unwrap();
assert_eq!(
read_back.speakers()[0].picture,
Some(PathBuf::from("portraits/sam.png"))
);
}
/// A file that lists its turns before its speakers must still read.
#[test]
fn turns_before_speakers_still_parse() {
let text = format!("{MAGIC}\nturn 0.000 1.000 0\nspeaker 0 Alex\n");
let conversation = Conversation::parse(&text).unwrap();
assert_eq!(conversation.len(), 1);
assert_eq!(conversation.turns().len(), 1);
}
/// A hand-edited file with two speakers numbered 0 would otherwise give
/// one of them the other's voice, silently.
#[test]
fn a_repeated_speaker_number_is_refused() {
let text = format!("{MAGIC}\nspeaker 0 Alex\nspeaker 0 Sam\n");
let error = Conversation::parse(&text).expect_err("must refuse");
assert!(error.to_string().contains("numbered from zero"), "{error}");
}
#[test]
fn a_malformed_plan_is_refused_rather_than_half_read() {
assert!(Conversation::parse("").is_err(), "empty");
assert!(
Conversation::parse("NOT-THE-MAGIC\n").is_err(),
"wrong magic"
);
for bad in [
"speaker x Alex",
"speaker 0 Alex\nturn x 1.0 0",
"speaker 0 Alex\nturn 0.0 x 0",
"speaker 0 Alex\nturn 0.0 1.0 x",
"speaker 0 Alex\nturn 0.0 1.0 9",
"whatever 1",
"nokeyword",
] {
let text = format!("{MAGIC}\n{bad}\n");
assert!(
Conversation::parse(&text).is_err(),
"should have been refused: {bad:?}"
);
}
}
#[test]
fn blank_lines_are_tolerated() {
let conversation = two_people();
let padded = conversation.to_text().replace('\n', "\n\n");
assert_eq!(Conversation::parse(&padded).unwrap(), conversation);
}
/// Renaming keeps the turns exactly where they were.
#[test]
fn renaming_leaves_every_turn_alone() {
let mut plan = two_people();
let before = plan.turns().to_vec();
plan.rename_speakers(&["Robin".to_string(), "Jules".to_string()])
.unwrap();
assert_eq!(plan.speakers()[0].name, "Robin");
assert_eq!(plan.speakers()[1].name, "Jules");
assert_eq!(plan.turns(), before.as_slice(), "the turns must not move");
}
/// The one mistake here that cannot be heard in the result: a mismatch
/// would put somebody's audio in another person's voice, and both voices
/// are unfamiliar, so nobody would notice.
#[test]
fn a_different_number_of_names_is_refused() {
let mut plan = two_people();
let error = plan
.rename_speakers(&["Only one".to_string()])
.expect_err("two speakers, one name");
assert!(format!("{error}").contains('2'), "{error}");
assert_eq!(plan.speakers()[0].name, "Alex", "nothing may have changed");
}
/// Every name is checked the way `add_speaker` checks one, and nothing is
/// changed unless all of them pass.
#[test]
fn a_bad_name_leaves_the_plan_exactly_as_it_was() {
let mut plan = two_people();
for bad in ["", " ", "Robin\nspeaker 9 Mallory"] {
let error = plan
.rename_speakers(&["Robin".to_string(), bad.to_string()])
.expect_err("{bad:?} should be refused");
let _ = error;
assert_eq!(
plan.speakers()[0].name,
"Alex",
"a refusal must not half-rename"
);
assert_eq!(plan.speakers()[1].name, "Sam");
}
}
#[test]
fn names_are_trimmed_the_same_way_they_are_when_added() {
let mut plan = two_people();
plan.rename_speakers(&[" Robin ".to_string(), "Jules".to_string()])
.unwrap();
assert_eq!(plan.speakers()[0].name, "Robin");
}
/// Uniform mode gives every speaker the same voice. This is the whole of
/// what the mode does to the sound, so it is asserted directly.
#[test]
fn uniform_mode_gives_every_speaker_one_voice() {
use crate::mode::VoiceMode;
let config = veilvoice_core::DeidConfig::default();
let mut plan = two_people();
assert_ne!(plan.voice(0), plan.voice(1), "distinct by default");
plan.set_mode(VoiceMode::Uniform, &config).unwrap();
assert_eq!(plan.voice(0), plan.voice(1), "one voice between them");
assert_eq!(plan.mode(), VoiceMode::Uniform);
plan.set_mode(VoiceMode::Distinct, &config).unwrap();
assert_ne!(plan.voice(0), plan.voice(1), "and back again");
}
/// A plan with more speakers than there are separable voices cannot be put
/// into distinct mode, and the refusal says what to do instead.
#[test]
fn distinct_mode_is_refused_past_the_measured_limit() {
use crate::mode::VoiceMode;
let config = veilvoice_core::DeidConfig::default();
let mut plan = Conversation::new();
for index in 0..9 {
plan.add_speaker(Speaker::named(&format!("P{index}")))
.unwrap();
}
// Nine is past the eight that are clearly separable...
let error = plan
.set_mode(VoiceMode::Distinct, &config)
.expect_err("nine speakers, eight clear voices");
assert!(error.to_string().contains("one voice for everybody"));
// ...and uniform mode carries them, which is the point of the refusal
// naming it.
plan.set_mode(VoiceMode::Uniform, &config).unwrap();
assert_eq!(plan.mode(), VoiceMode::Uniform);
for slot in 0..9 {
assert_eq!(plan.voice(slot), plan.voice(0));
}
}
/// The mode is not written to the plan file. A plan says who speaks when;
/// how it is rendered is the renderer's decision, and a mode hidden in a
/// shared file would change what somebody else's render sounds like.
#[test]
fn the_mode_is_not_carried_in_the_file() {
use crate::mode::VoiceMode;
let config = veilvoice_core::DeidConfig::default();
let mut plan = two_people();
plan.set_mode(VoiceMode::Uniform, &config).unwrap();
let text = plan.to_text();
assert!(!text.to_lowercase().contains("uniform"), "{text}");
assert!(!text.to_lowercase().contains("mode"), "{text}");
let read_back = Conversation::parse(&text).unwrap();
assert_eq!(
read_back.mode(),
VoiceMode::Distinct,
"a plan read from disk renders distinct until told otherwise"
);
}
#[test]
fn a_turn_reports_its_own_length() {
let turn = Turn {
start: 1.5,
end: 4.0,
speaker: 0,
text: None,
};
assert!((turn.duration() - 2.5).abs() < 1e-9);
}
}
#[cfg(test)]
mod guide_tests {
use super::*;
/// **F-110.** The plan printed in the user guide has to parse.
///
/// The guide is where somebody writing their first plan copies from, and
/// the example it prints was rejected: `unknown keyword "turn 19.000"`.
/// The parser wanted exactly two spaces between every field, and the
/// guide's third turn line uses one, because `19.000` is a digit wider
/// than `4.100` and the columns were lined up by eye.
///
/// Neither was wrong on its own. The example is what a person would write
/// and the parser was stricter than it needed to be about the fields that
/// cannot contain a space, and nothing compared the two.
///
/// So this reads the guide rather than a copy of it. A copy would drift,
/// which is the failure it is here to prevent: F-71 is the same shape, and
/// so is F-103.
#[test]
fn the_plan_in_the_user_guide_parses() {
let guide = include_str!("../../../docs/USER_GUIDE.md").replace("\r\n", "\n");
let mut blocks = Vec::new();
let mut current: Option<Vec<&str>> = None;
for line in guide.lines() {
if line.starts_with("```") {
if let Some(block) = current.take() {
blocks.push(block.join("\n"));
} else {
current = Some(Vec::new());
}
continue;
}
if let Some(block) = current.as_mut() {
block.push(line);
}
}
let plans: Vec<&String> = blocks
.iter()
.filter(|b| b.trim_start().starts_with(MAGIC))
.collect();
assert!(
!plans.is_empty(),
"the user guide no longer shows a plan beginning {MAGIC}, so either \
the format changed or this test has stopped reading the guide"
);
for plan in plans {
let parsed = Conversation::parse(plan).unwrap_or_else(|e| {
panic!(
"the plan printed in docs/USER_GUIDE.md does not parse: {e}\n\
Somebody copying it to write their first plan gets this.\n\
---\n{plan}\n---"
)
});
assert!(
!parsed.speakers().is_empty() && !parsed.turns().is_empty(),
"the guide's plan parsed to nothing useful"
);
}
}
/// One space, two spaces, and a tab all separate the numbers.
///
/// The columns in a hand-written plan are lined up by eye, so what falls
/// between two numbers is whatever made them line up that day.
#[test]
fn the_numbers_may_be_separated_by_any_whitespace() {
let plans = [
"VEILCONV1\nspeaker 0 Me\nturn 0.0 1.0 0 hello\n",
"VEILCONV1\nspeaker 0 Me\nturn 0.0 1.0 0 hello\n",
"VEILCONV1\nspeaker\t0\tMe\nturn\t0.0\t1.0\t0\thello\n",
"VEILCONV1\nspeaker 0 Me\nturn 0.0 1.0 0 hello\n",
];
for plan in plans {
let parsed =
Conversation::parse(plan).unwrap_or_else(|e| panic!("{plan:?} did not parse: {e}"));
assert_eq!(parsed.speakers().len(), 1, "{plan:?}");
assert_eq!(parsed.turns().len(), 1, "{plan:?}");
assert_eq!(parsed.turns()[0].text.as_deref(), Some("hello"), "{plan:?}");
}
}
/// A name and a subtitle keep the single spaces inside them.
///
/// This is what the two-space rule was for, and it still holds: the fields
/// that can contain a space are still separated by two.
#[test]
fn names_and_subtitles_keep_their_own_spaces() {
let plan = "VEILCONV1\nspeaker 0 Sam Smith\nturn 0.0 1.0 0 So, how did it go?\n";
let parsed = Conversation::parse(plan).expect("parses");
assert_eq!(parsed.speakers()[0].name, "Sam Smith");
assert_eq!(
parsed.turns()[0].text.as_deref(),
Some("So, how did it go?")
);
}
}