crates/veilvoice-audio/src/io.rs
what this file is for · veilvoice-audio · 574 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
//! Reading and writing audio files.
//!
//! Decoding goes through `symphonia`, which is pure Rust and covers WAV, MP3,
//! FLAC, OGG/Vorbis, MP4/AAC and friends without shelling out to a codec
//! library. Writing is WAV only, on purpose: VeilVoice's job is to hand back
//! audio that has not been degraded, and re-encoding to a lossy format after
//! de-identification would throw away quality for no benefit. Callers who want
//! MP3 can transcode with whatever they already trust.
//!
//! # A decoder is a parser, and this one reads files somebody else made
//!
//! `symphonia` is the largest attacker-facing surface in this crate: it is
//! handed whole files of a format VeilVoice does not itself define. It is pure
//! Rust, which removes the memory-corruption class outright, but it does not
//! remove panics -- and this workspace builds with `panic = "abort"`, so a
//! panic inside a decoder is not an error a caller can handle, it is the
//! process ending.
//!
//! That is why a pre-flight check runs before a file reaches the decoder, and
//! why the honest position is recorded in the audit rather than glossed:
//! decoding in a separate process is the only complete answer to "the next
//! malformed file in a format we do not parse ourselves", and it is not built.
//!
//! # Writing is WAV only, and that is a decision
//!
//! Re-encoding to a lossy format after de-identification would throw away
//! quality for no privacy benefit -- the voiceprint is already gone, and what
//! remains is the words, which is the part worth keeping intact.
//!
//! # In-memory encoding, so plaintext never reaches the disk
//!
//! [`wav_bytes`] exists so a recording can be encoded and then sealed without
//! ever being written in the clear. It has an awkward shape for a real reason:
//! `hound::WavWriter::finalize` consumes the writer, so the encode has to borrow
//! the cursor (`WavWriter::new(&mut cursor, spec)`) and read the bytes back
//! through `cursor.into_inner()` after the writer has been dropped.
//!
//! # In plain words
//!
//! This opens sound files and writes them back out.
//!
//! It can read the usual formats, and it always writes plain WAV. That is
//! deliberate: WAV throws nothing away. Saving as MP3 after the voice has been
//! veiled would lose quality for no reason, and anybody who wants a smaller file
//! can convert it afterwards with whatever they already use.
//!
//! Opening a sound file means reading a file somebody else made, which is the part
//! of any program most worth being careful in. A file that is damaged, or built on
//! purpose to cause trouble, is refused with a reason rather than being allowed to
//! bring the program down.
use crate::Error;
use std::io::{Read, Seek, SeekFrom};
use std::path::Path;
use symphonia::core::codecs::audio::AudioDecoderOptions;
use symphonia::core::codecs::CodecParameters;
use symphonia::core::formats::probe::Hint;
use symphonia::core::formats::FormatOptions;
use symphonia::core::io::MediaSourceStream;
use symphonia::core::meta::MetadataOptions;
/// The most decoded audio [`load`] will hold, in mono `f32` samples.
///
/// Roughly twelve hours at 48 kHz, and about eight gigabytes of `f32`. The
/// ceiling exists because compressed formats expand: a mono MP3 at 32 kbit/s
/// decodes to 48 000 `f32` per second, which is a **forty-eight-fold**
/// expansion, so a hundred-megabyte download becomes some five gigabytes of
/// samples. Rust aborts the process when an allocation fails, so an unbounded
/// decode turns "someone sent me a recording", the ordinary use this tool is
/// for, into a way to kill it.
///
/// Twelve hours is far past any interview or call anyone will veil, and the
/// refusal names the limit rather than truncating silently, because a recording
/// that quietly lost its second half is worse than one that would not open.
pub const MAX_DECODED_SAMPLES: usize = 48_000 * 60 * 60 * 12;
/// Reject a file whose own header carries a value that will crash the decoder,
/// before the decoder is given the file.
///
/// Public so that a caller holding bytes rather than a path can run the same
/// check [`load`] runs, and so the fuzz target in `fuzz/` can reach it. Pass as
/// much of the start of the file as is convenient; a few kilobytes is plenty,
/// and a short buffer is not an error.
///
/// This exists for one confirmed case and is deliberately narrow. A WAV whose
/// `fmt ` chunk declares a **sample rate of zero** makes `symphonia` panic
/// inside `Probe::format`, at `TimeBase::new`, before this crate is handed
/// anything it could check. VeilVoice's release profile sets `panic = "abort"`,
/// so that panic is not an error a caller can handle. It is the process
/// ending. `veilvoice anonymise` on a four-kilobyte file somebody sent you was
/// enough.
///
/// Every other malformed value tried during the audit, meaning zero channels,
/// 65535 channels, zero or 65535 bits per sample and a mismatched format tag, is
/// already refused cleanly by `symphonia` itself, so nothing else is duplicated
/// here. Checking what the decoder already checks would be a second parser to
/// keep in step, which is its own bug source.
///
/// **The residual is stated rather than engineered around:** this cannot
/// protect against a panic in the decoder for a format whose header VeilVoice
/// does not parse. Under `panic = "abort"` no wrapper can, short of decoding in
/// a separate process. The mitigations are this check, keeping `symphonia`
/// current, and the fact that it is a widely used pure-Rust decoder rather than
/// a C library.
pub fn preflight(head: &[u8]) -> Result<(), Error> {
// Only RIFF/WAVE is inspected; see the note above.
if head.len() < 12 || &head[..4] != b"RIFF" || &head[8..12] != b"WAVE" {
return Ok(());
}
let mut pos = 12usize;
while pos + 8 <= head.len() {
let id = &head[pos..pos + 4];
let size = u32::from_le_bytes([head[pos + 4], head[pos + 5], head[pos + 6], head[pos + 7]])
as usize;
if id == b"fmt " {
// The sample rate is a little-endian `u32` at offset 4 of the
// chunk's data. A `fmt ` chunk too short to hold one is malformed;
// leave that judgement to the decoder, which reports it properly.
let rate_at = pos + 8 + 4;
if size >= 8 && rate_at + 4 <= head.len() {
let rate = u32::from_le_bytes([
head[rate_at],
head[rate_at + 1],
head[rate_at + 2],
head[rate_at + 3],
]);
if rate == 0 {
return Err(Error::Decode(
"this WAV declares a sample rate of zero, which is not a rate; \
refusing it here because the decoder crashes on it"
.into(),
));
}
}
return Ok(());
}
// Chunks are word aligned. `saturating_add` for the same reason as the
// RIFF walker in `veilvoice-meta` (F-4): `size` is a `u32` read from
// the file and `pos + 8 + size` overflows `usize` on a 32-bit target.
let Some(next) = pos.checked_add(8).and_then(|p| p.checked_add(size)) else {
return Ok(());
};
pos = next.saturating_add(size % 2);
}
Ok(())
}
/// Mono audio in memory.
#[derive(Clone, Debug, PartialEq)]
pub struct Audio {
/// Interleaved-free mono samples, nominally in `[-1, 1]`.
pub samples: Vec<f32>,
/// Sample rate in hertz.
pub sample_rate: u32,
}
impl Audio {
/// Duration in seconds.
pub fn duration_secs(&self) -> f32 {
if self.sample_rate == 0 {
return 0.0;
}
self.samples.len() as f32 / self.sample_rate as f32
}
/// Peak absolute sample value.
pub fn peak(&self) -> f32 {
self.samples.iter().fold(0.0f32, |m, s| m.max(s.abs()))
}
}
/// Decode any supported audio file to mono `f32`.
///
/// Multi-channel input is averaged down to mono. VeilVoice's engine is
/// single-channel by design: a stereo image is itself a recording-setup
/// fingerprint, and collapsing it removes one more way to match a file to the
/// room and hardware that produced it.
pub fn load(path: &Path) -> Result<Audio, Error> {
let mut file = std::fs::File::open(path)?;
// Read the head from the handle we are about to hand over, then rewind it,
// rather than opening the path a second time. One open means the bytes
// checked are the bytes decoded; two opens would be a race in which the
// file could be swapped in between.
let mut head = [0u8; 4096];
let read = read_up_to(&mut file, &mut head)?;
preflight(&head[..read])?;
file.seek(SeekFrom::Start(0))?;
let stream = MediaSourceStream::new(Box::new(file), Default::default());
let mut hint = Hint::new();
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
hint.with_extension(ext);
}
let mut format = symphonia::default::get_probe()
.probe(
&hint,
stream,
FormatOptions::default(),
MetadataOptions::default(),
)
.map_err(|e| Error::Decode(e.to_string()))?;
// The first track that is audio at all. A container can carry video and
// subtitle tracks ahead of the sound, and a track whose parameters the
// demuxer could not read carries none.
let (track_id, params) = format
.tracks()
.iter()
.find_map(|t| match &t.codec_params {
Some(CodecParameters::Audio(p)) => Some((t.id, p.clone())),
_ => None,
})
.ok_or_else(|| Error::Decode("no decodable audio track".into()))?;
let mut decoder = symphonia::default::get_codecs()
.make_audio_decoder(¶ms, &AudioDecoderOptions::default())
.map_err(|e| Error::Decode(e.to_string()))?;
let mut samples = Vec::new();
let mut sample_rate = params.sample_rate.unwrap_or(48_000);
// Reused across packets: the decoder hands back a borrowed buffer in the
// codec's own sample format, and this is the one interleaved `f32` copy
// of it, sized by the first packet and never shrunk.
let mut interleaved: Vec<f32> = Vec::new();
loop {
let packet = match format.next_packet() {
Ok(Some(p)) => p,
// The end of the stream, said plainly.
Ok(None) => break,
// A truncated file we have already read the useful part of.
Err(symphonia::core::errors::Error::IoError(e))
if e.kind() == std::io::ErrorKind::UnexpectedEof =>
{
break
}
Err(symphonia::core::errors::Error::ResetRequired) => break,
Err(e) => return Err(Error::Decode(e.to_string())),
};
if packet.track_id != track_id {
continue;
}
match decoder.decode(&packet) {
Ok(decoded) => {
let spec = decoded.spec();
sample_rate = spec.rate();
let channels = spec.channels().count().max(1);
decoded.copy_to_vec_interleaved(&mut interleaved);
for frame in interleaved.chunks(channels) {
// Bounded so a compressed file cannot expand into an
// allocation failure, which aborts rather than errors.
if samples.len() >= MAX_DECODED_SAMPLES {
return Err(Error::Decode(format!(
"this file decodes to more than {} samples \
(about twelve hours); refusing it rather than \
running the machine out of memory",
MAX_DECODED_SAMPLES
)));
}
samples.push(frame.iter().sum::<f32>() / channels as f32);
}
}
// Decoders are allowed to report recoverable errors mid-stream;
// dropping the bad packet is better than losing the whole file.
Err(symphonia::core::errors::Error::DecodeError(_)) => continue,
Err(e) => return Err(Error::Decode(e.to_string())),
}
}
if samples.is_empty() {
return Err(Error::Decode("file contained no audio".into()));
}
Ok(Audio {
samples,
sample_rate,
})
}
/// Fill as much of `buf` as the file has, tolerating short reads.
///
/// A single `read` is allowed to return fewer bytes than asked for even when
/// more are available, and `read_exact` would fail outright on a file shorter
/// than the buffer, which most test fixtures are.
fn read_up_to(file: &mut std::fs::File, buf: &mut [u8]) -> Result<usize, Error> {
let mut filled = 0;
while filled < buf.len() {
match file.read(&mut buf[filled..])? {
0 => break,
n => filled += n,
}
}
Ok(filled)
}
/// Encode mono `f32` audio as a 16-bit PCM WAV, in memory.
///
/// The in-memory form is what makes encrypt-at-rest honest: a recording that is
/// going to be sealed must never touch the disk in the clear first, because a
/// plaintext file that is written and then deleted is exactly the thing
/// [`veilvoice_crypto::shred`](../../veilvoice_crypto/shred/index.html) explains
/// cannot be reliably taken back on flash storage.
///
/// Samples are clamped rather than allowed to wrap: a sample past full scale
/// would otherwise flip sign and produce a loud click.
pub fn wav_bytes(audio: &Audio) -> Result<Vec<u8>, Error> {
let spec = hound::WavSpec {
channels: 1,
sample_rate: audio.sample_rate,
bits_per_sample: 16,
sample_format: hound::SampleFormat::Int,
};
let mut cursor = std::io::Cursor::new(Vec::new());
{
let mut writer = hound::WavWriter::new(&mut cursor, spec)?;
for &s in &audio.samples {
let v = (s.clamp(-1.0, 1.0) * i16::MAX as f32).round() as i16;
writer.write_sample(v)?;
}
writer.finalize()?;
}
Ok(cursor.into_inner())
}
/// Write mono `f32` audio to a 16-bit PCM WAV file.
pub fn save_wav(path: &Path, audio: &Audio) -> Result<(), Error> {
std::fs::write(path, wav_bytes(audio)?)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn tone(sample_rate: u32, secs: f32, hz: f32) -> Audio {
let n = (sample_rate as f32 * secs) as usize;
let samples = (0..n)
.map(|i| (i as f32 / sample_rate as f32 * hz * std::f32::consts::TAU).sin() * 0.5)
.collect();
Audio {
samples,
sample_rate,
}
}
#[test]
fn wav_round_trip_preserves_audio() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("tone.wav");
let original = tone(48_000, 0.25, 440.0);
save_wav(&path, &original).unwrap();
let loaded = load(&path).unwrap();
assert_eq!(loaded.sample_rate, 48_000);
assert_eq!(loaded.samples.len(), original.samples.len());
// 16-bit quantisation is the only permitted difference.
let worst = original
.samples
.iter()
.zip(&loaded.samples)
.fold(0.0f32, |m, (a, b)| m.max((a - b).abs()));
assert!(worst < 1e-3, "round trip error {worst}");
}
/// The in-memory encoder is what the encrypt-at-rest path uses, so it must
/// produce exactly the file the on-disk one would.
#[test]
fn the_in_memory_encoder_matches_the_file_it_would_have_written() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("tone.wav");
let original = tone(44_100, 0.2, 330.0);
save_wav(&path, &original).unwrap();
let on_disk = std::fs::read(&path).unwrap();
let in_memory = wav_bytes(&original).unwrap();
assert_eq!(&in_memory[..4], b"RIFF");
assert_eq!(in_memory, on_disk);
}
#[test]
fn out_of_range_samples_clamp_instead_of_wrapping() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("hot.wav");
let hot = Audio {
samples: vec![3.0, -3.0, 0.0, 1.0, -1.0],
sample_rate: 48_000,
};
save_wav(&path, &hot).unwrap();
let loaded = load(&path).unwrap();
assert!(
loaded.samples[0] > 0.99,
"positive overshoot wrapped: {}",
loaded.samples[0]
);
assert!(
loaded.samples[1] < -0.99,
"negative overshoot wrapped: {}",
loaded.samples[1]
);
assert!(loaded.samples.iter().all(|s| s.abs() <= 1.0));
}
#[test]
fn stereo_is_averaged_to_mono() {
// Write a stereo WAV by hand, then check both channels contribute.
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("stereo.wav");
let spec = hound::WavSpec {
channels: 2,
sample_rate: 44_100,
bits_per_sample: 16,
sample_format: hound::SampleFormat::Int,
};
let mut w = hound::WavWriter::create(&path, spec).unwrap();
for _ in 0..1_000 {
w.write_sample(16_000i16).unwrap(); // left
w.write_sample(-16_000i16).unwrap(); // right, cancels
}
for _ in 0..1_000 {
w.write_sample(16_000i16).unwrap();
w.write_sample(16_000i16).unwrap(); // both, survives
}
w.finalize().unwrap();
let a = load(&path).unwrap();
assert_eq!(a.sample_rate, 44_100);
assert_eq!(a.samples.len(), 2_000, "one mono sample per frame");
assert!(a.samples[10].abs() < 0.01, "opposed channels should cancel");
assert!(a.samples[1_500] > 0.4, "matched channels should survive");
}
#[test]
fn metadata_helpers_are_sane() {
let a = tone(48_000, 0.5, 100.0);
assert!((a.duration_secs() - 0.5).abs() < 1e-6);
assert!(a.peak() > 0.49 && a.peak() <= 0.5);
assert_eq!(
Audio {
samples: vec![],
sample_rate: 0
}
.duration_secs(),
0.0
);
}
#[test]
fn a_missing_file_is_an_io_error() {
assert!(matches!(
load(Path::new("does-not-exist.wav")),
Err(Error::Io(_))
));
}
/// Build a WAV by hand so the header can say things `hound` would refuse
/// to write.
fn handmade_wav(rate: u32, channels: u16, bits: u16) -> Vec<u8> {
let data: Vec<u8> = (0..2_000u32)
.flat_map(|i| ((i as i16).wrapping_mul(37)).to_le_bytes())
.collect();
let mut fmt = Vec::new();
fmt.extend_from_slice(&1u16.to_le_bytes()); // PCM
fmt.extend_from_slice(&channels.to_le_bytes());
fmt.extend_from_slice(&rate.to_le_bytes());
fmt.extend_from_slice(&rate.wrapping_mul(2).to_le_bytes());
fmt.extend_from_slice(&2u16.to_le_bytes());
fmt.extend_from_slice(&bits.to_le_bytes());
let mut body = Vec::new();
body.extend_from_slice(b"fmt ");
body.extend_from_slice(&(fmt.len() as u32).to_le_bytes());
body.extend_from_slice(&fmt);
body.extend_from_slice(b"data");
body.extend_from_slice(&(data.len() as u32).to_le_bytes());
body.extend_from_slice(&data);
let mut out = Vec::from(*b"RIFF");
out.extend_from_slice(&((body.len() + 4) as u32).to_le_bytes());
out.extend_from_slice(b"WAVE");
out.extend_from_slice(&body);
out
}
/// The regression, and the reason `preflight` exists.
///
/// A WAV declaring a sample rate of zero made `symphonia` panic inside
/// `Probe::format`, at `TimeBase::new`, before this crate saw anything it
/// could check. Under the shipped `panic = "abort"` profile that is the
/// process dying, not an error, so `veilvoice anonymise` on a file somebody
/// sent you was enough to kill it. If this test ever *panics* rather than
/// failing an assertion, the pre-flight has stopped running.
#[test]
fn a_wav_declaring_a_zero_sample_rate_is_refused_rather_than_crashing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("hostile.wav");
std::fs::write(&path, handmade_wav(0, 1, 16)).unwrap();
match load(&path) {
Err(Error::Decode(m)) => assert!(
m.contains("sample rate of zero"),
"refused for the wrong reason: {m}"
),
other => panic!("expected a clean refusal, got {other:?}"),
}
}
/// The pre-flight must not become a second, divergent WAV parser. Values
/// `symphonia` already refuses properly are left to it, and ordinary files
/// must pass straight through.
#[test]
fn the_preflight_passes_everything_it_is_not_for() {
// Real headers, untouched.
for rate in [8_000u32, 44_100, 48_000, 192_000] {
assert!(preflight(&handmade_wav(rate, 1, 16)).is_ok(), "{rate} Hz");
}
// Not a WAV at all: not this function's business.
assert!(preflight(b"").is_ok());
assert!(preflight(b"\xff\xfb\x90\x00 an mp3 frame header").is_ok());
assert!(preflight(b"RIFF____AVI LIST").is_ok());
// Truncated inside the header, and a chunk size that would overflow a
// 32-bit `usize`. Neither may panic or loop.
let mut truncated = handmade_wav(48_000, 1, 16);
for n in 0..truncated.len().min(40) {
assert!(preflight(&truncated[..n]).is_ok(), "truncated at {n}");
}
truncated[16..20].copy_from_slice(&u32::MAX.to_le_bytes());
assert!(preflight(&truncated).is_ok());
// A WAV whose `fmt ` chunk comes after another chunk still gets found.
let mut with_junk = Vec::from(*b"RIFF");
let mut body = Vec::new();
body.extend_from_slice(b"JUNK");
body.extend_from_slice(&4u32.to_le_bytes());
body.extend_from_slice(&[0u8; 4]);
body.extend_from_slice(&handmade_wav(0, 1, 16)[12..]);
with_junk.extend_from_slice(&((body.len() + 4) as u32).to_le_bytes());
with_junk.extend_from_slice(b"WAVE");
with_junk.extend_from_slice(&body);
assert!(
preflight(&with_junk).is_err(),
"a zero rate behind a JUNK chunk was missed"
);
}
/// The decode ceiling has to sit far above anything anyone will really
/// veil, or it stops being a guard against hostile expansion and starts
/// being a limitation. Checked at compile time so it cannot drift.
const _: () = assert!(
MAX_DECODED_SAMPLES >= 48_000 * 60 * 60,
"an hour at 48 kHz must fit comfortably inside the decode ceiling"
);
#[test]
fn a_non_audio_file_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("notes.txt");
std::fs::write(&path, b"absolutely not audio").unwrap();
assert!(load(&path).is_err());
}
#[test]
fn sample_rate_is_preserved_across_rates() {
for rate in [16_000u32, 44_100, 48_000] {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("r.wav");
save_wav(&path, &tone(rate, 0.1, 220.0)).unwrap();
assert_eq!(load(&path).unwrap().sample_rate, rate);
}
}
}