crates/veilvoice-cli/src/conversation.rs
what this file is for · veilvoice-cli · 1090 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
//! `veilvoice conversation` -- several speakers, a voice each, and subtitles.
//!
//! The command-line front end to [`veilvoice_conversation`]. That crate holds
//! the plan, the renderer and the honest account of what a conversation keeps;
//! this file reads the audio, writes the results and prints what happened.
//!
//! # What comes out
//!
//! Three files beside the output you name:
//!
//! ```text
//! out.veiled.wav the audio, one destination voice per speaker
//! out.veiled.vtt subtitles for a browser
//! out.veiled.srt subtitles for everything else
//! out.veiled.html with `--page`: a player that needs nothing installed
//! ```
//!
//! # The page, and why it is not a video
//!
//! `--page` writes a self-contained HTML player: the waveform, a circle per
//! speaker that lights when they speak, and the subtitles. It references the
//! audio and the WebVTT track by relative name rather than embedding them, so
//! the files move together and the page does not double the size of a recording
//! already sitting beside it.
//!
//! A *video* file needs an encoder, and this project ships no codec. `preview`
//! prints the `ffmpeg` command that would make one, and says whether `ffmpeg`
//! is on this machine -- it never runs it. Nothing here silently depends on a
//! program the user did not know they were running.
//!
//! The subtitles are written whether or not anybody wrote down the words. With
//! every voice replaced, a caption track saying *who* is talking is often the
//! only way to follow a recording at all.
//!
//! # The one warning this command will not let you miss
//!
//! Audio no turn claims is **silenced**, never passed through. A gap in the
//! plan is a span nobody assigned to a speaker, so it has not been veiled, and
//! putting it into the result would place a real voice inside a file whose
//! whole purpose is that it contains none. The amount is printed, loudly,
//! because a plan with a hole in it is something to fix rather than something
//! to discover later.
//!
//! # This does not encrypt what it writes
//!
//! Unlike `anonymise`, which seals its output at rest by default. A conversation
//! render produces a set of files -- audio and two subtitle tracks -- and the
//! container this project uses seals one thing. Rather than invent a
//! half-answer, the files are written in the clear and the command says so:
//! `veilvoice encrypt` seals the audio afterwards, and the subtitles hold
//! whatever names were typed and are not veiled by anything.
//!
//! # In plain words
//!
//! The command line for recordings with several people in them: a voice each,
//! subtitles, a picture, and the command that would turn it into a video.
//!
//! All the actual work lives in the shared crate; this is the part that reads what
//! you typed, prints what it is about to do, and reports what it wrote.
use crate::theme::{colour, err, field, heading, ok, paint, warn};
use std::path::{Path, PathBuf};
use veilvoice_audio::io as audio_io;
use veilvoice_conversation::mode::VoiceMode;
use veilvoice_conversation::render::{self, Settings};
use veilvoice_conversation::subtitles::{self, Format};
use veilvoice_conversation::Conversation;
use veilvoice_core::DeidConfig;
use veilvoice_video::page::{Background, Look};
use veilvoice_video::{ffmpeg, page, waveform};
/// How many columns of waveform to reduce a recording to.
///
/// One per two pixels of a 1280-wide picture, which is finer than any display
/// resolves and coarse enough that the path stays a few kilobytes. The page
/// scales it to whatever width was asked for.
const WAVE_COLUMNS: usize = 640;
/// One correction to make to a plan.
///
/// A plain enum rather than the clap type, so this module is testable without
/// building a command line. `main.rs` converts.
#[derive(Clone, Debug, PartialEq)]
pub enum Fix {
/// Give the stretch at `at` to `to`.
Reassign { at: f64, to: usize },
/// Cut the stretch at `at` in two.
Split { at: f64 },
/// Join the stretch at `at` with the one at `with`.
Merge { at: f64, with: f64 },
/// Move the edges of the stretch at `at`.
Move { at: f64, start: f64, end: f64 },
/// Rename a speaker.
Name { speaker: usize, to: String },
/// Recolour a speaker, or return them to the palette.
Colour { speaker: usize, to: Option<String> },
}
/// Correct a plan in place, and say exactly what changed.
///
/// # Written back only after it worked
///
/// The plan is read, changed in memory, and only then written. A correction
/// that is refused leaves the file byte for byte as it was, so a mistyped
/// command cannot leave a plan half-edited. A half-edited plan is the worst
/// outcome available here: it looks fine, and it renders somebody in the wrong
/// voice, which nobody can hear.
///
/// # What is printed
///
/// The before and the after, for the spans that changed. A correction that says
/// only "done" leaves somebody to open the file and check, and the whole point
/// of these commands is that checking by ear is not possible.
pub fn fix(path: &Path, what: Fix) -> Result<(), String> {
let mut plan = load_plan(path)?;
let names: Vec<String> = plan.speakers().iter().map(|s| s.name.clone()).collect();
let before = describe(&plan);
match &what {
Fix::Reassign { at, to } => {
let turn = plan.reassign_at(*at, *to).map_err(|e| e.to_string())?;
let span = &plan.turns()[turn];
println!(
"{}",
ok(&format!(
"{:.3} s to {:.3} s is now {}, and was {}",
span.start,
span.end,
names.get(*to).map(String::as_str).unwrap_or("?"),
before
.get(turn)
.map(String::as_str)
.unwrap_or("something else")
))
);
}
Fix::Split { at } => {
let (first, second) = plan.split_at(*at).map_err(|e| e.to_string())?;
let (a, b) = (&plan.turns()[first], &plan.turns()[second]);
println!(
"{}",
ok(&format!(
"cut at {at:.3} s into {:.3}-{:.3} and {:.3}-{:.3}, both still {}",
a.start,
a.end,
b.start,
b.end,
names.get(a.speaker).map(String::as_str).unwrap_or("?")
))
);
println!(
"{}",
warn(
"both halves are still the same speaker. Reassign whichever \
one is wrong."
)
);
}
Fix::Merge { at, with } => {
let first = plan
.turn_at(*at)
.ok_or_else(|| format!("nothing is assigned at {at} s"))?;
let second = plan
.turn_at(*with)
.ok_or_else(|| format!("nothing is assigned at {with} s"))?;
let joined = plan.merge(first, second).map_err(|e| e.to_string())?;
let span = &plan.turns()[joined];
println!(
"{}",
ok(&format!(
"joined into {:.3} s to {:.3} s, {}",
span.start,
span.end,
names.get(span.speaker).map(String::as_str).unwrap_or("?")
))
);
}
Fix::Move { at, start, end } => {
let turn = plan
.turn_at(*at)
.ok_or_else(|| format!("nothing is assigned at {at} s"))?;
let was = before.get(turn).cloned().unwrap_or_default();
plan.move_edges(turn, *start, *end)
.map_err(|e| e.to_string())?;
println!(
"{}",
ok(&format!("{was} now runs {start:.3} s to {end:.3} s"))
);
}
Fix::Name { speaker, to } => {
let was = names.get(*speaker).cloned().unwrap_or_default();
plan.set_name(*speaker, to).map_err(|e| e.to_string())?;
println!(
"{}",
ok(&format!("speaker {speaker} was {was:?}, now {to:?}"))
);
println!(
"{}",
warn(
"a name is a label and not part of the veiling. A real name \
here is a real name in the subtitles."
)
);
}
Fix::Colour { speaker, to } => {
plan.set_colour(*speaker, to.as_deref())
.map_err(|e| e.to_string())?;
match to {
Some(colour) => println!(
"{}",
ok(&format!("speaker {speaker} is now drawn in {colour}"))
),
None => println!(
"{}",
ok(&format!(
"speaker {speaker} is back to the colour their slot is given"
))
),
}
}
}
plan.save(path)
.map_err(|e| format!("{}: {e}", path.display()))?;
println!("{}", field("written", &path.display().to_string()));
Ok(())
}
/// Every span as `speaker, start to end`, for reporting what a change replaced.
fn describe(plan: &Conversation) -> Vec<String> {
plan.turns()
.iter()
.map(|turn| {
format!(
"{} at {:.3}-{:.3} s",
plan.speakers()
.get(turn.speaker)
.map(|s| s.name.as_str())
.unwrap_or("?"),
turn.start,
turn.end
)
})
.collect()
}
/// Turn the picture flags into a [`Look`], or explain why they do not describe
/// a picture that can be drawn.
///
/// `checked()` is the crate's own refusal, and it refuses rather than clamps:
/// somebody who asked for a 200-pixel render of nine speakers meant something,
/// and quietly drawing an illegible one answers a question they did not ask.
pub fn look_from(
width: u32,
height: u32,
padding: u32,
background: Option<String>,
black: bool,
theme: Option<String>,
) -> Result<Look, String> {
let mut look = Look {
width,
height,
padding,
..Look::default()
};
// Resolved here, where the user typed it, so that every drawing after this
// is looking at colours that exist. An unknown name is refused and the
// refusal lists what it could have been: a picture silently drawn in a
// different scheme than the one asked for is worse than an error.
if let Some(theme) = theme {
let palette = veilvoice_video::palette::by_id(&theme).ok_or_else(|| {
format!(
"{theme:?} is not a theme. It is one of: {}",
veilvoice_video::palette::ids().join(", ")
)
})?;
look = look.themed(palette);
}
if let Some(background) = background {
// A value that names a file that exists is an image; anything else is
// read as a colour, and `checked()` rejects it if it is not one. Tried
// in that order so `--background red.png` cannot be mistaken for a
// colour called "red.png" on a machine where the file is missing.
let as_path = PathBuf::from(&background);
look.background = if as_path.is_file() {
Background::Image(as_path)
} else {
Background::Colour(background)
};
}
if black {
look = look.black();
}
look.checked().map_err(|error| error.to_string())?;
Ok(look)
}
/// Show a plan without rendering anything.
pub fn inspect(plan_path: &Path) -> Result<(), String> {
let plan = load_plan(plan_path)?;
println!("{}", heading("Conversation"));
if let Some(title) = &plan.title {
println!("{}", field("title", title));
}
println!("{}", field("speakers", &plan.len().to_string()));
println!("{}", field("turns", &plan.turns().len().to_string()));
println!("{}", field("length", &format!("{:.2} s", plan.duration())));
println!();
for (index, speaker) in plan.speakers().iter().enumerate() {
let voice = plan.voice(index);
println!("{}", paint(colour::BLUE, &speaker.name));
println!("{}", field("voice", &voice.describe()));
let spoken: f64 = plan
.turns()
.iter()
.filter(|turn| turn.speaker == index)
.map(|turn| turn.duration())
.sum();
println!("{}", field("speaks for", &format!("{spoken:.2} s")));
if let Some(picture) = &speaker.picture {
println!("{}", field("picture", &picture.display().to_string()));
}
println!();
}
let overlaps = plan.overlaps();
if !overlaps.is_empty() {
println!(
"{}",
field(
"interruptions",
&format!("{} -- these will be mixed, not resolved", overlaps.len())
)
);
}
let self_overlaps = plan.self_overlaps();
if !self_overlaps.is_empty() {
println!(
"{}",
warn(&format!(
"{} place(s) where one speaker overlaps themselves. One person cannot \
be in two places in their own recording, so this is usually a typed \
time.",
self_overlaps.len()
))
);
}
println!();
println!("{}", paint(colour::YELLOW, "WHAT A CONVERSATION KEEPS"));
for line in crate::sentry::wrap(veilvoice_conversation::SCOPE, 72) {
println!(" {line}");
}
Ok(())
}
/// Render a recording according to a plan.
///
/// `look` is `Some` when `--page` was asked for, and it decides what the page
/// looks like. `None` writes the audio and the subtitles and nothing else.
pub fn run(
plan_path: &Path,
input: &Path,
output: Option<PathBuf>,
tuning: DeidConfig,
look: Option<Look>,
one_voice: bool,
) -> Result<(), String> {
let mut plan = load_plan(plan_path)?;
if plan.is_empty() {
return Err("the plan names no speakers, so there is nothing to render into".into());
}
// Set before anything is read or rendered. The refusal here is the useful
// one -- it names the alternative -- and it costs nothing to hit it before
// a long file has been decoded.
let mode = if one_voice {
VoiceMode::Uniform
} else {
VoiceMode::Distinct
};
plan.set_mode(mode, &tuning)
.map_err(|error| error.to_string())?;
let out_path = output.unwrap_or_else(|| {
let mut path = input.to_path_buf();
path.set_extension("veiled.wav");
path
});
let audio = audio_io::load(input).map_err(|error| error.to_string())?;
println!("{}", heading("Input"));
println!("{}", field("file", &input.display().to_string()));
println!(
"{}",
field("duration", &format!("{:.2} s", audio.duration_secs()))
);
println!(
"{}",
field("sample rate", &format!("{} Hz", audio.sample_rate))
);
println!("{}", field("speakers", &plan.len().to_string()));
println!("{}", field("voices", mode.label()));
if mode == VoiceMode::Uniform {
println!();
for line in crate::sentry::wrap(mode.note(), 72) {
println!(" {line}");
}
}
println!();
// The engine settings come from the same flags `anonymise` uses, with the
// sample rate taken from the file rather than from a default that would
// silently resample nothing and mistime everything.
let mut settings = Settings {
config: tuning,
..Settings::default()
};
settings.config.sample_rate = audio.sample_rate as f32;
let started = std::time::Instant::now();
let rendered = render::render(&plan, &audio.samples, &settings, None)
.map_err(|error| error.to_string())?;
let elapsed = started.elapsed().as_secs_f32();
println!("{}", heading("Rendered"));
for (index, speaker) in plan.speakers().iter().enumerate() {
println!(
"{}",
field(
&speaker.name,
&format!(
"{:.2} s as {}",
rendered.per_speaker_secs[index],
plan.voice(index).describe()
)
)
);
}
println!("{}", field("took", &format!("{elapsed:.2} s")));
// Printed at error weight rather than as a note. A gap in the plan is the
// one mistake here that costs content, and it is invisible in a waveform
// somebody skims.
if rendered.has_unassigned() {
println!();
println!(
"{}",
err(&format!(
"{:.2} s of the recording is not claimed by any turn",
rendered.unassigned_secs
))
);
println!(" That audio was SILENCED, not passed through: nobody assigned it to");
println!(" a speaker, so it has not been veiled, and putting it into the");
println!(" result would place a real voice in a file whose whole purpose is");
println!(" that it contains none. Add a turn covering it and render again.");
}
for note in &rendered.notes {
// The unassigned note is already printed above, in stronger words.
if note.contains("was silenced") {
continue;
}
println!("{}", warn(note));
}
let veiled = veilvoice_audio::io::Audio {
samples: rendered.samples,
sample_rate: audio.sample_rate,
};
// Owner-only, all four of them.
//
// `anonymise` writes its result 0600 even on the path where somebody has
// explicitly turned encryption off, and says so while doing it. These four
// were written 0644, by the ordinary `fs::write`, readable by every other
// account on the machine.
//
// That is the wrong way round. A conversation's output carries strictly
// more than a single speaker's: the subtitles hold the names somebody
// typed and the words they typed, in plain text, and the plan's shape says
// who spoke when and for how long. The file with more in it had the weaker
// permission, and the difference was not a decision anybody took -- one
// path used the hardened write and the other used the default.
//
// Not disk encryption, and the note below still says so. It is the same
// file permission the rest of this program applies, applied here too.
write_private(
&out_path,
&audio_io::wav_bytes(&veiled).map_err(|e| e.to_string())?,
)?;
let vtt = with_extension(&out_path, Format::WebVtt.extension());
let srt = with_extension(&out_path, Format::SubRip.extension());
let vtt_text = subtitles::write(&plan, Format::WebVtt);
write_private(&vtt, vtt_text.as_bytes())?;
write_private(&srt, subtitles::write(&plan, Format::SubRip).as_bytes())?;
// The page is written from the *veiled* audio, so its waveform is the
// waveform of what somebody will actually hear. Drawing the input's would
// put a picture of the original signal beside a file whose whole point is
// that the original is gone.
let mut page_path = None;
if let Some(look) = &look {
let envelope = waveform::envelope(&veiled.samples, WAVE_COLUMNS);
// The captions are inlined rather than referenced. A browser treats
// every `file:` URL as its own origin, so a track loaded from the file
// beside the page is refused when somebody opens the page from a
// folder, which is how they open it. The separate `.vtt` is still
// written for every other player.
let drawn = page::player(
&plan,
&envelope,
look,
&file_name(&out_path),
&page::inline_vtt(&vtt_text),
)
.map_err(|error| error.to_string())?;
let html = with_extension(&out_path, "html");
write_private(&html, drawn.markup.as_bytes())?;
for note in &drawn.notes {
println!("{}", warn(note));
}
page_path = Some(html);
}
println!();
println!("{}", ok(&format!("wrote {}", out_path.display())));
println!("{}", ok(&format!("wrote {}", vtt.display())));
println!("{}", ok(&format!("wrote {}", srt.display())));
if let Some(html) = &page_path {
println!("{}", ok(&format!("wrote {}", html.display())));
println!(" The page carries its own captions and reads the audio by name from");
println!(" the same directory. Move both, or neither.");
}
println!();
println!(
"{}",
warn(
"These files are not encrypted. `veilvoice encrypt` seals the audio. The \
subtitles hold whatever names you typed, and nothing veils a name. They \
are written readable only by your account, which is a file permission \
and nothing more: it does not survive a copy, a backup, or anyone who \
has the disk."
)
);
Ok(())
}
/// A still of what the page will look like, and the command that would make a
/// video of it.
///
/// Nothing is rendered and nothing is encoded. This exists so that the answer
/// to "what will I get" costs a second rather than the length of the recording.
pub fn preview(
plan_path: &Path,
audio: Option<PathBuf>,
at_secs: f64,
look: Look,
output: Option<PathBuf>,
show_ffmpeg: Option<veilvoice_video::size::Plan>,
one_voice: bool,
) -> Result<(), String> {
let mut plan = load_plan(plan_path)?;
if plan.is_empty() {
return Err("the plan names no speakers, so there is nothing to draw".into());
}
let mode = if one_voice {
VoiceMode::Uniform
} else {
VoiceMode::Distinct
};
plan.set_mode(mode, &DeidConfig::default())
.map_err(|error| error.to_string())?;
// With no audio the waveform is flat rather than absent: the picture is
// about the layout, and an empty band is honest about there being nothing
// measured yet. With audio it is that file's real envelope.
let envelope = match &audio {
Some(path) => {
let loaded = audio_io::load(path).map_err(|error| error.to_string())?;
waveform::envelope(&loaded.samples, WAVE_COLUMNS)
}
None => waveform::envelope(&vec![0.0; WAVE_COLUMNS], WAVE_COLUMNS),
};
let drawn = page::still(&plan, &envelope, &look, at_secs).map_err(|e| e.to_string())?;
let out_path = output.unwrap_or_else(|| with_extension(plan_path, "preview.svg"));
write_private(&out_path, drawn.markup.as_bytes())?;
println!("{}", heading("Preview"));
println!("{}", field("plan", &plan_path.display().to_string()));
println!("{}", field("speakers", &plan.len().to_string()));
println!(
"{}",
field("picture", &format!("{}x{}", look.width, look.height))
);
println!("{}", field("theme", look.palette.name));
println!("{}", field("at", &format!("{at_secs:.2} s")));
println!("{}", field("voices", mode.label()));
if audio.is_none() {
println!("{}", field("waveform", "flat -- no recording was given"));
}
for note in &drawn.notes {
println!("{}", warn(note));
}
println!();
println!("{}", ok(&format!("wrote {}", out_path.display())));
println!();
println!("{}", heading("Which voice each speaker becomes"));
if mode == VoiceMode::Uniform {
for line in crate::sentry::wrap(mode.note(), 72) {
println!(" {line}");
}
println!();
}
for (index, speaker) in plan.speakers().iter().enumerate() {
println!(
"{}",
field(
&speaker.name,
&format!(
"{} -- {}",
plan.voice(index).describe(),
plan.colour_of(index, veilvoice_video::palette::speaker(index))
)
)
);
}
if let Some(render) = show_ffmpeg {
println!();
println!("{}", heading("Making a video of it"));
// The command printed is the one for the size and rate that were asked
// for, not a default. Printing a command that would produce a different
// video from the picture above it is the sort of thing somebody copies
// and only notices afterwards.
let estimate = render.estimate(plan.duration());
println!(
"{}",
field(
"frames to draw",
&format!(
"{} at {}, {} frames a second",
estimate.frames,
render.size.label(),
render.fps.get()
)
)
);
println!(
"{}",
field(
"scratch space",
&format!(
"about {}",
veilvoice_video::size::human_bytes(estimate.scratch_bytes)
)
)
);
println!();
let argv = ffmpeg::command(
Path::new("frames"),
"frame-%05d.png",
Path::new("out.veiled.wav"),
Path::new("out.mp4"),
ffmpeg::Encoding {
plan: render,
..Default::default()
},
);
println!(" {}", ffmpeg::command_line(&argv));
println!();
match ffmpeg::found() {
Some(path) => println!("{}", ok(&format!("ffmpeg is here: {}", path.display()))),
None => println!(
"{}",
warn("ffmpeg was not found on PATH. The page above needs nothing installed.")
),
}
println!();
for line in crate::sentry::wrap(&ffmpeg::describe(), 72) {
println!(" {line}");
}
println!();
println!(
"{}",
warn("VeilVoice never runs this for you. Copy it, read it, and run it yourself.")
);
}
Ok(())
}
/// The last component of a path, for writing into a page as a relative link.
///
/// A page that referenced the absolute path would break the moment the three
/// files were moved together, which is the one thing they are meant to survive.
fn file_name(path: &Path) -> String {
path.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default()
}
/// Read a plan, and say where it went wrong rather than only that it did.
fn load_plan(path: &Path) -> Result<Conversation, String> {
Conversation::load(path).map_err(|error| format!("{}: {error}", path.display()))
}
/// Replace the last extension, keeping any `.veiled` before it.
/// Write a file only this account can read, naming it if that fails.
///
/// Everything a conversation produces goes through here. The audio, both
/// subtitle tracks and the page all used the ordinary `fs::write`, which is
/// 0644, while `anonymise` wrote its result 0600 even when somebody had turned
/// encryption off on purpose.
///
/// The conversation output is the one carrying more: the subtitles hold the
/// names and the words as typed, and the timings say who spoke when. It had
/// the weaker permission because one path called the hardened write and the
/// other called the default, not because anybody decided it should.
fn write_private(path: &Path, bytes: &[u8]) -> Result<(), String> {
veilvoice_crypto::privatefile::write_owner_only(path, bytes)
.map_err(|error| format!("{}: {error}", path.display()))
}
fn with_extension(path: &Path, extension: &str) -> PathBuf {
let mut out = path.to_path_buf();
out.set_extension(extension);
out
}
#[cfg(test)]
mod tests {
use super::*;
use veilvoice_conversation::{Speaker, Turn};
fn plan_file(dir: &Path) -> PathBuf {
let mut plan = Conversation::new();
plan.title = Some("Two people".into());
plan.add_speaker(Speaker::named("Alex")).unwrap();
plan.add_speaker(Speaker::named("Sam")).unwrap();
plan.add_turn(Turn {
start: 0.0,
end: 0.5,
speaker: 0,
text: Some("Hello".into()),
})
.unwrap();
plan.add_turn(Turn {
start: 0.5,
end: 1.0,
speaker: 1,
text: None,
})
.unwrap();
let path = dir.join("plan.txt");
plan.save(&path).unwrap();
path
}
fn wav_file(dir: &Path, seconds: f32) -> PathBuf {
let rate = 48_000u32;
let len = (seconds * rate as f32) as usize;
let samples: Vec<f32> = (0..len)
.map(|i| {
let t = i as f32 / rate as f32;
0.3 * (2.0 * std::f32::consts::PI * 150.0 * t).sin()
})
.collect();
let audio = veilvoice_audio::io::Audio {
samples,
sample_rate: rate,
};
let path = dir.join("input.wav");
audio_io::save_wav(&path, &audio).unwrap();
path
}
#[test]
fn inspecting_a_plan_needs_no_audio() {
let dir = tempfile::tempdir().unwrap();
inspect(&plan_file(dir.path())).expect("a plan alone is enough to describe");
}
#[test]
fn a_missing_plan_says_which_file() {
let error = inspect(Path::new("definitely-not-here.txt")).expect_err("no such file");
assert!(error.contains("definitely-not-here.txt"), "{error}");
}
/// The whole command, end to end: audio in, audio and two subtitle tracks
/// out, and the output the same length as the input.
#[test]
fn rendering_writes_the_audio_and_both_subtitle_tracks() {
let dir = tempfile::tempdir().unwrap();
let plan = plan_file(dir.path());
let input = wav_file(dir.path(), 1.0);
let output = dir.path().join("out.wav");
run(
&plan,
&input,
Some(output.clone()),
DeidConfig::default(),
None,
false,
)
.expect("the render should succeed");
assert!(output.exists(), "no audio was written");
let vtt = dir.path().join("out.vtt");
let srt = dir.path().join("out.srt");
assert!(vtt.exists(), "no WebVTT was written");
assert!(srt.exists(), "no SubRip was written");
let written = std::fs::read_to_string(&vtt).unwrap();
assert!(written.starts_with("WEBVTT"), "{written}");
assert!(written.contains("Alex: Hello"), "{written}");
assert!(
written.contains("Sam"),
"a turn with no words still names its speaker: {written}"
);
let veiled = audio_io::load(&output).unwrap();
let original = audio_io::load(&input).unwrap();
assert_eq!(veiled.sample_rate, original.sample_rate);
assert_eq!(veiled.samples.len(), original.samples.len());
assert!(veiled.samples.iter().all(|s| s.is_finite()));
}
/// Nothing a render writes is readable by another account.
///
/// All four files used the ordinary `fs::write`, which is 0644, while
/// `anonymise` wrote its result 0600 even where somebody had turned
/// encryption off on purpose. The conversation output is the one carrying
/// the names and the words as typed, so it had the weaker permission on
/// the more revealing file, and nobody had decided that: one path called
/// the hardened write and the other called the default.
#[cfg(unix)]
#[test]
fn everything_a_render_writes_is_readable_only_by_this_account() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let plan = plan_file(dir.path());
let input = wav_file(dir.path(), 1.0);
let output = dir.path().join("out.wav");
run(
&plan,
&input,
Some(output.clone()),
DeidConfig::default(),
Some(Look::default()),
false,
)
.expect("the render should succeed");
for name in ["out.wav", "out.vtt", "out.srt", "out.html"] {
let path = dir.path().join(name);
assert!(path.exists(), "{name} was not written");
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode, 0o600,
"{name} is {mode:o}, so another account on this machine can read it"
);
}
}
/// The output name is derived from the input when none is given.
#[test]
fn the_default_output_sits_beside_the_input() {
let dir = tempfile::tempdir().unwrap();
let plan = plan_file(dir.path());
let input = wav_file(dir.path(), 1.0);
run(&plan, &input, None, DeidConfig::default(), None, false).expect("should succeed");
assert!(dir.path().join("input.veiled.wav").exists());
}
#[test]
fn a_plan_with_no_speakers_is_refused_before_the_audio_is_read() {
let dir = tempfile::tempdir().unwrap();
let empty = dir.path().join("empty.txt");
Conversation::new().save(&empty).unwrap();
let error = run(
&empty,
Path::new("this-file-does-not-exist.wav"),
None,
DeidConfig::default(),
None,
false,
)
.expect_err("an empty plan is refused");
assert!(error.contains("no speakers"), "{error}");
}
/// `--page` writes a fourth file, and it points at the other three by name.
#[test]
fn a_page_is_written_beside_the_audio_and_links_to_it_by_name() {
let dir = tempfile::tempdir().unwrap();
let plan = plan_file(dir.path());
let input = wav_file(dir.path(), 1.0);
run(
&plan,
&input,
None,
DeidConfig::default(),
Some(Look::default()),
false,
)
.expect("should succeed");
let html = dir.path().join("input.veiled.html");
let markup = std::fs::read_to_string(&html).expect("the page should be written");
// The audio by relative name, so the two files move together. An
// absolute path here would break the moment somebody copied the
// directory, which is the one thing this arrangement exists to survive.
assert!(markup.contains("input.veiled.wav"), "{markup:.400}");
assert!(
!markup.contains(&dir.path().display().to_string()),
"the page must not carry an absolute path"
);
// The captions are **carried**, not named. A browser treats every
// `file:` URL as its own origin, so a track fetched from the file
// beside the page is refused and the captions silently do not appear,
// in a page whose own text promises they will. The separate `.vtt` is
// still written, for every other player.
assert!(
markup.contains("data:text/vtt;base64,"),
"the captions are not in the page"
);
assert!(
!markup.contains("src=\"input.veiled.vtt\""),
"the captions are fetched by name again"
);
assert!(
dir.path().join("input.veiled.vtt").is_file(),
"the separate subtitle file should still be written"
);
}
/// No `--page`, no page. The default is three files, as it always was.
#[test]
fn without_the_flag_no_page_is_written() {
let dir = tempfile::tempdir().unwrap();
let plan = plan_file(dir.path());
let input = wav_file(dir.path(), 1.0);
run(&plan, &input, None, DeidConfig::default(), None, false).expect("should succeed");
assert!(!dir.path().join("input.veiled.html").exists());
}
/// A picture nobody could read is refused rather than drawn.
#[test]
fn a_look_that_cannot_be_drawn_is_refused_with_the_numbers_in_it() {
let error = look_from(200, 100, 8, None, false, None).expect_err("too small to draw");
assert!(error.contains("200x100"), "{error}");
}
/// `--background` takes a colour or a file, and says so when it is neither.
#[test]
fn a_background_that_is_neither_a_colour_nor_a_file_is_named_in_the_error() {
let error = look_from(1280, 720, 48, Some("chartreuse".into()), false, None)
.expect_err("not a colour");
assert!(error.contains("chartreuse"), "{error}");
}
/// `--black` wins over `--background`, as its help says.
#[test]
fn black_overrides_a_background_colour() {
let look = look_from(1280, 720, 48, Some("#ff0000".into()), true, None).unwrap();
assert_eq!(
look.background,
Background::Colour("#000000".to_string()),
"--black must override --background"
);
}
/// The default is Tokyo Night, and it is what a picture with no `--theme`
/// is drawn in.
#[test]
fn no_theme_means_tokyo_night() {
let look = look_from(1280, 720, 48, None, false, None).unwrap();
assert_eq!(look.palette.id, "tokyo-night");
}
/// A named theme reaches the drawing, and takes the background with it.
/// A Gruvbox picture on a Tokyo Night page is not what anybody asked for.
#[test]
fn a_named_theme_takes_the_background_with_it() {
let look = look_from(1280, 720, 48, None, false, Some("gruvbox".into())).unwrap();
assert_eq!(look.palette.id, "gruvbox");
assert_eq!(
look.background,
Background::Colour(look.palette.bg.to_string()),
"the page should follow the theme"
);
}
/// Unless the background was asked for separately, in which case both
/// requests are honoured.
#[test]
fn a_background_given_by_hand_survives_a_theme() {
let look = look_from(
1280,
720,
48,
Some("#123456".into()),
false,
Some("nord".into()),
)
.unwrap();
assert_eq!(look.palette.id, "nord");
assert_eq!(look.background, Background::Colour("#123456".to_string()));
}
/// An unknown name is refused, and the refusal says what it could have
/// been. An error that only says "no" leaves the reader guessing at a
/// spelling.
#[test]
fn an_unknown_theme_is_refused_and_lists_the_real_ones() {
let error = look_from(1280, 720, 48, None, false, Some("solarised".into()))
.expect_err("not a theme this build has");
assert!(error.contains("solarised"), "{error}");
assert!(error.contains("tokyo-night"), "{error}");
assert!(error.contains("gruvbox"), "{error}");
}
/// The colours actually reach the markup. Without this the flag could be
/// plumbed all the way through and change nothing anybody can see.
#[test]
fn the_theme_changes_the_colours_in_the_drawing() {
let dir = tempfile::tempdir().unwrap();
let plan = plan_file(dir.path());
let gruvbox = dir.path().join("gruvbox.svg");
preview(
&plan,
None,
0.0,
look_from(1280, 720, 48, None, false, Some("gruvbox".into())).unwrap(),
Some(gruvbox.clone()),
None,
false,
)
.unwrap();
let markup = std::fs::read_to_string(&gruvbox).unwrap();
let wanted = veilvoice_video::palette::by_id("gruvbox").unwrap();
assert!(
markup.contains(wanted.bg),
"the page should be {}",
wanted.bg
);
assert!(
!markup.contains(veilvoice_video::palette::BG),
"no Tokyo Night background should survive a Gruvbox render"
);
}
/// A preview needs no recording, and writes a still that is real SVG.
#[test]
fn a_preview_draws_without_any_audio_at_all() {
let dir = tempfile::tempdir().unwrap();
let plan = plan_file(dir.path());
let out = dir.path().join("still.svg");
preview(
&plan,
None,
0.25,
Look::default(),
Some(out.clone()),
None,
false,
)
.expect("a preview needs no audio");
let markup = std::fs::read_to_string(&out).unwrap();
assert!(markup.starts_with("<svg"), "{markup:.120}");
assert!(markup.contains("Alex"), "the speakers should be named");
}
#[test]
fn the_subtitle_names_follow_the_audio_extension() {
let path = PathBuf::from("/somewhere/out.veiled.wav");
assert_eq!(
with_extension(&path, "vtt"),
PathBuf::from("/somewhere/out.veiled.vtt")
);
}
}