crates/veilvoice-cli/src/record.rs
what this file is for · veilvoice-cli · 406 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 record` -- capture the veiled voice straight into an encrypted
//! file.
//!
//! # What this is, next to the commands beside it
//!
//! `veilvoice live` veils the voice and sends it to a device, keeping nothing.
//! `veilvoice anonymise` veils a recording somebody already made, which means
//! the original exists, in the clear, on their disk, and stays there unless
//! they remember to shred it.
//!
//! This is the third case and the one that leaves the least behind: the
//! microphone goes in, the veiled voice comes out, and the only file that ever
//! exists is the encrypted one.
//!
//! # Never a plaintext file, not even briefly
//!
//! The recording is accumulated in a `Tape`, encoded
//! into a `Secret`, and sealed from there. At no
//! point is there a WAV on disk to be deleted afterwards, because a plaintext
//! file that is written and deleted is exactly what
//! `veilvoice_crypto::shred` explains cannot be reliably taken back on flash
//! storage. Writing one and encrypting it afterwards would leave the original
//! recoverable and the file merely tidy.
//!
//! # Why it stops on a keypress rather than on Ctrl-C
//!
//! Ctrl-C ends the process, and a recording that ends by killing the process
//! is a recording that is never sealed and never written. Catching the signal
//! instead would mean a signal-handling dependency for one command, and this
//! project argues at length against dependencies nobody has read.
//!
//! So it stops on Enter, or after `--seconds`. Both are ordinary control flow,
//! reach the sealing step, and need nothing new in the dependency tree. Ctrl-C
//! still works and still abandons the recording, which is the correct thing for
//! it to do: it is how somebody says "stop, and keep nothing".
//!
//! # In plain words
//!
//! Records you with your voice already disguised, and saves it encrypted.
//!
//! There is never an unencrypted copy of the recording anywhere, not even for a
//! moment, so there is nothing to delete afterwards and nothing to recover from
//! the disk.
use crate::meter;
use crate::theme::{colour, field, heading, ok, paint, warn};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use veilvoice_audio::{devices, record};
/// How the recording is to be protected once it is made.
pub struct Sealing {
/// Seal to this recipient's public key rather than to a passphrase.
pub public_key: Option<PathBuf>,
/// Write it unencrypted. Asks first, loudly.
pub plaintext: bool,
/// Answer that prompt with yes.
pub yes: bool,
}
/// Run a recording session and seal what it captured.
#[allow(clippy::too_many_arguments)]
pub fn run(
input: Option<String>,
output: Option<String>,
seconds: Option<f32>,
to: Option<PathBuf>,
monitor: bool,
sealing: Sealing,
tuning: crate::Tuning,
) -> Result<(), String> {
// Refuse before recording, not after. Finding out that the output path is
// unusable once somebody has finished speaking would mean either losing the
// recording or holding it hostage to a prompt.
if sealing.plaintext && sealing.public_key.is_some() {
return Err(
"--plaintext and --to-public-key ask for opposite things; name one".to_string(),
);
}
let destination = destination(to)?;
let in_device =
devices::open(devices::Direction::Input, input.as_deref()).map_err(|e| e.to_string())?;
// The default is this machine's own output, unlike `live`. Recording is not
// routing: nobody is on the other end of a cable waiting for it, and
// sending it to one would put the recording into whatever is listening
// there as well as into the file.
let out_device =
devices::open(devices::Direction::Output, output.as_deref()).map_err(|e| e.to_string())?;
println!("{}", heading("Record, veiled and encrypted"));
println!("{}", field("Input", &devices::name_of(&in_device)));
println!("{}", field("Monitor", &devices::name_of(&out_device)));
println!(
"{}",
field(
"Accent",
if tuning.keep_accent {
"kept"
} else {
"neutralised"
}
)
);
println!("{}", field("Writes", &destination.display().to_string()));
if sealing.plaintext {
println!("{}", warn("unencrypted, once you have confirmed it"));
} else if sealing.public_key.is_some() {
println!("{}", field("Sealed to", "a recipient public key"));
} else {
println!(
"{}",
field("Sealed with", "a passphrase, asked for at the end")
);
}
println!(
"{}",
paint(
colour::MUTED,
" Use headphones: speakers plus a microphone is a feedback loop."
)
);
let config = crate::config(tuning);
// The session builds the recorder, at the rate the device agreed to. This
// used to build one here from `config.sample_rate`, which is the rate that
// was *asked* for, and the two disagree on any device not running at
// 48 kHz. See F-166 and `LiveSession::start_recording`.
let (session, kept) = veilvoice_audio::LiveSession::start_recording(
&in_device,
&out_device,
config,
veilvoice_audio::Keeping {
veiled: true,
plain: false,
},
)
.map_err(|e| e.to_string())?;
let mut recorder = kept
.veiled
.ok_or("the veiled recorder the session was asked for")?;
println!();
match seconds {
Some(n) => println!("{}", ok(&format!("recording for {n:.0} seconds"))),
None => println!("{}", ok("recording. Press Enter to stop and save.")),
}
println!(
"{}",
paint(
colour::MUTED,
" Ctrl-C abandons it instead, keeping nothing."
)
);
println!();
let stop = stop_signal(seconds);
const WIDTH: usize = 20;
let mut in_meter = meter::Channel::default();
let mut out_meter = meter::Channel::default();
while !stop.load(Ordering::Relaxed) {
std::thread::sleep(std::time::Duration::from_millis(50));
// Draining is what moves the audio out of the ring and into locked
// memory. Skipping it while the meters draw would lose the recording a
// ring-length at a time, so it happens every tick regardless of whether
// anything is being drawn.
recorder.drain();
if !monitor {
continue;
}
let s = session.stats();
let lost = if recorder.dropped() > 0 {
paint(
colour::YELLOW,
&format!(" lost {} samples", recorder.dropped()),
)
} else {
String::new()
};
// **Roadmap item 132.** What the platform said about the streams, on the
// line already being watched. A recording made while the microphone
// was taken away is a recording somebody has to know that about, and
// the report is repeated when it is stored.
let interfered = if s.interfered > 0 {
paint(colour::RED, &format!(" INTERRUPTED x{}", s.interfered))
} else {
String::new()
};
print!(
"\r {} {} {} {} {} {:>6.1}s{}{} ",
paint(colour::MUTED, " in"),
in_meter.update(s.input_peak, WIDTH),
paint(colour::MUTED, "out"),
out_meter.update(s.output_peak, WIDTH),
paint(colour::MUTED, "length"),
recorder.seconds(),
lost,
interfered,
);
use std::io::Write;
let _ = std::io::stdout().flush();
}
// What the platform reported about the streams, taken before the session
// goes: the counter lives with the session, and the answer is wanted after
// it has been dropped.
let interfered = session.stats().interfered;
// Stop the audio before sealing. Argon2id takes a noticeable moment, and
// samples arriving during it would be recorded after the point the person
// asked it to stop.
drop(session);
println!();
println!();
let length = recorder.seconds();
let wav = recorder.wav().map_err(|e| e.to_string())?;
// Read before the session is dropped, above, so this is the count for the
// take that was just made rather than for a session that no longer exists.
report(&recorder, length, wav.len(), interfered);
if sealing.plaintext {
crate::atrest::confirm_plaintext(sealing.yes)?;
veilvoice_crypto::privatefile::write_owner_only(&destination, wav.expose())
.map_err(|e| format!("{}: {e}", destination.display()))?;
println!("{}", field("written", &destination.display().to_string()));
println!("{}", warn("unencrypted, as you asked"));
return Ok(());
}
let recipient = match &sealing.public_key {
Some(path) => crate::atrest::Recipient::PublicKey(path),
None => crate::atrest::Recipient::Password,
};
let out = crate::atrest::seal_to_disk(&destination, wav.expose(), recipient)?;
println!("{}", ok(&format!("sealed to {}", out.display())));
Ok(())
}
/// What was captured, and what was actually obtained for it.
///
/// The locking line is the honest one: it says what the operating system
/// granted rather than what was asked for, because [`veilvoice_crypto::tape`]
/// cannot promise a lock and neither can this.
fn report(recorder: &record::Recorder, seconds: f32, bytes: usize, interfered: u64) {
println!("{}", field("length", &format!("{seconds:.1} s")));
println!("{}", field("size", &format!("{} KiB", bytes / 1024)));
// **Roadmap item 132.** Said here as well as on the meter line, because the
// meter line is gone by the time this is read and this is the moment
// somebody decides whether to keep what was recorded.
if interfered > 0 {
println!(
"{}",
warn(&format!(
"the audio path was interrupted {interfered} time(s) while this was \
recording, so it may be short or may have gaps"
))
);
println!(
"{}",
paint(
colour::MUTED,
" What each interruption was is printed above, as the platform said it."
)
);
}
if recorder.dropped() > 0 {
println!(
"{}",
warn(&format!(
"{} samples were lost: the recording has a gap",
recorder.dropped()
))
);
}
if recorder.fully_locked() {
println!(
"{}",
field("in memory", "held locked out of the page file throughout")
);
} else {
println!(
"{}",
warn("some of the recording could not be locked out of the page file")
);
println!(
"{}",
paint(
colour::MUTED,
" The operating system limits how much a program may lock, and this"
)
);
println!(
"{}",
paint(
colour::MUTED,
" recording was larger than that limit. It was still wiped from memory."
)
);
}
}
/// A flag that becomes true when the recording should stop.
///
/// Either after `seconds`, or when Enter is pressed. The reading thread is
/// detached and blocks on stdin: it is never joined, because a fixed-length
/// recording must not wait for a keypress that is not coming.
fn stop_signal(seconds: Option<f32>) -> Arc<AtomicBool> {
let stop = Arc::new(AtomicBool::new(false));
match seconds {
Some(n) => {
let stop = Arc::clone(&stop);
let millis = (n.max(0.0) * 1000.0) as u64;
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(millis));
stop.store(true, Ordering::Relaxed);
});
}
None => {
let stop = Arc::clone(&stop);
std::thread::spawn(move || {
let mut line = String::new();
let _ = std::io::stdin().read_line(&mut line);
stop.store(true, Ordering::Relaxed);
});
}
}
stop
}
/// Where the recording goes, defaulting to a timestamped name here.
///
/// The default carries the moment it was made rather than a counter, so two
/// recordings never race for the same name and the file says when it happened
/// without depending on a filesystem timestamp that a copy would not preserve.
fn destination(to: Option<PathBuf>) -> Result<PathBuf, String> {
if let Some(path) = to {
if path.as_os_str().is_empty() {
return Err("the output path is empty".to_string());
}
return Ok(path);
}
Ok(PathBuf::from(format!("veilvoice-{}.wav", stamp())))
}
/// `YYYYMMDD-HHMMSS` in UTC, for a filename.
///
/// Built from the same civil-date arithmetic the mandate history uses, rather
/// than from a date crate, for the reason recorded there: one line of output
/// does not justify a dependency nobody has read.
fn stamp() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let text = veilvoice_policy::utc(secs);
// "YYYY-MM-DD HH:MM:SS UTC" to "YYYYMMDD-HHMMSS".
let cleaned: String = text
.trim_end_matches(" UTC")
.chars()
.filter(|c| c.is_ascii_digit() || *c == ' ')
.collect();
cleaned.replace(' ', "-")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_default_name_is_a_timestamp_a_filesystem_accepts() {
let path = destination(None).unwrap();
let name = path.file_name().unwrap().to_string_lossy().to_string();
assert!(name.starts_with("veilvoice-"), "{name}");
assert!(name.ends_with(".wav"), "{name}");
// Nothing a Windows path rejects, and nothing a shell would split on.
for bad in [':', ' ', '/', '\\', '*', '?', '"', '<', '>', '|'] {
assert!(!name.contains(bad), "{name} contains {bad:?}");
}
}
#[test]
fn a_timestamp_is_the_shape_the_name_promises() {
let s = stamp();
let (date, time) = s.split_once('-').expect("a date and a time");
assert_eq!(date.len(), 8, "{s}");
assert_eq!(time.len(), 6, "{s}");
assert!(s.chars().all(|c| c.is_ascii_digit() || c == '-'), "{s}");
}
#[test]
fn an_explicit_destination_is_used_as_given() {
let path = destination(Some(PathBuf::from("interview.wav"))).unwrap();
assert_eq!(path, PathBuf::from("interview.wav"));
}
#[test]
fn an_empty_destination_is_refused_rather_than_turned_into_a_default() {
// Silently substituting a default would write the recording somewhere
// the person did not name, which for an encrypted file they then have
// to find is worse than an error.
assert!(destination(Some(PathBuf::new())).is_err());
}
}