crates/veilvoice-audio/src/record.rs
what this file is for · veilvoice-audio · 543 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
//! Recording the veiled voice without it ever reaching unprotected memory.
//!
//! # What this is for
//!
//! [`live`](crate::live) sends the veiled voice to a device and keeps nothing.
//! This keeps it, and the whole difficulty is *where*. A recording that is
//! accumulated in a `Vec`, encoded with a library that returns a `Vec`, and
//! then sealed, has existed in unlocked, unzeroized memory three times over by
//! the time it is encrypted, and the operating system may have written any of
//! those copies to the page file. Sealing it afterwards does not take that
//! back.
//!
//! So the recording lives in a [`Tape`] from the first sample to the last, the
//! WAV is assembled inside a [`Secret`], and the only thing that leaves this
//! module is that sealed-ready `Secret`. There is no route here that produces a
//! plain `Vec` of the audio, because a route that existed would eventually be
//! taken.
//!
//! # Never a plaintext file, either
//!
//! Nothing here writes to disk at all. The caller seals the [`Secret`] and
//! writes the result. A recorder that wrote a WAV and encrypted it afterwards
//! would leave a plaintext file that
//! [`veilvoice_crypto::shred`](../../veilvoice_crypto/shred/index.html) explains
//! cannot be
//! reliably taken back on flash storage, which is the whole reason at-rest
//! encryption is the default rather than an option.
//!
//! # The two halves, and why they are split
//!
//! [`Sink`] is handed to the audio callback and [`Recorder`] is kept by the
//! caller. They are joined by a lock-free ring buffer, for the reason the
//! [`live`](crate::live) module documentation gives: a callback that allocates
//! or waits produces a dropout, and locking a page or growing a tape does both.
//! So the callback only ever pushes into a buffer that is already allocated,
//! and the slow, careful work of moving those samples into locked memory
//! happens on the caller's thread in [`Recorder::drain`].
//!
//! A caller that stops draining does not stall the audio. The ring fills, and
//! samples are counted as dropped rather than waited for, because a glitch in a
//! recording is better than a glitch in the live output somebody is speaking
//! into. [`Recorder::dropped`] reports it rather than letting the recording be
//! quietly short.
//!
//! # In plain words
//!
//! Keeps the veiled voice as it is produced, in memory the operating system has
//! been asked not to write to disk, and hands it over ready to be encrypted.
//!
//! It never writes an unencrypted recording anywhere, not even briefly, because
//! a file that is written and deleted can still be recovered from the disk
//! afterwards.
use crate::Error;
use ringbuf::traits::{Consumer, Producer, Split};
use ringbuf::{HeapCons, HeapProd, HeapRb};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use veilvoice_crypto::{Secret, Tape};
/// Bytes in a canonical 16-bit PCM WAV header.
const HEADER: usize = 44;
/// The most PCM data a RIFF/WAVE file can describe, in bytes.
///
/// Not a limit this module chose. A WAV header states its sizes in unsigned
/// 32-bit fields, so the format itself cannot describe more, and the `RIFF`
/// size field has to hold the data plus the 36 bytes of header around it.
/// At 48 kHz, 16-bit, mono, this is a little over twelve hours.
///
/// It is checked rather than wrapped. A cast would produce a header claiming a
/// fraction of the real length, and that file opens, plays, and is silently
/// short: the worst shape a defect can take on a recording somebody made once
/// and cannot make again.
const WAV_MAX_DATA: usize = u32::MAX as usize - 36;
/// How much audio the ring holds before samples are dropped, in seconds.
///
/// Generous on purpose. The ring exists to absorb the gap between an audio
/// callback that runs every few milliseconds and a caller that drains when it
/// gets round to it, and a caller doing a screen redraw between drains is
/// normal. Sized in seconds rather than samples so the slack does not shrink
/// when the device runs at a higher rate.
pub const SLACK_SECONDS: f32 = 8.0;
/// The writing half, handed to the audio callback.
///
/// Every method is safe to call from a realtime audio callback: no allocation,
/// no locking, no syscall, no waiting.
pub struct Sink {
producer: HeapProd<f32>,
dropped: Arc<AtomicU64>,
}
impl Sink {
/// Take a block of veiled samples.
///
/// Samples that do not fit are counted and discarded rather than waited
/// for. Blocking here would stall the output callback and glitch the audio
/// the speaker is producing, to protect a recording of it, which is the
/// wrong way round.
pub fn write(&mut self, samples: &[f32]) {
let taken = self.producer.push_slice(samples);
if taken < samples.len() {
self.dropped
.fetch_add((samples.len() - taken) as u64, Ordering::Relaxed);
}
}
}
/// The reading half: moves samples out of the ring and into locked memory.
pub struct Recorder {
consumer: HeapCons<f32>,
tape: Tape,
dropped: Arc<AtomicU64>,
sample_rate: u32,
samples: usize,
/// Drain scratch, allocated once. Not protected memory, and does not need
/// to be: it holds at most one drain's worth and is overwritten every time,
/// but it is wiped in [`Recorder::wav`] so the last block does not sit in
/// it after the recording is handed over.
scratch: Vec<f32>,
}
/// Start a recorder and the sink that feeds it.
///
/// `sample_rate` is the rate the device actually agreed to, not the one that
/// was asked for: it is written into the WAV header, and a header that
/// disagrees with the samples plays back at the wrong speed and the wrong
/// pitch, which on a de-identified recording would be a second voice change
/// nobody chose.
pub fn start(sample_rate: u32) -> (Recorder, Sink) {
let capacity = ((sample_rate as f32 * SLACK_SECONDS) as usize).max(4096);
let (producer, consumer) = HeapRb::<f32>::new(capacity).split();
let dropped = Arc::new(AtomicU64::new(0));
let recorder = Recorder {
consumer,
tape: Tape::new(),
dropped: Arc::clone(&dropped),
sample_rate,
samples: 0,
scratch: vec![0.0; 8192],
};
(recorder, Sink { producer, dropped })
}
impl Recorder {
/// Move everything waiting in the ring into the tape.
///
/// Returns how many samples moved. Call this regularly: the ring holds
/// [`SLACK_SECONDS`] and drops what does not fit.
///
/// Samples are converted to 16-bit here rather than at the end, so the tape
/// holds exactly the bytes the WAV will carry and the final step is a copy
/// rather than a second pass over the whole recording. Values are clamped
/// rather than allowed to wrap, for the reason
/// [`io::wav_bytes`](crate::io::wav_bytes) gives: a sample past full scale
/// that wrapped would flip sign and produce a loud click.
pub fn drain(&mut self) -> usize {
let mut moved = 0;
loop {
let got = self.consumer.pop_slice(&mut self.scratch);
if got == 0 {
break;
}
// A small stack buffer keeps the conversion allocation-free.
let mut bytes = [0u8; 2 * 512];
for block in self.scratch[..got].chunks(512) {
for (pair, &s) in bytes.chunks_mut(2).zip(block) {
let v = (s.clamp(-1.0, 1.0) * i16::MAX as f32).round() as i16;
pair.copy_from_slice(&v.to_le_bytes());
}
self.tape.push(&bytes[..block.len() * 2]);
}
self.samples += got;
moved += got;
}
moved
}
/// Samples recorded so far, as of the last [`Recorder::drain`].
pub fn samples(&self) -> usize {
self.samples
}
/// Length so far in seconds, as of the last [`Recorder::drain`].
pub fn seconds(&self) -> f32 {
if self.sample_rate == 0 {
return 0.0;
}
self.samples as f32 / self.sample_rate as f32
}
/// Samples lost because the caller did not drain in time.
///
/// Non-zero means the recording is short by this many samples and has a gap
/// rather than a glitch. Worth reporting: a recording that is quietly
/// missing a second of speech is worse than one that says so.
pub fn dropped(&self) -> u64 {
self.dropped.load(Ordering::Relaxed)
}
/// Whether every page holding the recording is locked out of swap.
///
/// False is not a failure, as [`veilvoice_crypto::tape`] explains: the
/// operating system's lock budget is small and unprivileged processes
/// cannot raise it. It is surfaced so the caller can say what was actually
/// obtained rather than imply a guarantee.
pub fn fully_locked(&self) -> bool {
self.tape.fully_locked()
}
/// The sample rate written into the WAV header.
pub fn sample_rate(&self) -> u32 {
self.sample_rate
}
/// Drain what is left and hand over the recording as a WAV, in a
/// [`Secret`], ready to be sealed.
///
/// Returns a `Secret` rather than a `Vec` deliberately. This is the moment
/// the recording is complete and therefore at its most worth protecting,
/// and it is exactly the moment a convenient `Vec` would put all of it into
/// memory that can be paged to disk and is never wiped.
///
/// The header is written by hand rather than through the WAV library for
/// the same reason: that library builds its output in a `Vec` it grows and
/// returns, which is the copy this module exists to avoid.
pub fn wav(&mut self) -> Result<Secret, Error> {
self.drain();
let data = self.tape.len();
// Refused before the allocation, not after: a recording this long is
// already several gigabytes, and finding out afterwards would mean
// reserving all of it to then throw it away.
if data > WAV_MAX_DATA {
return Err(Error::TooLong(data));
}
let mut out = Secret::zeroed(HEADER + data);
write_header(out.expose_mut(), self.sample_rate, data);
self.tape
.copy_into(&mut out.expose_mut()[HEADER..])
.map_err(Error::Crypto)?;
// The last drained block is still in the scratch buffer, which is not
// protected memory. Nothing else wipes it.
self.wipe_scratch();
Ok(out)
}
/// Wipe the recording held so far and start again from nothing.
pub fn discard(&mut self) {
self.tape.wipe();
self.samples = 0;
self.wipe_scratch();
}
/// Clear the drain scratch.
///
/// The one place this is written. `wav`, `discard` and the destructor all
/// call it rather than each clearing the buffer themselves, so there is no
/// second copy of the operation to fall out of step with the others, and a
/// test of this function is a test of what the destructor actually runs.
fn wipe_scratch(&mut self) {
self.scratch.iter_mut().for_each(|s| *s = 0.0);
}
}
impl Drop for Recorder {
/// Wipe the drain scratch.
///
/// The tape wipes itself: every chunk of it is a [`Secret`]. The scratch
/// buffer is not, and it holds up to one drain's worth of veiled audio in
/// ordinary heap memory. Without this, abandoning a recording (Ctrl-C, an
/// error on the way to sealing, or simply dropping the recorder) frees
/// those samples without clearing them, and the allocator is then free to
/// hand that memory, contents intact, to anything else in the process.
///
/// # What this does not reach
///
/// The ring buffer between [`Sink`] and [`Recorder`] also holds veiled
/// samples, up to [`SLACK_SECONDS`] of them, and `ringbuf` exposes no way
/// to clear its backing storage. Those bytes are freed unwiped and this
/// module cannot prevent it. It is written down rather than left for
/// somebody to discover: the exposure is veiled audio, which is the same
/// audio the file holds and is not key material, but it is not nothing and
/// claiming the recorder wipes everything would be false.
fn drop(&mut self) {
self.wipe_scratch();
}
}
/// Write a canonical 44-byte mono 16-bit PCM WAV header into `out`.
///
/// `out` must be at least [`HEADER`] bytes; callers here always size it from
/// the same constant.
fn write_header(out: &mut [u8], sample_rate: u32, data_len: usize) {
// `wav` refuses anything past `WAV_MAX_DATA` before calling here, so both
// of these fit. They are still written as saturating rather than wrapping
// conversions: if that guard is ever moved or lost, an over-long file
// becomes an obviously wrong header rather than a plausible short one.
let data_len = u32::try_from(data_len).unwrap_or(u32::MAX);
let riff = data_len.saturating_add(36);
let byte_rate = sample_rate.saturating_mul(2); // one channel, two bytes
out[0..4].copy_from_slice(b"RIFF");
out[4..8].copy_from_slice(&riff.to_le_bytes());
out[8..12].copy_from_slice(b"WAVE");
out[12..16].copy_from_slice(b"fmt ");
out[16..20].copy_from_slice(&16u32.to_le_bytes()); // PCM fmt chunk size
out[20..22].copy_from_slice(&1u16.to_le_bytes()); // PCM, uncompressed
out[22..24].copy_from_slice(&1u16.to_le_bytes()); // mono
out[24..28].copy_from_slice(&sample_rate.to_le_bytes());
out[28..32].copy_from_slice(&byte_rate.to_le_bytes());
out[32..34].copy_from_slice(&2u16.to_le_bytes()); // block align
out[34..36].copy_from_slice(&16u16.to_le_bytes()); // bits per sample
out[36..40].copy_from_slice(b"data");
out[40..44].copy_from_slice(&data_len.to_le_bytes());
}
#[cfg(test)]
mod tests {
use super::*;
/// Decode a WAV back to samples with the same library the rest of the crate
/// reads files with, so the header is checked by something other than the
/// code that wrote it.
fn decode(wav: &[u8]) -> (u32, Vec<i16>) {
let reader = hound::WavReader::new(std::io::Cursor::new(wav)).expect("a readable WAV");
let rate = reader.spec().sample_rate;
assert_eq!(reader.spec().channels, 1);
assert_eq!(reader.spec().bits_per_sample, 16);
let samples = reader.into_samples::<i16>().map(|s| s.unwrap()).collect();
(rate, samples)
}
#[test]
fn the_header_this_writes_is_one_a_wav_reader_accepts() {
// Written by hand to keep the recording out of a library's Vec, so it
// is checked against a real decoder rather than against itself.
let (mut rec, mut sink) = start(48_000);
sink.write(&[0.0, 0.5, -0.5, 1.0]);
let wav = rec.wav().unwrap();
let (rate, samples) = decode(wav.expose());
assert_eq!(rate, 48_000);
assert_eq!(samples.len(), 4);
}
#[test]
fn what_was_spoken_is_what_comes_back() {
let (mut rec, mut sink) = start(16_000);
let input: Vec<f32> = (0..1000).map(|i| (i as f32 / 40.0).sin() * 0.8).collect();
sink.write(&input);
rec.drain();
let wav = rec.wav().unwrap();
let (_, got) = decode(wav.expose());
assert_eq!(got.len(), input.len());
for (i, (&want, &have)) in input.iter().zip(&got).enumerate() {
let expected = (want.clamp(-1.0, 1.0) * i16::MAX as f32).round() as i16;
assert_eq!(have, expected, "sample {i} came back changed");
}
}
#[test]
fn a_sample_past_full_scale_is_clamped_rather_than_wrapped() {
// Wrapping would flip the sign and produce a loud click, which on a
// recording somebody is going to publish is the worst kind of defect:
// inaudible while monitoring and obvious in the file.
let (mut rec, mut sink) = start(8_000);
sink.write(&[2.0, -2.0, 1.0, -1.0]);
let wav = rec.wav().unwrap();
let (_, got) = decode(wav.expose());
assert_eq!(got, vec![i16::MAX, -i16::MAX, i16::MAX, -i16::MAX]);
}
#[test]
fn draining_in_pieces_gives_the_same_recording_as_draining_at_the_end() {
// The caller drains whenever it gets round to it, so the result must
// not depend on how often that was.
let input: Vec<f32> = (0..5000).map(|i| ((i % 97) as f32 / 97.0) - 0.5).collect();
let (mut often, mut sink_a) = start(44_100);
for block in input.chunks(64) {
sink_a.write(block);
often.drain();
}
let a = often.wav().unwrap();
let (mut once, mut sink_b) = start(44_100);
for block in input.chunks(64) {
sink_b.write(block);
}
let b = once.wav().unwrap();
assert_eq!(a.expose(), b.expose());
}
#[test]
fn a_recording_nobody_drained_reports_what_it_lost_rather_than_going_short_in_silence() {
// The ring is finite. Overrunning it has to be counted, because a
// recording quietly missing a second of speech is worse than one that
// says so.
let (mut rec, mut sink) = start(8_000); // 8 s of slack at 8 kHz
let flood = vec![0.25f32; 8_000 * 20];
sink.write(&flood);
assert!(rec.dropped() > 0, "the overrun was not counted");
rec.drain();
assert!(
rec.samples() < flood.len(),
"more samples arrived than the ring can hold"
);
assert_eq!(
rec.samples() as u64 + rec.dropped(),
flood.len() as u64,
"every sample must be either recorded or counted as dropped"
);
}
#[test]
fn an_empty_recording_is_a_valid_wav_of_no_length() {
let (mut rec, _sink) = start(48_000);
let wav = rec.wav().unwrap();
assert_eq!(wav.len(), HEADER, "a header and no samples");
let (rate, samples) = decode(wav.expose());
assert_eq!(rate, 48_000);
assert!(samples.is_empty());
}
#[test]
fn the_length_reported_matches_the_samples_recorded() {
let (mut rec, mut sink) = start(1000);
sink.write(&vec![0.1f32; 2500]);
rec.drain();
assert_eq!(rec.samples(), 2500);
assert!((rec.seconds() - 2.5).abs() < 1e-6, "{}", rec.seconds());
}
#[test]
fn discarding_leaves_a_recorder_that_records_again_from_nothing() {
let (mut rec, mut sink) = start(8_000);
sink.write(&vec![0.5f32; 100]);
rec.drain();
assert_eq!(rec.samples(), 100);
rec.discard();
assert_eq!(rec.samples(), 0);
sink.write(&[0.25, 0.25]);
let wav = rec.wav().unwrap();
let (_, got) = decode(wav.expose());
assert_eq!(got.len(), 2, "the discarded audio came back");
}
#[test]
fn the_recording_is_handed_over_in_protected_memory() {
// The type is the guarantee: `wav` returns a Secret, which is wiped on
// drop and locked where the OS allows. This asserts the shape rather
// than the locking, which is best-effort and budget-dependent.
let (mut rec, mut sink) = start(8_000);
sink.write(&vec![0.5f32; 64]);
let wav: Secret = rec.wav().unwrap();
assert_eq!(wav.len(), HEADER + 128);
}
#[test]
fn a_recording_too_long_for_the_format_is_refused_rather_than_truncated() {
// A WAV states its sizes in 32-bit fields. Casting a longer length into
// one wraps, and the file that comes out opens, plays, and is silently
// a fraction of its real length. On a recording somebody made once,
// that is the worst shape a defect can take, so the limit is checked.
//
// The limit itself is asserted rather than the behaviour of a
// multi-gigabyte allocation: at 48 kHz 16-bit mono this is a little
// over twelve hours, and a test that actually recorded that would not
// be a test anybody runs.
assert_eq!(WAV_MAX_DATA, u32::MAX as usize - 36);
// Twelve hours fits; thirteen does not. Both are computed the way the
// recorder computes a length, so the boundary is checked in the units
// a caller thinks in.
//
// In `u64` rather than `usize`, because thirteen hours at 48 kHz
// 16-bit mono is 4,492,800,000 bytes and a 32-bit `usize` stops at
// 4,294,967,295. Written in `usize` this line does not merely fail on
// a 32-bit target, it refuses to compile there: the multiplication is
// constant, so `arithmetic_overflow` rejects it before any test runs.
// The number being asserted is a property of the WAV format, not of
// the machine, so it is computed in a width that holds it everywhere.
let per_second: u64 = 48_000 * 2;
let limit = WAV_MAX_DATA as u64;
assert!(12 * 3600 * per_second < limit);
assert!(13 * 3600 * per_second > limit);
}
#[test]
fn the_header_stays_wrong_rather_than_plausible_if_the_guard_is_ever_lost() {
// `write_header` is only reached after `wav` has refused an over-long
// recording. This asserts the second line of defence: were that guard
// moved or removed, the length written saturates rather than wrapping,
// so the file is obviously broken instead of quietly short.
let mut out = [0u8; HEADER];
write_header(&mut out, 48_000, usize::MAX);
let data = u32::from_le_bytes(out[40..44].try_into().unwrap());
let riff = u32::from_le_bytes(out[4..8].try_into().unwrap());
assert_eq!(data, u32::MAX, "a wrapped length would look plausible");
assert_eq!(riff, u32::MAX);
}
#[test]
fn abandoning_a_recording_does_not_leave_the_scratch_buffer_full_of_audio() {
// The tape wipes itself, chunk by chunk, because every chunk is a
// Secret. The scratch buffer is ordinary heap memory, and before this
// it was freed with the last drained block still in it: Ctrl-C, or any
// error on the way to sealing, handed that memory back to the
// allocator with the audio intact.
//
// Reading freed memory is not something a safe test can do, so this
// checks the state `drop` leaves rather than the heap afterwards: the
// same buffer, wiped by the same code path the destructor runs.
let (mut rec, mut sink) = start(8_000);
sink.write(&vec![0.75f32; 2048]);
rec.drain();
assert!(
rec.scratch.iter().any(|&s| s != 0.0),
"the drain left nothing in the scratch, so this proves nothing"
);
// The very function the destructor calls, not a copy of it.
rec.wipe_scratch();
assert!(
rec.scratch.iter().all(|&s| s == 0.0),
"audio survived in the scratch buffer"
);
}
#[test]
fn a_zero_sample_rate_does_not_divide_by_it() {
let (rec, _sink) = start(0);
assert_eq!(rec.seconds(), 0.0);
}
}