crates/veilvoice-core/src/modulation.rs
what this file is for · veilvoice-core · 323 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
//! Cryptographically-seeded modulation of the effect parameters.
//!
//! The pitch and formant ratios are never constant: a ChaCha20 CSPRNG picks a
//! new random target every `frames_per_target` STFT frames, and a one-pole
//! filter glides continuously toward it. Because the transform is therefore
//! non-stationary and unpredictable, an attacker cannot "undo" it by assuming a
//! single fixed shift: there is no single shift to undo, and the target
//! sequence is unknowable without the seed (which never leaves the process and
//! is zeroized on drop).
//!
//! The seed does not stay put either. It is rolled forward every couple of
//! seconds by default (see [`Modulator::reseed`]), so the stream driving any
//! given stretch of audio is closed off permanently once that stretch is past.
//!
//! # In plain words
//!
//! The amount by which the voice is altered is never held still. It drifts,
//! constantly and unpredictably.
//!
//! The drift comes from the same kind of random number generator used for
//! encryption, so it cannot be guessed, worked out from what came before, or
//! reproduced by somebody who has the recording. It slides between values rather
//! than jumping, so nothing about it can be heard.
//!
//! This is what stops the transform from being reversed by anybody who works out
//! the settings, because there is no single setting to work out.
use rand::Rng;
use rand::RngCore;
use rand::SeedableRng;
use rand_chacha::ChaCha20Rng;
use zeroize::Zeroize;
/// One smoothly-varying parameter bounded to `[lo, hi]`.
struct Param {
lo: f32,
hi: f32,
current: f32,
target: f32,
smooth: f32, // one-pole coefficient in (0,1]; larger = snappier
}
impl Param {
/// Start in the middle of the range, so the first frames are not a slide
/// from an edge the caller never asked for.
fn new(lo: f32, hi: f32, smooth: f32) -> Self {
let mid = 0.5 * (lo + hi);
Self {
lo,
hi,
current: mid,
target: mid,
smooth,
}
}
/// Draw the next value to move towards. The draw is the only place the
/// keystream is consumed, which is what makes the modulation reproducible
/// from a seed and unpredictable without one.
fn retarget(&mut self, rng: &mut ChaCha20Rng) {
self.target = rng.gen_range(self.lo..=self.hi);
}
/// Move one step of the way towards the target and report where that is.
/// A one-pole approach rather than a jump: a parameter that steps is a
/// parameter you can hear stepping.
fn step(&mut self) -> f32 {
self.current += (self.target - self.current) * self.smooth;
self.current
}
}
/// The values handed to the spectral transform for one frame.
#[derive(Clone, Copy, Debug)]
pub struct ModValues {
/// Excitation pitch ratio (>1 raises pitch).
pub pitch_ratio: f32,
/// Envelope formant ratio (>1 moves formants up).
pub formant_ratio: f32,
}
/// Non-stationary parameter generator.
pub struct Modulator {
rng: ChaCha20Rng,
pitch: Param,
formant: Param,
frames_per_target: u32,
frame_in_seg: u32,
seed: [u8; 32],
}
impl Modulator {
/// Build from an explicit 32-byte seed (deterministic; used by tests and by
/// session-key-derived seeding).
pub fn from_seed(
seed: [u8; 32],
pitch_bounds: (f32, f32),
formant_bounds: (f32, f32),
frames_per_target: u32,
smooth: f32,
) -> Self {
Self {
rng: ChaCha20Rng::from_seed(seed),
pitch: Param::new(pitch_bounds.0, pitch_bounds.1, smooth),
formant: Param::new(formant_bounds.0, formant_bounds.1, smooth),
frames_per_target: frames_per_target.max(1),
frame_in_seg: 0,
seed,
}
}
/// The 32 fixed per-bin phase offsets consumer needs are derived from the
/// same stream; expose a helper that fills `out` with values in [0, 2π).
pub fn fill_phase_offsets(&mut self, out: &mut [f32]) {
for v in out.iter_mut() {
*v = self.rng.gen_range(0.0..std::f32::consts::TAU);
}
}
/// Roll onto a fresh seed, drawn from the current stream.
///
/// # Why a ratchet rather than fresh OS entropy
///
/// The new seed is 32 bytes of ChaCha20 output from the stream being
/// replaced. That buys the property that matters, **forward secrecy**.
/// ChaCha20 is not invertible, so an adversary who somehow learned the
/// current seed could generate everything from this moment on but could not
/// walk backwards to recover any earlier one. Each roll permanently closes
/// off the segment before it.
///
/// Reading fresh entropy from the OS instead would mean a syscall inside an
/// audio callback every couple of seconds, which is exactly the kind of
/// thing that causes a dropout, and it would make
/// [`crate::Deidentifier::from_seed`] non-deterministic, and losing the
/// reproducibility the test suite depends on. The chain is seeded from the
/// OS CSPRNG once at construction; the ratchet carries that unpredictability
/// forward without ever going back to the kernel.
///
/// The smoothed parameter values are deliberately **not** reset. Only the
/// source of future targets changes, so the glide continues through a roll
/// and there is no discontinuity to hear.
pub fn reseed(&mut self) {
let mut next = [0u8; 32];
self.rng.fill_bytes(&mut next);
self.rng = ChaCha20Rng::from_seed(next);
// Replace the retained copy, wiping the old one first.
self.seed.zeroize();
self.seed.copy_from_slice(&next);
next.zeroize();
}
/// Draw a whole number of frames uniformly from `lo..=hi`.
///
/// Used for the randomised roll interval: the gap before the next ratchet
/// is itself drawn from the stream, so it is as unpredictable as everything
/// else here and costs no syscall and no allocation -- which it cannot,
/// because it is drawn inside an audio callback.
///
/// `hi` below `lo` is treated as `lo`. That is not input validation --
/// [`crate::DeidConfig::checked`] refuses a reversed range long before this
/// is reached -- it is this function being total so that a caller cannot
/// produce a panic in an audio thread by arithmetic.
pub fn draw_frames(&mut self, lo: u32, hi: u32) -> u32 {
let lo = lo.max(1);
let hi = hi.max(lo);
if hi == lo {
return lo;
}
self.rng.gen_range(lo..=hi)
}
/// Advance one STFT frame and return the parameters to apply.
pub fn next_frame(&mut self) -> ModValues {
if self.frame_in_seg == 0 {
self.pitch.retarget(&mut self.rng);
self.formant.retarget(&mut self.rng);
}
self.frame_in_seg = (self.frame_in_seg + 1) % self.frames_per_target;
ModValues {
pitch_ratio: self.pitch.step(),
formant_ratio: self.formant.step(),
}
}
}
impl Drop for Modulator {
fn drop(&mut self) {
// Best-effort wipe of the retained seed (the ChaCha state itself is not
// exposed; the seed is the sensitive input).
self.seed.zeroize();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn mk(seed: u8) -> Modulator {
Modulator::from_seed([seed; 32], (0.8, 1.25), (0.85, 1.3), 8, 0.06)
}
#[test]
fn values_stay_in_bounds() {
let mut m = mk(1);
for _ in 0..10_000 {
let v = m.next_frame();
assert!((0.79..=1.26).contains(&v.pitch_ratio), "{}", v.pitch_ratio);
assert!(
(0.84..=1.31).contains(&v.formant_ratio),
"{}",
v.formant_ratio
);
}
}
#[test]
fn same_seed_is_deterministic() {
let mut a = mk(7);
let mut b = mk(7);
for _ in 0..500 {
let (x, y) = (a.next_frame(), b.next_frame());
assert_eq!(x.pitch_ratio.to_bits(), y.pitch_ratio.to_bits());
assert_eq!(x.formant_ratio.to_bits(), y.formant_ratio.to_bits());
}
}
#[test]
fn different_seed_diverges() {
let mut a = mk(1);
let mut b = mk(2);
let mut differ = false;
for _ in 0..500 {
let (x, y) = (a.next_frame(), b.next_frame());
if (x.pitch_ratio - y.pitch_ratio).abs() > 1e-6 {
differ = true;
break;
}
}
assert!(differ, "distinct seeds should produce distinct streams");
}
#[test]
fn reseeding_changes_the_stream() {
let mut rolled = mk(11);
let mut steady = mk(11);
for _ in 0..50 {
rolled.next_frame();
steady.next_frame();
}
rolled.reseed();
let mut diverged = false;
for _ in 0..500 {
if (rolled.next_frame().formant_ratio - steady.next_frame().formant_ratio).abs() > 1e-6
{
diverged = true;
break;
}
}
assert!(diverged, "a roll should change what comes next");
}
/// The ratchet must stay deterministic, or `from_seed` stops being
/// reproducible and the reproducible-build story goes with it.
#[test]
fn the_ratchet_is_deterministic() {
let mut a = mk(5);
let mut b = mk(5);
for round in 0..4 {
for _ in 0..30 {
let (x, y) = (a.next_frame(), b.next_frame());
assert_eq!(
x.formant_ratio.to_bits(),
y.formant_ratio.to_bits(),
"round {round}"
);
}
a.reseed();
b.reseed();
}
}
/// A roll must not jolt the smoothed values: the glide is what keeps the
/// transform inaudible at the seam.
#[test]
fn reseeding_does_not_jump_the_parameters() {
let mut m = mk(9);
for _ in 0..200 {
m.next_frame();
}
let before = m.next_frame();
m.reseed();
let after = m.next_frame();
assert!(
(after.formant_ratio - before.formant_ratio).abs() < 0.02,
"parameters jumped across a roll: {} -> {}",
before.formant_ratio,
after.formant_ratio
);
}
#[test]
fn a_roll_replaces_the_retained_seed() {
let mut m = mk(3);
let original = m.seed;
m.reseed();
assert_ne!(m.seed, original, "the old seed must not be kept around");
}
#[test]
fn parameters_actually_move() {
let mut m = mk(3);
let first = m.next_frame().pitch_ratio;
let mut moved = false;
for _ in 0..200 {
if (m.next_frame().pitch_ratio - first).abs() > 1e-3 {
moved = true;
break;
}
}
assert!(moved, "pitch ratio should vary over time");
}
}