crates/veilvoice-audio/src/playback.rs
what this file is for · veilvoice-audio · 212 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
//! Playing a recording that is only in memory, and never on disk.
//!
//! # What this is for
//!
//! A take in the studio vault is sealed. Hearing it back means decrypting it,
//! and the obvious way to hear a WAV is to write it somewhere and hand the path
//! to something that plays files. That would put an unencrypted recording on
//! the disk, which is the one thing the vault exists to prevent, and it would
//! leave it there until somebody remembered to shred it.
//!
//! So this takes the samples as they already are, in page-locked memory, and
//! plays them from there. Nothing is written. When playback stops the buffer is
//! dropped and `veilvoice_crypto::Secret` wipes itself.
//!
//! # The samples are held, not streamed from the vault
//!
//! Be plain about the shape rather than implying a stronger one. The whole take
//! is decrypted into locked memory before a note is heard, because the
//! container is sealed and authenticated as one piece: there is no way to open
//! the first second of it without opening all of it, and an AEAD that let you
//! would not be authenticating anything.
//!
//! What that buys is still the thing that matters: **no plaintext file, at any
//! point.** What it does not buy is a smaller footprint than the recording, and
//! an hour of audio is an hour of audio in RAM. A format sealed in blocks would
//! change that and is not what the vault writes today.
//!
//! # In plain words
//!
//! Plays a recording straight out of protected memory, so listening to one
//! never leaves a copy on the disk for somebody to find later.
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::Arc;
use cpal::traits::{DeviceTrait, StreamTrait};
use crate::Error;
/// A take being played, for as long as this is held.
///
/// Dropping it stops the audio and releases the samples. That is deliberate:
/// there is no `stop` that leaves the buffer alive, because a buffer of
/// somebody's recording outliving the reason it was decrypted is exactly the
/// leak this module is avoiding.
pub struct Playing {
// The stream must outlive the session; dropping it stops the callback.
_stream: cpal::Stream,
shared: Arc<Shared>,
samples: usize,
rate: u32,
}
impl Playing {
/// How far in, in seconds.
pub fn position(&self) -> f32 {
if self.rate == 0 {
return 0.0;
}
self.shared.at.load(Ordering::Relaxed) as f32 / self.rate as f32
}
/// How long the take is, in seconds.
pub fn duration(&self) -> f32 {
if self.rate == 0 {
return 0.0;
}
self.samples as f32 / self.rate as f32
}
/// Whether it has reached the end.
pub fn finished(&self) -> bool {
self.shared.done.load(Ordering::Relaxed)
}
/// The loudest sample since this was last called, and it resets.
pub fn peak(&self) -> f32 {
match self.shared.peak.lock() {
Ok(mut p) => {
let was = *p;
*p = 0.0;
was
}
// A poisoned lock means a callback panicked. Reporting silence is
// wrong but harmless; reporting a stale peak would be a meter that
// has quietly stopped moving.
Err(_) => 0.0,
}
}
}
/// Start playing `samples` at `rate` on the default output device.
///
/// The samples are moved into the callback. The caller's copy is gone, which is
/// what keeps there from being two: the one being played and one left behind.
pub fn start(samples: Vec<f32>, rate: u32, device: Option<&str>) -> Result<Playing, Error> {
if samples.is_empty() {
return Err(Error::Device("there is nothing to play".into()));
}
if rate == 0 {
return Err(Error::Device(
"the recording does not say what rate it was made at".into(),
));
}
let out = crate::devices::open(crate::devices::Direction::Output, device)?;
let cfg = out
.default_output_config()
.map_err(|e| Error::Device(e.to_string()))?;
let channels = cfg.channels() as usize;
let total = samples.len();
let shared = Arc::new(Shared {
at: AtomicUsize::new(0),
done: AtomicBool::new(false),
peak: std::sync::Mutex::new(0.0),
});
let callback_shared = Arc::clone(&shared);
// Resampling is deliberately not done here.
//
// The take was recorded at whatever the capture device agreed to, and the
// output device may want something else. Playing it at the wrong rate makes
// a voice sound higher or lower, which in a program whose whole purpose is
// that a voice cannot be traced back is a wrong answer rather than a small
// one. Where the device will not take the recording's rate this refuses and
// says so, and the export path is the way to hear it in something that does
// resample properly.
if cfg.sample_rate() != rate {
return Err(Error::Device(format!(
"this recording is {rate} Hz and the output device wants {}. \
Take it out of the vault to play it somewhere that can convert.",
cfg.sample_rate()
)));
}
let stream = out
.build_output_stream(
cfg.config(),
move |data: &mut [f32], _| {
let start = callback_shared.at.load(Ordering::Relaxed);
let frames = data.len() / channels.max(1);
let available = total.saturating_sub(start);
let taking = frames.min(available);
let mut loudest = 0.0f32;
for (frame, &s) in data
.chunks_mut(channels.max(1))
.zip(&samples[start..start + taking])
{
let v = s.clamp(-1.0, 1.0);
loudest = loudest.max(v.abs());
for slot in frame.iter_mut() {
*slot = v;
}
}
// Past the end is silence, not a repeat of the last block,
// which would be an audible stutter at the end of every take.
for slot in data.iter_mut().skip(taking * channels.max(1)) {
*slot = 0.0;
}
callback_shared.at.store(start + taking, Ordering::Relaxed);
if start + taking >= total {
callback_shared.done.store(true, Ordering::Relaxed);
}
if let Ok(mut p) = callback_shared.peak.try_lock() {
*p = p.max(loudest);
}
},
move |e| eprintln!("veilvoice: playback stream error: {e}"),
None,
)
.map_err(|e| Error::Stream(e.to_string()))?;
stream.play().map_err(|e| Error::Stream(e.to_string()))?;
Ok(Playing {
_stream: stream,
shared,
samples: total,
rate,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nothing_to_play_is_refused_rather_than_started() {
// A stream that plays an empty buffer reaches the end immediately and
// looks like a device fault. Saying so is more use.
assert!(start(Vec::new(), 48_000, None).is_err());
}
#[test]
fn a_rate_of_zero_is_refused_rather_than_divided_by() {
assert!(start(vec![0.0; 10], 0, None).is_err());
}
}