crates/veilvoice-core/src/chain.rs
what this file is for · veilvoice-core · 1741 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
//! The assembled de-identification chain and its live performance statistics.
//!
//! Every other module in this crate does one job. This is the file that puts
//! them in order and decides what happens to a block of samples, so it is the
//! one to read first if you want to know what VeilVoice actually *does* to
//! audio.
//!
//! # The signal path
//!
//! [`Deidentifier::process`] takes a block of input and writes an equal-length
//! block of output. Everything below happens inside it, per STFT frame:
//!
//! 1. **Roll the modulation stream** if this frame is the one where the
//! ratchet fires. See "forward secrecy" below.
//! 2. **Draw this frame's modulation** from the CSPRNG -- a pitch ratio and a
//! formant ratio, glided toward fresh random targets rather than jumped, so
//! the scrambling is inaudible as scrambling.
//! 3. **Track the fundamental** from the newest hop of *time-domain* samples.
//! This cannot be done in the frequency domain: at any frame size with
//! usable latency, the FFT's bin spacing cannot tell 100 Hz from 140 Hz.
//! The tracker keeps its own longer history and is fed only what is new.
//! 4. **Let the accent neutraliser observe** that estimate, so its long-term
//! picture of the speaker stays current.
//! 5. **Transform the spectrum** -- this is the irreversible step, and it lives
//! in [`crate::spectral`]. Measured phase is discarded and resynthesised;
//! pitch, vocal-tract scale and spectral tilt are mapped onto canonical
//! values.
//!
//! Then, once per block rather than per frame, a short time-domain tail: soft
//! clip, chorus, reverb. Those are cosmetic. **They are not what makes the
//! output unlinkable** and nothing here should be read as though they were.
//!
//! # Why it is one-way, in one paragraph
//!
//! Two independent reasons, and both are needed:
//!
//! * **The mapping is many-to-one.** Every speaker is pushed toward the same
//! pitch register, the same vocal-tract scale and the same long-term
//! spectrum. Many different inputs produce the same output, so there is no
//! inverse to compute -- not "an inverse that is hard to find", none.
//! * **The phase is gone.** The measured phase of every frame is discarded and
//! replaced. Phase carries the precise waveform and a speaker's
//! micro-timing; it is never stored, so nothing downstream can restore it.
//!
//! The CSPRNG modulation on top means there is not even one fixed transform to
//! characterise. That is a third reason, and it is the weakest of the three:
//! randomness alone would be reversible by anyone holding the seed. The seed
//! never leaves the process and is zeroized on drop, but the argument does not
//! rest on that.
//!
//! # Forward secrecy, and what `reseed_secs` is really for
//!
//! The modulation stream rolls onto a fresh seed every [`DeidConfig::reseed_secs`]
//! (two seconds by default), drawing the new seed from the stream it replaces.
//! ChaCha20 cannot be run backwards, so obtaining the current state tells an
//! adversary nothing about the modulation that drove any earlier segment: a
//! long recording is a chain of short independently-sealed streams rather than
//! one long one.
//!
//! **This is forward secrecy, not irreversibility.** Rolling more often does
//! not make the output harder to invert -- the phase discard and the
//! many-to-one mapping already did that, and they do not depend on the ratchet
//! at all. Setting `reseed_secs` to `0.0` keeps one stream for the session and
//! the output is exactly as unlinkable as before.
//!
//! # A roll cannot happen faster than a frame, and the interface must say so
//!
//! [`DeidConfig::reseed_range_ms`] asks for the interval to be drawn fresh from
//! a range at every roll, in milliseconds, rather than fixed. The gap is drawn
//! from the modulation stream itself, so it is unpredictable and costs neither
//! a syscall nor an allocation.
//!
//! It is **quantised to whole frames**, and the grain is coarser than people
//! expect. The engine produces one set of modulation parameters per STFT hop:
//! 256 samples at the default frame size, which is 5.33 ms at 48 kHz. There is
//! nothing between two frames to change, so a request for a 0.7 ms interval
//! does not roll seven times inside a frame -- it rolls once, at the frame
//! boundary, exactly as a request for 5 ms would.
//!
//! Making the frame short enough for a sub-millisecond roll would mean a
//! 128-point transform, which is 375 Hz per bin: too coarse to locate a
//! formant, and moving formants is the thing being done. The trade is not
//! available.
//!
//! So [`DeidConfig::effective_reseed_range_ms`] reports what a requested range
//! actually comes to on this configuration, and a front end shows that rather
//! than the number that was typed. Quietly accepting 0.7 ms and rolling at
//! 5.33 ms would be a setting that lies about itself.
//!
//! The roll is deliberately cheap: no syscall, no allocation, no lock. It has
//! to be, because it happens inside an audio callback.
//!
//! # Real-time constraints
//!
//! [`Deidentifier::process`] is allocation-free and safe to call from an audio
//! callback. That is a property of this file and it is easy to lose: a `Vec`
//! grown inside the per-frame closure, a lock taken, or a log line written
//! would each turn a working live path into audible dropouts on somebody
//! else's machine and not on yours.
//!
//! [`Deidentifier::process_vec`] is the convenience form that *does* allocate.
//! It is for offline processing; do not reach for it in a callback.
//!
//! [`ProcessStats`] records what each block cost -- last, worst, and an
//! exponential moving average -- so a front-end can show a real-time factor
//! instead of guessing. `worst_block_ms` is the one that matters for live use:
//! the average being comfortable says nothing about whether the worst block
//! missed its deadline.
//!
//! # Configuration is validated in one place
//!
//! [`DeidConfig::checked`] is the single funnel, and nothing should bypass it.
//! Two shipped defects are the reason it exists in that shape: a configuration
//! value once made every output sample silently `NaN` (F-10), and parameters
//! read from a file and handed to a library without a bound killed the process
//! (F-2, F-3). The engine keeps persistent state, so a bad value is not one bad
//! block -- it is every block from then on.
//!
//! # In plain words
//!
//! This is the file to read first if you want to know what VeilVoice actually does
//! to a voice.
//!
//! Every other file in the engine does one job. This one puts them in order and
//! decides what happens to each piece of sound: what is measured, what is thrown
//! away, what is replaced, and in which order.
//!
//! It also keeps count of how long the work is taking, which is what live mode
//! needs in order to tell you honestly if the computer is not keeping up.
use crate::accent::{AccentConfig, AccentNeutralizer, AccentStats};
use crate::effects::{Chorus, Reverb, SoftClip};
use crate::modulation::Modulator;
use crate::pitch::PitchTracker;
use crate::spectral::SpectralState;
use crate::stft::StftEngine;
use std::time::Instant;
/// User-facing configuration for the de-identifier.
#[derive(Clone, Copy, Debug)]
pub struct DeidConfig {
/// Audio sample rate in Hz.
pub sample_rate: f32,
/// FFT size (power of two recommended). Larger = better frequency
/// resolution but more latency.
pub frame_size: usize,
/// Overlap factor; hop = frame_size / overlap (4 = 75 % overlap).
pub overlap: usize,
/// Pitch ratio bounds (before intensity scaling).
pub pitch_bounds: (f32, f32),
/// Formant ratio bounds (before intensity scaling).
pub formant_bounds: (f32, f32),
/// Frames between fresh random modulation targets.
pub frames_per_target: u32,
/// One-pole glide coefficient toward each target (0,1].
pub mod_smooth: f32,
/// Soft-clip drive and dry/wet mix.
pub distortion_drive: f32,
/// Soft-clip dry/wet mix.
pub distortion_mix: f32,
/// Chorus dry/wet mix.
pub chorus_mix: f32,
/// Reverb dry/wet mix.
pub reverb_mix: f32,
/// 0..1 scales how far pitch/formant ratios deviate from 1.0.
pub intensity: f32,
/// Accent and speaker-trait neutralisation.
pub accent: AccentConfig,
/// How often the modulation stream rolls onto a fresh seed, in seconds.
///
/// Each roll permanently closes off the stream that drove the audio before
/// it: ChaCha20 cannot be run backwards, so an adversary who obtained the
/// current state could not reconstruct the modulation of any earlier
/// segment. A long recording therefore is not one key stream but a chain of
/// short, independently-sealed ones.
///
/// Two seconds by default, which is frequent enough to keep each segment
/// small and far too slow to hear: the parameters glide across a roll and
/// the phase offsets ease to their new values over about half a second.
/// Set to `0.0` to keep a single stream for the whole session.
///
/// Ignored when [`DeidConfig::reseed_range_ms`] is set.
pub reseed_secs: f32,
/// Draw the interval before each roll from this range, in **milliseconds**.
///
/// `Some((lo, hi))` replaces the fixed [`DeidConfig::reseed_secs`] with a
/// gap drawn fresh from the modulation stream at every roll, so the ratchet
/// has no period to observe. `None` keeps the fixed interval.
///
/// **Quantised to whole frames.** One frame is
/// [`DeidConfig::frame_ms`] -- 5.33 ms at the default settings and 48 kHz
/// -- and nothing can happen between two of them.
/// [`DeidConfig::effective_reseed_range_ms`] is what the range comes to,
/// and it is what an interface should display.
///
/// Not part of [`DeidConfig::default`], which stays deterministic so the
/// test suite does. The front ends call
/// [`DeidConfig::with_random_reseed_range`] at launch, which is what makes
/// the shipped interval something other than a number compiled in.
///
/// F-73: that last sentence was written before anything did it. The method
/// existed, was tested, and was called by **nothing but its own test** for
/// two releases, so every shipped copy rolled on the same fixed two-second
/// period -- a number compiled into the binary, which is precisely what it
/// says here is not the case. A test now reads the front ends' source and
/// fails the build if the call is not there.
pub reseed_range_ms: Option<(f32, f32)>,
}
impl Default for DeidConfig {
fn default() -> Self {
Self {
sample_rate: 48_000.0,
frame_size: 1024,
overlap: 4,
// Strong enough to erase identity, gentle enough to stay legible.
pitch_bounds: (0.80, 1.28),
formant_bounds: (0.78, 1.30),
frames_per_target: 8,
mod_smooth: 0.06,
distortion_drive: 1.5,
distortion_mix: 0.12,
chorus_mix: 0.28,
reverb_mix: 0.12,
intensity: 1.0,
accent: AccentConfig::default(),
reseed_secs: 2.0,
reseed_range_ms: None,
}
}
}
/// The narrowest randomised roll range this engine will accept, in
/// milliseconds. Below one frame the range has no room to vary in.
///
/// Public so that a front end refusing a typed value can name the limit. A
/// refusal that will not say what the bound is leaves somebody guessing, which
/// is only marginally better than the clamp it replaced.
pub const MIN_RESEED_MS: f32 = 0.05;
/// The widest, in milliseconds. Ten minutes is far past any use for a ratchet
/// and stops an absurd value producing a frame count that overflows.
pub const MAX_RESEED_MS: f32 = 600_000.0;
/// Why a ratchet range typed by a person was not accepted.
///
/// **Every one of these is a refusal, never a correction.** Clamping a typed
/// number to something legal is how somebody ends up running on a setting they
/// did not choose and cannot see: they typed a value, nothing complained, and
/// the program used a different one. For a control whose entire purpose is that
/// the interval should not be predictable, silently substituting a value would
/// be the worst available failure -- and the roadmap marker asks for exactly
/// this, in these words: *invalid input refused rather than clamped*.
#[derive(Clone, Debug, PartialEq)]
pub enum RangeError {
/// The text was not two numbers.
NotTwoNumbers(String),
/// One of the two was not a number at all.
NotANumber(String),
/// A number was negative, zero, or not finite.
NotPositive(f32),
/// The low end was not below the high end.
Backwards {
/// What was given as the low end.
lo: f32,
/// What was given as the high end.
hi: f32,
},
/// Below the narrowest range the engine can draw from.
TooShort {
/// The low end asked for.
lo: f32,
/// The least this engine accepts.
least: f32,
},
/// Past the longest interval the engine accepts.
TooLong {
/// The high end asked for.
hi: f32,
/// The most this engine accepts.
most: f32,
},
}
impl std::fmt::Display for RangeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotTwoNumbers(text) => write!(
f,
"a ratchet range is two numbers of milliseconds, low first, like \
250,1800 -- got \"{text}\""
),
Self::NotANumber(part) => {
write!(f, "\"{part}\" is not a number of milliseconds")
}
Self::NotPositive(value) => write!(
f,
"{value} is not a length of time; both ends must be above zero"
),
Self::Backwards { lo, hi } => write!(
f,
"the range runs backwards: {lo} is not below {hi}, and the low end \
comes first"
),
Self::TooShort { lo, least } => write!(
f,
"{lo} ms is below the {least} ms floor. The ratchet can only fire on \
a frame boundary, so anything shorter cannot be drawn"
),
Self::TooLong { hi, most } => write!(
f,
"{hi} ms is past the {most} ms ceiling. A ratchet that slow is almost \
certainly a typo, and a long interval weakens forward secrecy without \
buying anything"
),
}
}
}
impl std::error::Error for RangeError {}
/// Read a `low,high` ratchet range in milliseconds, or say why not.
///
/// A comma or a dash may separate the two, because both are what people type.
/// Everything else is **refused with the reason** and nothing is ever adjusted
/// to fit -- see [`RangeError`].
///
/// The range is not quantised here. One frame is the smallest gap that can
/// happen at all, so a range narrower than a frame collapses to a single value
/// once it meets the engine; [`DeidConfig::effective_reseed_range_ms`] is what
/// a range really comes to and is what an interface should display beside it.
pub fn parse_reseed_range(text: &str) -> Result<(f32, f32), RangeError> {
let cleaned = text.trim();
let parts: Vec<&str> = if cleaned.contains(',') {
cleaned.splitn(2, ',').collect()
} else if cleaned.len() > 1 {
// Searched from the second character so that a leading minus stays part
// of the first number and is refused as not-positive, rather than read
// as the separator and turning "-5-9" into a valid-looking pair.
match cleaned[1..].find('-') {
Some(at) => vec![&cleaned[..at + 1], &cleaned[at + 2..]],
None => vec![cleaned],
}
} else {
vec![cleaned]
};
if parts.len() != 2 {
return Err(RangeError::NotTwoNumbers(cleaned.to_string()));
}
let mut ends = [0.0f32; 2];
for (slot, part) in ends.iter_mut().zip(parts.iter()) {
let part = part.trim();
*slot = part
.parse::<f32>()
.map_err(|_| RangeError::NotANumber(part.to_string()))?;
if !slot.is_finite() || *slot <= 0.0 {
return Err(RangeError::NotPositive(*slot));
}
}
let (lo, hi) = (ends[0], ends[1]);
if lo >= hi {
return Err(RangeError::Backwards { lo, hi });
}
if lo < MIN_RESEED_MS {
return Err(RangeError::TooShort {
lo,
least: MIN_RESEED_MS,
});
}
if hi > MAX_RESEED_MS {
return Err(RangeError::TooLong {
hi,
most: MAX_RESEED_MS,
});
}
Ok((lo, hi))
}
impl DeidConfig {
/// How far the analysis window moves between frames, in samples.
///
/// At least one, whatever the configuration says, because a hop of zero would
/// never advance through the input.
fn hop(&self) -> usize {
(self.frame_size / self.overlap.max(1)).max(1)
}
/// How long one analysis frame is, in milliseconds.
///
/// The grain of everything the modulation does. No parameter can change
/// more often than this, because only one set of them exists per frame.
pub fn frame_ms(&self) -> f32 {
if self.sample_rate > 0.0 {
self.hop() as f32 * 1000.0 / self.sample_rate
} else {
0.0
}
}
/// The number of frames a millisecond interval comes to, at least one.
fn frames_for_ms(&self, ms: f32) -> u32 {
let frame = self.frame_ms();
if frame <= 0.0 {
return 1;
}
((ms / frame).round() as i64).clamp(1, u32::MAX as i64) as u32
}
/// What [`DeidConfig::reseed_range_ms`] actually comes to on this
/// configuration, after quantising to whole frames.
///
/// Show this, not the number the user typed. A request for 0.7 ms to
/// 2.7 ms comes back as 5.33 ms to 5.33 ms at the default frame size,
/// because a frame is the grain and the whole requested range is finer than
/// one. That is not a failure -- it is the fastest this can honestly roll --
/// but an interface that displayed "0.7 ms" would be claiming something
/// that is not happening.
pub fn effective_reseed_range_ms(&self) -> Option<(f32, f32)> {
let (lo, hi) = self.reseed_range_ms?;
let frame = self.frame_ms();
Some((
self.frames_for_ms(lo) as f32 * frame,
self.frames_for_ms(hi) as f32 * frame,
))
}
/// Whether the requested range is finer than one frame, so the whole of it
/// collapses onto a single interval.
///
/// A front end should say so where the control is, rather than leaving
/// somebody to wonder why moving the slider changes nothing.
pub fn reseed_range_is_finer_than_a_frame(&self) -> bool {
match self.reseed_range_ms {
Some((lo, hi)) => self.frames_for_ms(lo) == self.frames_for_ms(hi),
None => false,
}
}
/// This configuration with a roll range drawn from the OS CSPRNG.
///
/// The shipped interval is then a property of this launch rather than a
/// number compiled into the binary -- which is the point: a fixed ratchet
/// period is a fixed thing to observe, and every copy of VeilVoice having
/// the same one makes it a property of the *program* rather than of the
/// session.
///
/// The range is centred somewhere between one frame and about two seconds,
/// and both ends are drawn, so neither the period nor the spread is the
/// same twice. Falls back to leaving the configuration alone if the OS
/// CSPRNG cannot be read, because a de-identifier that refuses to start
/// over the *ratchet* -- which is forward secrecy, not irreversibility --
/// would be trading the whole feature for a nicety.
pub fn with_random_reseed_range(mut self) -> Self {
// Eight bytes, four per end, rather than the two per end this used to
// take. Two gave each end 16 bits and the pair 32, so two launches drew
// the same interval after about eighty thousand of them: that is the
// birthday bound for 32 bits and it is exactly what a uniform draw
// looks like, not a defect.
//
// It is widened anyway, and the reason is worth being exact about,
// because it is *not* that the old draw was crackable. The seed that
// does the veiling is 256 bits and is drawn separately in
// `Deidentifier::new`; nothing here feeds it. What this sets is the
// roll interval, which VeilVoice prints on purpose in `veilvoice info`
// and which is therefore not a secret at all.
//
// What a shared interval is, is a weak session fingerprint: two
// sessions carrying the same one are marginally more linkable by
// somebody watching both. Four more bytes, once, at launch, moves that
// collision from 2^32 to 2^64 and costs nothing measurable. A cheap
// removal of a weak signal, not a repair.
let mut bytes = [0u8; 8];
if getrandom::getrandom(&mut bytes).is_err() {
return self;
}
let a =
u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as f64 / u32::MAX as f64;
let b =
u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as f64 / u32::MAX as f64;
self.reseed_range_ms = Some(reseed_range_from(a as f32, b as f32, self.frame_ms()));
self
}
/// Scale a `(lo, hi)` ratio range toward 1.0 by `intensity`.
fn scaled(&self, bounds: (f32, f32)) -> (f32, f32) {
let s = self.intensity.clamp(0.0, 1.0);
(1.0 + (bounds.0 - 1.0) * s, 1.0 + (bounds.1 - 1.0) * s)
}
/// The largest sample rate this engine will build for, in Hz.
///
/// Every value in here is reachable from a file: a WAV's `fmt ` chunk
/// carries a **`u32`** sample rate, and `symphonia` passes whatever it
/// finds straight through. That number then sizes the delay lines in
/// `effects.rs`, where `Reverb`'s comb is `0.0297 × sample_rate` samples and
/// the chorus voices are similar, so a four-kilobyte file declaring
/// `u32::MAX` asks for roughly two gigabytes of buffers before a single
/// sample is processed. A failed allocation in Rust aborts the process,
/// which is the same shape as F-3: opening a hostile file kills the
/// program.
///
/// 768 kHz is chosen well above anything real. Professional converters top
/// out at 384 kHz and DSD-rate PCM at 705.6 kHz; nothing legitimate asks
/// for more, and the largest buffer this permits is a few megabytes.
pub const MAX_SAMPLE_RATE: f32 = 768_000.0;
/// The largest FFT size this engine will build for.
///
/// Bounded for the same reason as the sample rate: `frame_size` sizes every
/// internal buffer and the FFT plan, and there was previously no upper
/// limit at all, so a caller could ask for a `usize::MAX / 2` transform.
/// 65536 is eight times the largest size anyone uses for speech.
pub const MAX_FRAME_SIZE: usize = 1 << 16;
/// Validate and normalise; returns an error string on impossible values.
///
/// Every float is checked for finiteness, not merely for range. A `NaN`
/// compares false against every bound, so a bare `self.sample_rate <
/// 8_000.0` test *passes* `NaN`, and an engine built at a `NaN` sample
/// rate produced `NaN` for every output sample, for the whole session,
/// with nothing reported. That is F-5 arriving through a second door: F-5
/// sanitised the samples, and nothing sanitised the configuration they were
/// processed under.
pub fn checked(mut self) -> Result<Self, String> {
if self.frame_size < 64 || !self.frame_size.is_multiple_of(2) {
return Err("frame_size must be even and >= 64".into());
}
if self.frame_size > Self::MAX_FRAME_SIZE {
return Err(format!(
"frame_size must be at most {}",
Self::MAX_FRAME_SIZE
));
}
if !(2..=16).contains(&self.overlap) {
return Err("overlap must be in 2..=16".into());
}
if !self.frame_size.is_multiple_of(self.overlap) {
return Err("frame_size must be divisible by overlap".into());
}
// `is_finite` first: `NaN < 8_000.0` is false, so a range test alone
// lets it through.
if !self.sample_rate.is_finite() {
return Err("sample_rate must be a real number".into());
}
if self.sample_rate < 8_000.0 {
return Err("sample_rate too low".into());
}
if self.sample_rate > Self::MAX_SAMPLE_RATE {
return Err(format!(
"sample_rate {} Hz is above the {} Hz this engine will build for",
self.sample_rate,
Self::MAX_SAMPLE_RATE
));
}
if !self.reseed_secs.is_finite() || self.reseed_secs < 0.0 {
return Err("reseed_secs must be zero or a positive number of seconds".into());
}
// Refused rather than clamped, and refused rather than silently
// reordered. A reversed range is a typo, and a caller who wrote one
// believes something about what their recording is doing.
if let Some((lo, hi)) = self.reseed_range_ms {
if !lo.is_finite() || !hi.is_finite() {
return Err("reseed_range_ms must be two real numbers".into());
}
if lo > hi {
return Err(format!(
"reseed_range_ms is {lo} to {hi} ms, which is backwards. Give the \
shorter interval first."
));
}
if lo < MIN_RESEED_MS || hi > MAX_RESEED_MS {
return Err(format!(
"reseed_range_ms {lo} to {hi} ms is outside {MIN_RESEED_MS} to \
{MAX_RESEED_MS} ms"
));
}
}
// The remaining floats are all clamped rather than refused, because
// every one of them has a meaningful nearest legal value. A `NaN`
// does not clamp, it propagates, so it is refused by name.
for (name, value) in [
("intensity", self.intensity),
("mod_smooth", self.mod_smooth),
("distortion_drive", self.distortion_drive),
("distortion_mix", self.distortion_mix),
("chorus_mix", self.chorus_mix),
("reverb_mix", self.reverb_mix),
("pitch_bounds.0", self.pitch_bounds.0),
("pitch_bounds.1", self.pitch_bounds.1),
("formant_bounds.0", self.formant_bounds.0),
("formant_bounds.1", self.formant_bounds.1),
] {
if !value.is_finite() {
return Err(format!("{name} must be a real number"));
}
}
self.intensity = self.intensity.clamp(0.0, 1.0);
self.mod_smooth = self.mod_smooth.clamp(1e-6, 1.0);
self.distortion_mix = self.distortion_mix.clamp(0.0, 1.0);
self.chorus_mix = self.chorus_mix.clamp(0.0, 1.0);
self.reverb_mix = self.reverb_mix.clamp(0.0, 1.0);
self.distortion_drive = self.distortion_drive.clamp(0.01, 64.0);
// Ratios outside this are not a transform, they are a resampler with a
// pathological factor; `resample_linear` already substitutes 1.0 for
// anything non-finite, and this stops the merely absurd as well.
self.pitch_bounds = clamp_ratio_bounds(self.pitch_bounds);
self.formant_bounds = clamp_ratio_bounds(self.formant_bounds);
Ok(self)
}
}
/// Keep a `(lo, hi)` ratio pair inside a range a resampler can act on, and in
/// the right order.
fn clamp_ratio_bounds((lo, hi): (f32, f32)) -> (f32, f32) {
const MIN: f32 = 0.05;
const MAX: f32 = 20.0;
let lo = lo.clamp(MIN, MAX);
let hi = hi.clamp(MIN, MAX);
if lo <= hi {
(lo, hi)
} else {
(hi, lo)
}
}
/// Rolling performance statistics, surfaced live to the UI.
#[derive(Clone, Copy, Debug, Default)]
pub struct ProcessStats {
/// Total blocks processed.
pub blocks: u64,
/// Total input samples processed.
pub samples: u64,
/// Wall-clock microseconds for the most recent block.
pub last_block_us: f64,
/// Worst (maximum) block time observed, microseconds.
pub worst_block_us: f64,
/// Exponential moving average of block time, microseconds.
pub ema_block_us: f64,
/// Sample count of the most recent block.
pub last_block_samples: usize,
/// Sample rate (for realtime-factor computation).
pub sample_rate: f32,
/// Fixed algorithmic latency of the STFT, milliseconds.
pub algorithmic_latency_ms: f64,
/// Frames until the next seed roll, as last drawn.
///
/// With a randomised range this changes at every roll; with a fixed
/// interval it is constant, and it is zero when rolling is off.
pub reseed_frames: u32,
/// The same figure in milliseconds -- the interval **actually** in force.
///
/// This is the number to show a user, not the one they typed: the request
/// is quantised to whole frames, and at the default frame size a frame is
/// 5.33 ms.
pub reseed_interval_ms: f64,
}
impl ProcessStats {
/// Most recent block processing time in milliseconds.
pub fn last_block_ms(&self) -> f64 {
self.last_block_us / 1000.0
}
/// Worst block processing time in milliseconds.
pub fn worst_block_ms(&self) -> f64 {
self.worst_block_us / 1000.0
}
/// Smoothed block processing time in milliseconds.
pub fn ema_block_ms(&self) -> f64 {
self.ema_block_us / 1000.0
}
/// Processing time divided by the block's real-time duration. < 1.0 means
/// the machine keeps up with real time; the headroom is `1 - factor`.
pub fn last_realtime_factor(&self) -> f64 {
let audio_us = self.last_block_samples as f64 / self.sample_rate as f64 * 1e6;
if audio_us > 0.0 {
self.last_block_us / audio_us
} else {
0.0
}
}
}
/// The complete, irreversible voice de-identification chain.
///
/// Feed it mono `f32` samples; it returns mono `f32` samples of equal length,
/// delayed by [`Deidentifier::latency_samples`]. Not real-time-thread cheap to
/// *construct* (allocates FFT plans), but `process` performs no heap
/// allocation and is safe to run inside an audio callback.
pub struct Deidentifier {
stft: StftEngine,
spectral: SpectralState,
modulator: Modulator,
accent: AccentNeutralizer,
pitch: PitchTracker,
softclip: SoftClip,
chorus: Chorus,
reverb: Reverb,
stats: ProcessStats,
latency_samples: usize,
hop: usize,
/// One frame in milliseconds, kept so the per-block statistics need no
/// division by a sample rate inside the hot path.
frame_ms: f64,
/// Frames between seed rolls; 0 disables rolling entirely.
///
/// With a randomised range this is the *last drawn* interval rather than a
/// constant, and `reseed_span` is the range each new one is drawn from.
reseed_frames: u32,
/// The inclusive frame range each interval is drawn from, or `None` for a
/// fixed interval.
reseed_span: Option<(u32, u32)>,
frames_until_reseed: u32,
/// Pre-allocated, so a roll never allocates inside an audio callback.
phase_scratch: Vec<f32>,
}
impl Deidentifier {
/// Build with a fresh, unpredictable seed from the OS CSPRNG.
pub fn new(config: DeidConfig) -> Result<Self, String> {
let mut seed = [0u8; 32];
getrandom::getrandom(&mut seed).map_err(|e| format!("OS CSPRNG: {e}"))?;
Self::from_seed(config, seed)
}
/// Build with an explicit seed (deterministic; for tests or seed-from-key).
pub fn from_seed(config: DeidConfig, seed: [u8; 32]) -> Result<Self, String> {
let config = config.checked()?;
let n = config.frame_size;
let hop = config.hop();
let half = n / 2 + 1;
let mut modulator = Modulator::from_seed(
seed,
config.scaled(config.pitch_bounds),
config.scaled(config.formant_bounds),
config.frames_per_target,
config.mod_smooth,
);
// Draw the fixed per-bin phase offsets from the same CSPRNG stream.
let mut phase = vec![0.0f32; half];
modulator.fill_phase_offsets(&mut phase);
let stft = StftEngine::new(n, hop);
let latency_samples = stft.latency_samples();
let spectral = SpectralState::new(n, hop, config.sample_rate, &phase);
let accent = AccentNeutralizer::new(config.accent, config.sample_rate, n, hop, half);
let pitch = PitchTracker::new(config.sample_rate);
// Rolls are counted in frames so the audio thread never touches a clock.
let reseed_span = config
.reseed_range_ms
.map(|(lo, hi)| (config.frames_for_ms(lo), config.frames_for_ms(hi)));
let reseed_frames = match reseed_span {
// The first interval is drawn like every other one, so the very
// first roll is no more predictable than the rest.
Some((lo, hi)) => modulator.draw_frames(lo, hi),
None if config.reseed_secs > 0.0 => ((config.reseed_secs * config.sample_rate)
/ hop as f32)
.round()
.max(1.0) as u32,
None => 0,
};
let stats = ProcessStats {
sample_rate: config.sample_rate,
algorithmic_latency_ms: latency_samples as f64 / config.sample_rate as f64 * 1000.0,
reseed_frames,
reseed_interval_ms: reseed_frames as f64 * config.frame_ms() as f64,
..Default::default()
};
Ok(Self {
stft,
spectral,
modulator,
accent,
pitch,
softclip: SoftClip::new(config.distortion_drive, config.distortion_mix),
chorus: Chorus::new(config.sample_rate, config.chorus_mix),
reverb: Reverb::new(config.sample_rate, config.reverb_mix),
stats,
latency_samples,
hop,
frame_ms: config.frame_ms() as f64,
reseed_frames,
reseed_span,
frames_until_reseed: reseed_frames,
phase_scratch: phase,
})
}
/// Fixed algorithmic latency in samples.
pub fn latency_samples(&self) -> usize {
self.latency_samples
}
/// Live performance statistics (copy).
pub fn stats(&self) -> ProcessStats {
self.stats
}
/// Live accent-neutralisation read-out (detected f0, applied ratios).
pub fn accent_stats(&self) -> AccentStats {
self.accent.stats()
}
/// Process `input` into `output` (equal length). Allocation-free; safe for
/// an audio callback. Updates [`Deidentifier::stats`].
pub fn process(&mut self, input: &[f32], output: &mut [f32]) {
assert_eq!(input.len(), output.len());
let start = Instant::now();
// Disjoint field borrows so the per-frame closure can drive the
// modulator + spectral transform while the STFT owns the FFT plumbing.
let spectral = &mut self.spectral;
let modulator = &mut self.modulator;
let accent = &mut self.accent;
let tracker = &mut self.pitch;
let hop = self.hop;
let reseed_span = self.reseed_span;
let interval = &mut self.reseed_frames;
let countdown = &mut self.frames_until_reseed;
let phase_scratch = &mut self.phase_scratch;
self.stft.process(input, output, |spec, frame| {
// Roll the stream forward. Cheap, allocation-free and syscall-free:
// the new seed is drawn from the stream it replaces, and so is the
// gap before the next roll.
if *interval > 0 {
*countdown = countdown.saturating_sub(1);
if *countdown == 0 {
modulator.reseed();
modulator.fill_phase_offsets(phase_scratch);
spectral.retarget_phase_offsets(phase_scratch);
// Drawn *after* the roll, so the next gap comes from the new
// stream. Drawing it before would leave the timing of every
// future roll recoverable from a seed that the roll was
// supposed to have closed off.
*interval = match reseed_span {
Some((lo, hi)) => modulator.draw_frames(lo, hi),
None => *interval,
};
*countdown = *interval;
}
}
let m = modulator.next_frame();
// f0 has to come from the time domain: the FFT resolution at usable
// frame sizes cannot tell 100 Hz from 140 Hz. Only the newest `hop`
// samples are new; the tracker keeps its own longer history.
tracker.push(&frame[frame.len() - hop..]);
let est = tracker.estimate();
accent.observe(est);
spectral.transform(spec, m.pitch_ratio, m.formant_ratio, Some(accent), est);
});
// Time-domain effect tail.
for s in output.iter_mut() {
let mut y = self.softclip.process(*s);
y = self.chorus.process(y);
y = self.reverb.process(y);
*s = y;
}
self.stats.reseed_frames = self.reseed_frames;
self.stats.reseed_interval_ms = self.reseed_frames as f64 * self.frame_ms;
let us = start.elapsed().as_nanos() as f64 / 1000.0;
self.stats.blocks += 1;
self.stats.samples += input.len() as u64;
self.stats.last_block_us = us;
self.stats.last_block_samples = input.len();
self.stats.worst_block_us = self.stats.worst_block_us.max(us);
self.stats.ema_block_us = if self.stats.blocks == 1 {
us
} else {
0.05 * us + 0.95 * self.stats.ema_block_us
};
}
/// Convenience: process a whole buffer and return a new `Vec`.
pub fn process_vec(&mut self, input: &[f32]) -> Vec<f32> {
let mut out = vec![0.0; input.len()];
self.process(input, &mut out);
out
}
}
/// Turn two ratios in `0.0..=1.0` into a reseed range in milliseconds.
///
/// One frame at the fast end, about two seconds at the slow end, the two draws
/// sorted so the range is never reversed, and **never narrower than one
/// frame**.
///
/// # Why the minimum width is not a nicety
///
/// Each draw is sixteen bits, so one launch in 65,536 draws the same number
/// twice and the range comes out with no width at all. `checked` accepts that,
/// because it only refuses a range that is backwards, and a front end showing
/// it would show two identical numbers. What it means is a **fixed** reseed
/// interval, which is precisely the fixed ratchet period this function exists
/// to avoid: the whole argument for drawing the range is that the period is a
/// property of the session rather than of the program.
///
/// So a collision is widened by one frame, from whichever end has room. One
/// frame because that is the resolution the engine actually has:
/// `reseed_range_is_finer_than_a_frame` exists to report a range that collapses
/// onto a single interval, and a drawn range should never be one.
///
/// Found by `a_drawn_range_is_always_valid` failing once, in a verification run
/// that had already passed eleven times. The test was right and the code was
/// wrong; sixty-four draws a run against a one in 65,536 event is a coin that
/// comes up about once in a thousand runs. F-188.
fn reseed_range_from(a: f32, b: f32, frame_ms: f32) -> (f32, f32) {
let frame = frame_ms.max(MIN_RESEED_MS);
let span = (2000.0f32 - frame).max(frame);
let (mut lo, mut hi) = if a <= b { (a, b) } else { (b, a) };
// One frame expressed in the same units as the draws, and never more than
// the whole of them: where the frame is longer than the span, which happens
// once the frame passes a second, the widest honest answer is everything.
let least = (frame / span).min(1.0);
if hi - lo < least {
// Widened around the draw rather than from one end, so a collision near
// the slow end stays near the slow end instead of being thrown to the
// fast one. The second clamp catches the case where the first ran into
// the top.
let middle = (lo + hi) * 0.5;
lo = (middle - least * 0.5).max(0.0);
hi = (lo + least).min(1.0);
if hi - lo < least {
lo = (hi - least).max(0.0);
}
}
(frame + lo * span, frame + hi * span)
}
#[cfg(test)]
mod tests {
use super::*;
fn rms(x: &[f32]) -> f32 {
if x.is_empty() {
return 0.0;
}
(x.iter().map(|v| v * v).sum::<f32>() / x.len() as f32).sqrt()
}
/// One frame is the grain of everything the modulation does, and the
/// documented figure -- 5.33 ms at 48 kHz and the default frame size -- is
/// the number every claim about roll intervals rests on.
#[test]
fn a_frame_is_the_documented_length() {
let config = DeidConfig::default();
assert_eq!(config.frame_size, 1024);
assert_eq!(config.overlap, 4);
assert!(
(config.frame_ms() - 5.333).abs() < 0.01,
"a frame is {} ms, not the 5.33 ms the documentation claims",
config.frame_ms()
);
}
/// The request that started this: 0.7 ms to 2.7 ms. The whole range is
/// finer than one frame, so it collapses onto exactly one interval -- and
/// the engine must say so rather than displaying the number typed.
#[test]
fn a_range_finer_than_a_frame_collapses_and_admits_it() {
let config = DeidConfig {
reseed_range_ms: Some((0.7, 2.7)),
..DeidConfig::default()
};
let (lo, hi) = config
.effective_reseed_range_ms()
.expect("a range was asked for");
assert!((lo - config.frame_ms()).abs() < 1e-3, "{lo}");
assert!((hi - config.frame_ms()).abs() < 1e-3, "{hi}");
assert!(
config.reseed_range_is_finer_than_a_frame(),
"the collapse must be reportable, or the control lies about itself"
);
}
/// A range wide enough to hold several frames keeps its width.
#[test]
fn a_range_wider_than_a_frame_survives_quantisation() {
let config = DeidConfig {
reseed_range_ms: Some((20.0, 200.0)),
..DeidConfig::default()
};
let (lo, hi) = config.effective_reseed_range_ms().unwrap();
assert!(hi > lo * 5.0, "{lo} to {hi} lost its width");
assert!(!config.reseed_range_is_finer_than_a_frame());
// And both ends are whole frames.
for value in [lo, hi] {
let frames = value / config.frame_ms();
assert!(
(frames - frames.round()).abs() < 1e-3,
"{value} ms is not a whole number of frames"
);
}
}
/// A reversed range is a typo about what a recording is doing, so it is
/// refused rather than quietly sorted.
#[test]
fn a_backwards_range_is_refused_rather_than_reordered() {
let error = DeidConfig {
reseed_range_ms: Some((200.0, 20.0)),
..DeidConfig::default()
}
.checked()
.expect_err("backwards must be refused");
assert!(error.contains("backwards"), "{error}");
assert!(DeidConfig {
reseed_range_ms: Some((f32::NAN, 20.0)),
..DeidConfig::default()
}
.checked()
.is_err());
assert!(
DeidConfig {
reseed_range_ms: Some((0.0, 20.0)),
..DeidConfig::default()
}
.checked()
.is_err(),
"zero is below the floor and must be refused"
);
assert!(DeidConfig {
reseed_range_ms: Some((1.0, 1e9)),
..DeidConfig::default()
}
.checked()
.is_err());
}
/// The interval in force is reported, and with a randomised range it
/// actually changes as the recording runs. Without that, the feature is a
/// setting that does nothing.
#[test]
fn a_randomised_interval_changes_as_the_audio_runs() {
let config = DeidConfig {
// Wide enough in frames that two consecutive draws being equal by
// chance is unlikely, and short enough that a second of audio
// contains many rolls.
reseed_range_ms: Some((10.0, 120.0)),
..DeidConfig::default()
};
let mut deid = Deidentifier::from_seed(config, [7u8; 32]).unwrap();
let block = vec![0.05f32; 4096];
let mut out = vec![0.0f32; 4096];
let mut seen = std::collections::BTreeSet::new();
for _ in 0..40 {
deid.process(&block, &mut out);
seen.insert(deid.stats().reseed_frames);
}
assert!(
seen.len() > 1,
"the interval never changed: {seen:?} -- the range is not being drawn from"
);
for frames in &seen {
let ms = *frames as f64 * DeidConfig::default().frame_ms() as f64;
assert!(
(9.0..=125.0).contains(&ms),
"{ms} ms is outside the range that was asked for"
);
}
}
/// A fixed interval must still report itself, and must not wander.
#[test]
fn a_fixed_interval_is_reported_and_stays_put() {
let config = DeidConfig {
reseed_secs: 0.1,
..DeidConfig::default()
};
let mut deid = Deidentifier::from_seed(config, [9u8; 32]).unwrap();
let block = vec![0.05f32; 4096];
let mut out = vec![0.0f32; 4096];
let mut seen = std::collections::BTreeSet::new();
for _ in 0..20 {
deid.process(&block, &mut out);
seen.insert(deid.stats().reseed_frames);
}
assert_eq!(seen.len(), 1, "a fixed interval moved: {seen:?}");
let frames = *seen.iter().next().unwrap();
assert!(
(frames as f32 * DeidConfig::default().frame_ms() - 100.0).abs() < 6.0,
"{frames} frames is not about 100 ms"
);
}
/// Rolling off means no interval at all, not an interval of zero length.
#[test]
fn rolling_off_reports_no_interval() {
let config = DeidConfig {
reseed_secs: 0.0,
..DeidConfig::default()
};
let mut deid = Deidentifier::from_seed(config, [3u8; 32]).unwrap();
let block = vec![0.05f32; 2048];
let mut out = vec![0.0f32; 2048];
deid.process(&block, &mut out);
assert_eq!(deid.stats().reseed_frames, 0);
assert_eq!(deid.stats().reseed_interval_ms, 0.0);
}
/// A randomised range is still deterministic from a seed, or the test
/// suite could not hold anything about it.
#[test]
fn the_same_seed_draws_the_same_intervals() {
let config = DeidConfig {
reseed_range_ms: Some((10.0, 120.0)),
..DeidConfig::default()
};
let run = || {
let mut deid = Deidentifier::from_seed(config, [42u8; 32]).unwrap();
let block = vec![0.05f32; 4096];
let mut out = vec![0.0f32; 4096];
let mut intervals = Vec::new();
for _ in 0..20 {
deid.process(&block, &mut out);
intervals.push(deid.stats().reseed_frames);
}
intervals
};
assert_eq!(run(), run());
}
/// The launch-time randomiser must produce something the engine accepts,
/// every time, and something that is not the same on two calls.
#[test]
fn a_randomised_launch_range_is_valid_and_not_a_constant() {
let mut seen = std::collections::BTreeSet::new();
for _ in 0..12 {
let config = DeidConfig::default().with_random_reseed_range();
let checked = config
.checked()
.expect("the launch randomiser must produce a legal range");
let (lo, hi) = checked.reseed_range_ms.expect("a range was set");
assert!(lo <= hi, "{lo} to {hi}");
assert!(lo >= checked.frame_ms() - 1e-3, "{lo} is below one frame");
Deidentifier::from_seed(checked, [1u8; 32]).expect("and one the engine can build from");
seen.insert(format!("{lo:.3}-{hi:.3}"));
}
assert!(
seen.len() > 1,
"the launch randomiser returned the same range every time"
);
}
#[test]
fn config_rejects_impossible_values() {
assert!(DeidConfig {
overlap: 1,
..Default::default()
}
.checked()
.is_err());
assert!(DeidConfig {
frame_size: 1000,
overlap: 3,
..Default::default()
}
.checked()
.is_err());
assert!(DeidConfig::default().checked().is_ok());
}
#[test]
fn output_finite_and_length_preserved() {
let mut d = Deidentifier::from_seed(DeidConfig::default(), [42u8; 32]).unwrap();
let input: Vec<f32> = (0..48_000)
.map(|i| (i as f32 * 220.0 * std::f32::consts::TAU / 48_000.0).sin() * 0.3)
.collect();
let out = d.process_vec(&input);
assert_eq!(out.len(), input.len());
assert!(out.iter().all(|v| v.is_finite()));
}
#[test]
fn loudness_roughly_preserved_no_runaway() {
// De-identified speech must remain audible: not silent, not exploding.
let mut d = Deidentifier::from_seed(DeidConfig::default(), [7u8; 32]).unwrap();
// voiced-like input: fundamental + a few harmonics
let sr = 48_000.0;
let input: Vec<f32> = (0..sr as usize)
.map(|i| {
let t = i as f32 / sr;
0.3 * (2.0 * std::f32::consts::PI * 140.0 * t).sin()
+ 0.15 * (2.0 * std::f32::consts::PI * 280.0 * t).sin()
+ 0.1 * (2.0 * std::f32::consts::PI * 420.0 * t).sin()
})
.collect();
let out = d.process_vec(&input);
let (ri, ro) = (rms(&input), rms(&out[sr as usize / 4..])); // skip warm-up
assert!(ro > ri * 0.15, "output too quiet: in={ri} out={ro}");
assert!(ro < ri * 6.0, "output runaway: in={ri} out={ro}");
}
#[test]
fn different_seeds_produce_different_output() {
let input: Vec<f32> = (0..24_000).map(|i| (i as f32 * 0.05).sin() * 0.3).collect();
let a = Deidentifier::from_seed(DeidConfig::default(), [1u8; 32])
.unwrap()
.process_vec(&input);
let b = Deidentifier::from_seed(DeidConfig::default(), [2u8; 32])
.unwrap()
.process_vec(&input);
let diff: f32 = a.iter().zip(&b).map(|(x, y)| (x - y).abs()).sum();
assert!(
diff > 1.0,
"distinct seeds must yield distinct audio (diff={diff})"
);
}
/// Harmonically rich voiced speech from a speaker with a given pitch and
/// vocal-tract scale (`vtl` > 1 = longer tract = lower formants).
fn speaker(f0: f32, vtl: f32, secs: f32) -> Vec<f32> {
let sr = 48_000.0f32;
let n = (sr * secs) as usize;
(0..n)
.map(|i| {
let t = i as f32 / sr;
let mut s = 0.0;
for h in 1..=24 {
let f = f0 * h as f32;
if f > sr * 0.45 {
break;
}
let mut g = 1.0 / h as f32;
for &cf in &[700.0f32, 1220.0, 2600.0] {
g += 0.9 / (1.0 + ((f - cf / vtl) / 110.0).powi(2)) / h as f32;
}
s += g * (std::f32::consts::TAU * f * t).sin();
}
s * 0.1
})
.collect()
}
/// Isolate the accent path: no random modulation, no time-domain effects.
fn accent_only(accent: AccentConfig) -> DeidConfig {
DeidConfig {
intensity: 0.0,
distortion_mix: 0.0,
chorus_mix: 0.0,
reverb_mix: 0.0,
accent,
..Default::default()
}
}
fn measure_f0(signal: &[f32]) -> f32 {
let mut t = crate::pitch::PitchTracker::new(48_000.0);
t.push(signal);
t.estimate().f0_hz
}
/// The end-to-end claim: two speakers who differ sharply in register go in,
/// and come out sharing one canonical register.
#[test]
fn accent_neutralisation_converges_speakers_end_to_end() {
let cfg = accent_only(AccentConfig::default());
let (lo_f0, hi_f0) = (105.0f32, 230.0f32);
let run = |f0: f32, vtl: f32| {
let mut d = Deidentifier::from_seed(cfg, [11u8; 32]).unwrap();
let out = d.process_vec(&speaker(f0, vtl, 3.0));
// Skip warm-up; measure the settled tail.
measure_f0(&out[out.len() * 2 / 3..])
};
let out_lo = run(lo_f0, 1.15);
let out_hi = run(hi_f0, 0.87);
assert!(out_lo > 0.0 && out_hi > 0.0, "output should be voiced");
let before = (hi_f0 / lo_f0).log2().abs();
let after = (out_hi / out_lo).log2().abs();
assert!(
after < before * 0.35,
"registers should converge: {before:.2} octaves apart before, \
{after:.2} after ({out_lo:.0} Hz vs {out_hi:.0} Hz)"
);
}
#[test]
fn accent_neutralisation_can_be_switched_off() {
let input = speaker(210.0, 0.9, 2.0);
let off = accent_only(AccentConfig {
enabled: false,
..Default::default()
});
let out_off = Deidentifier::from_seed(off, [3u8; 32])
.unwrap()
.process_vec(&input);
let out_on = Deidentifier::from_seed(accent_only(AccentConfig::default()), [3u8; 32])
.unwrap()
.process_vec(&input);
let tail = input.len() * 2 / 3;
let f_on = measure_f0(&out_on[tail..]);
let f_off = measure_f0(&out_off[tail..]);
// Enabled, the output is a clean comb at the canonical register.
let target = AccentConfig::default().target_f0_hz;
assert!(
(f_on - target).abs() < 25.0,
"expected ~{target} Hz, got {f_on}"
);
// Disabled, the legacy channel-vocoder path runs instead, which does not
// produce that register.
assert!(
(f_off - target).abs() > 25.0,
"bypass should not land on the canonical register: {f_off}"
);
}
#[test]
fn accent_stats_are_populated() {
let mut d = Deidentifier::from_seed(DeidConfig::default(), [5u8; 32]).unwrap();
d.process_vec(&speaker(160.0, 1.0, 1.5));
let a = d.accent_stats();
assert!(a.voiced, "synthetic speech should register as voiced");
assert!(
(a.detected_f0_hz - 160.0).abs() < 12.0,
"f0={}",
a.detected_f0_hz
);
assert!(a.warmup > 0.99, "warm-up did not complete");
assert!(a.speaker_centroid_hz > 0.0);
}
/// Accent tracking must not cost the real-time budget: it is an addition to
/// the spectral work, not a multiple of it.
///
/// # Why this is a ratio and not a number
///
/// This asserted an absolute real-time factor under 0.5 until it failed,
/// and what it was measuring was the machine. A debug build under QEMU on
/// the armv7 job reported 0.557 while the same commit passed on every
/// native target: an emulated 32-bit target is far slower than the runner
/// hosting it, and there is no single number that is generous enough there
/// and tight enough to catch anything here.
///
/// The claim worth defending does not depend on the machine. The regression
/// this exists to catch, an un-decimated pitch search, is an order of
/// magnitude, and an order of magnitude is still an order of magnitude on a
/// slow processor. So the same audio is run twice on the same machine in
/// the same test, once with the neutraliser bypassed, and the two are
/// compared. Both runs are preceded by an unmeasured pass so that neither
/// is paying for a cold cache.
///
/// The bound is deliberately loose. This is a timing measurement on a
/// shared build machine, and a test that fails when somebody else's job
/// gets busy teaches people to re-run it rather than read it.
#[test]
fn accent_tracking_is_a_small_part_of_what_the_chain_costs() {
let input = speaker(150.0, 1.0, 1.0);
let cost = |enabled: bool| {
let config = DeidConfig {
accent: AccentConfig {
enabled,
..AccentConfig::default()
},
..DeidConfig::default()
};
let mut d = Deidentifier::from_seed(config, [8u8; 32]).unwrap();
let mut out = vec![0.0; 1024];
let mut run = |d: &mut Deidentifier| {
for block in input.chunks(1024) {
d.process(block, &mut out[..block.len()]);
}
};
run(&mut d);
let start = std::time::Instant::now();
run(&mut d);
start.elapsed().as_secs_f64()
};
let off = cost(false);
let on = cost(true);
println!("one second of audio: {off:.4}s bypassed, {on:.4}s with accent tracking");
assert!(off > 0.0, "the bypassed run took no measurable time");
assert!(
on < off * 4.0,
"accent tracking cost {:.1} times the rest of the chain ({on:.4}s against {off:.4}s), \
which is the shape of a search that stopped being decimated",
on / off
);
}
/// The property that makes rolling usable at all: it must be inaudible.
/// A discontinuity in the phase offsets would show up as a sample-to-sample
/// jump far larger than the signal ever produces on its own.
#[test]
fn rolling_the_seed_introduces_no_clicks() {
let input = speaker(150.0, 1.0, 6.0);
let worst_jump = |cfg: DeidConfig| {
let out = Deidentifier::from_seed(cfg, [21u8; 32])
.unwrap()
.process_vec(&input);
out.windows(2)
.skip(4_800) // past the engine's warm-up
.fold(0.0f32, |m, w| m.max((w[1] - w[0]).abs()))
};
let steady = worst_jump(DeidConfig {
reseed_secs: 0.0,
..Default::default()
});
// Fast enough to roll several times inside the test signal.
let rolling = worst_jump(DeidConfig {
reseed_secs: 0.25,
..Default::default()
});
assert!(
rolling <= steady * 1.5 + 1e-3,
"rolling produced a discontinuity: worst jump {rolling:.4} vs {steady:.4} without"
);
}
#[test]
fn rolling_changes_the_audio_but_keeps_it_sane() {
let input = speaker(150.0, 1.0, 5.0);
let steady = Deidentifier::from_seed(
DeidConfig {
reseed_secs: 0.0,
..Default::default()
},
[4u8; 32],
)
.unwrap()
.process_vec(&input);
let rolling = Deidentifier::from_seed(
DeidConfig {
reseed_secs: 0.5,
..Default::default()
},
[4u8; 32],
)
.unwrap()
.process_vec(&input);
assert!(rolling.iter().all(|v| v.is_finite()));
let diff: f32 = steady
.iter()
.zip(&rolling)
.map(|(a, b)| (a - b).abs())
.sum();
assert!(
diff > 1.0,
"rolling should change the modulation (diff={diff})"
);
let rms = |x: &[f32]| (x.iter().map(|v| v * v).sum::<f32>() / x.len() as f32).sqrt();
let ratio = rms(&rolling) / rms(&steady);
assert!(
(0.5..2.0).contains(&ratio),
"rolling should not change the level ({ratio:.2}x)"
);
}
/// Rolling must not cost determinism, because reproducible builds and the whole
/// test suite depend on `from_seed` being repeatable.
#[test]
fn rolling_stays_deterministic_for_a_given_seed() {
let input = speaker(180.0, 1.0, 3.0);
let cfg = DeidConfig {
reseed_secs: 0.3,
..Default::default()
};
let a = Deidentifier::from_seed(cfg, [77u8; 32])
.unwrap()
.process_vec(&input);
let b = Deidentifier::from_seed(cfg, [77u8; 32])
.unwrap()
.process_vec(&input);
assert_eq!(a, b, "same seed must give the same audio, rolling or not");
}
#[test]
fn reseed_interval_is_validated() {
assert!(DeidConfig {
reseed_secs: -1.0,
..Default::default()
}
.checked()
.is_err());
assert!(DeidConfig {
reseed_secs: f32::NAN,
..Default::default()
}
.checked()
.is_err());
assert!(DeidConfig {
reseed_secs: 0.0,
..Default::default()
}
.checked()
.is_ok());
assert!(DeidConfig {
reseed_secs: 2.0,
..Default::default()
}
.checked()
.is_ok());
}
#[test]
fn stats_are_populated() {
let mut d = Deidentifier::from_seed(DeidConfig::default(), [9u8; 32]).unwrap();
let input = vec![0.1f32; 8192];
d.process(&input, &mut vec![0.0; 8192]);
let s = d.stats();
assert_eq!(s.blocks, 1);
assert!(s.last_block_us > 0.0);
assert!(s.algorithmic_latency_ms > 0.0);
}
}
#[cfg(test)]
mod reseed_range_tests {
use super::*;
/// **F-73.** The front ends must actually draw a range at launch.
///
/// [`DeidConfig::reseed_range_ms`]'s own documentation said "the front ends
/// call [`DeidConfig::with_random_reseed_range`] at launch, which is what
/// makes the shipped interval something other than a number compiled in".
/// Nothing called it. It was written, documented, tested in isolation, and
/// reached by no code path for two releases, so every shipped copy rolled
/// on the same fixed two-second period -- exactly the thing the sentence
/// said was not happening.
///
/// A comment cannot be tested, so this tests the code the comment is about.
/// It reads both front ends and fails the build if the call is gone.
#[test]
fn both_front_ends_draw_a_random_range_at_launch() {
let here = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let crates = here.parent().expect("crates/");
for (crate_name, file) in [
("veilvoice-cli", "src/main.rs"),
("veilvoice-gui", "src/app.rs"),
] {
let path = crates.join(crate_name).join(file);
let source = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("{}: {e}", path.display()))
.replace("\r\n", "\n");
let code: String = source
.lines()
.filter(|line| {
let trimmed = line.trim_start();
!trimmed.starts_with("//") && !trimmed.starts_with("///")
})
.collect::<Vec<_>>()
.join("\n");
assert!(
code.contains("with_random_reseed_range()"),
"{crate_name} does not draw a ratchet range at launch, so every copy \
of it ships the same fixed period"
);
}
}
/// A drawn range is different from run to run. If it were not, it would be
/// a compiled-in number wearing a random-looking coat.
#[test]
fn a_drawn_range_is_not_the_same_twice() {
let mut seen = std::collections::BTreeSet::new();
for _ in 0..8 {
let range = DeidConfig::default()
.with_random_reseed_range()
.reseed_range_ms
.expect("a range");
seen.insert(format!("{:.3},{:.3}", range.0, range.1));
}
assert!(seen.len() > 1, "eight draws produced one range: {seen:?}");
}
/// Two equal draws must not give a range of no width, which is a fixed
/// interval wearing a range's clothes.
#[test]
fn two_equal_draws_still_give_a_range_with_width_in_it() {
for frame_ms in [0.05f32, 1.0, 10.0, 21.3, 100.0, 2000.0, 5000.0] {
for step in 0..=64u32 {
let draw = step as f32 / 64.0;
let (lo, hi) = reseed_range_from(draw, draw, frame_ms);
assert!(
lo < hi,
"two draws of {draw} at a {frame_ms} ms frame gave {lo} to {hi}"
);
assert!(lo >= MIN_RESEED_MS, "{lo} is below the floor");
assert!(hi <= MAX_RESEED_MS, "{hi} is above the ceiling");
// And the width is a frame or more, which is the resolution the
// engine has: anything finer collapses onto one interval and is
// a fixed period wearing a range's clothes.
// A frame, or the whole of the available room where the
// frame is longer than that: past a one second frame the span
// is the frame, and everything is the widest honest answer.
let frame = frame_ms.max(MIN_RESEED_MS);
let span = (2000.0f32 - frame).max(frame);
let want = frame.min(span);
assert!(
hi - lo >= want * 0.99,
"{lo} to {hi} is narrower than {want} ms"
);
}
}
}
/// The same invariant over every pair of draws, not only equal ones.
#[test]
fn any_two_draws_give_a_usable_range() {
for i in 0..=16u32 {
for j in 0..=16u32 {
let (a, b) = (i as f32 / 16.0, j as f32 / 16.0);
let (lo, hi) = reseed_range_from(a, b, 21.3);
assert!(lo < hi, "draws {a} and {b} gave {lo} to {hi}");
assert!(lo >= MIN_RESEED_MS && hi <= MAX_RESEED_MS, "{lo} {hi}");
let config = DeidConfig {
reseed_range_ms: Some((lo, hi)),
..DeidConfig::default()
};
assert!(config.checked().is_ok(), "{lo} {hi} was refused");
}
}
}
/// A drawn range is usable: the right way round, inside the bounds, and
/// wide enough to survive quantisation.
#[test]
fn a_drawn_range_is_always_valid() {
for _ in 0..64 {
let config = DeidConfig::default().with_random_reseed_range();
let (lo, hi) = config.reseed_range_ms.expect("a range");
assert!(lo < hi, "{lo} {hi}");
assert!(lo >= MIN_RESEED_MS, "{lo}");
assert!(hi <= MAX_RESEED_MS, "{hi}");
assert!(config.checked().is_ok(), "{lo} {hi}");
}
}
/// Everything a person can type that is not a range is **refused**, and
/// the refusal says which thing was wrong. Nothing is adjusted to fit:
/// that is the whole of roadmap item 28's wording.
#[test]
fn bad_input_is_refused_with_a_reason_and_never_corrected() {
use RangeError::*;
for (text, expected) in [
("", NotTwoNumbers(String::new())),
("5", NotTwoNumbers("5".into())),
("abc,def", NotANumber("abc".into())),
("100,zzz", NotANumber("zzz".into())),
("0,100", NotPositive(0.0)),
("-5,100", NotPositive(-5.0)),
(
"1800,250",
Backwards {
lo: 1800.0,
hi: 250.0,
},
),
(
"100,100",
Backwards {
lo: 100.0,
hi: 100.0,
},
),
(
"0.001,100",
TooShort {
lo: 0.001,
least: MIN_RESEED_MS,
},
),
(
"100,900000",
TooLong {
hi: 900_000.0,
most: MAX_RESEED_MS,
},
),
] {
let got =
parse_reseed_range(text).expect_err("a value that is not a range must be refused");
assert_eq!(
std::mem::discriminant(&got),
std::mem::discriminant(&expected),
"{text:?} gave {got:?}"
);
// Every refusal has to be a sentence somebody can act on.
let words = got.to_string();
assert!(words.len() > 20, "{text:?}: {words}");
}
}
/// The shapes people actually type are accepted, and accepted exactly --
/// the numbers that come back are the numbers that went in.
#[test]
fn a_usable_range_survives_unchanged() {
for (text, want) in [
("250,1800", (250.0, 1800.0)),
(" 250 , 1800 ", (250.0, 1800.0)),
("250-1800", (250.0, 1800.0)),
("0.5,2", (0.5, 2.0)),
] {
let got = parse_reseed_range(text).unwrap_or_else(|e| panic!("{text:?}: {e}"));
assert_eq!(got, want, "{text:?}");
}
}
/// What an interface shows is what the engine will do, not what was asked
/// for. The ratchet only fires on a frame boundary, so a range is
/// quantised, and displaying the request would describe a spread that does
/// not exist.
#[test]
fn the_effective_range_is_quantised_to_whole_frames() {
let config = DeidConfig {
reseed_range_ms: Some((250.0, 1800.0)),
..DeidConfig::default()
};
let (lo, hi) = config.effective_reseed_range_ms().expect("a range");
let frame = config.frame_ms();
for value in [lo, hi] {
let frames = value / frame;
assert!(
(frames - frames.round()).abs() < 1e-3,
"{value} ms is not a whole number of {frame} ms frames"
);
}
assert!(lo >= 250.0 - frame && hi >= 1800.0 - frame, "{lo} {hi}");
}
}