crates/veilvoice-core/src/effects.rs
what this file is for · veilvoice-core · 245 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
//! Light time-domain effects applied after resynthesis.
//!
//! These run on the continuous output stream (not per FFT frame) and exist to
//! (a) further decorrelate the signal from the original, and (b) add a few
//! detuned "voices" so the spectrogram is densely filled rather than showing a
//! clean harmonic stack, without harming intelligibility, so every mix defaults
//! low. None of them are invertible in a way that recovers the source voice.
//!
//! # How little these contribute, said plainly
//!
//! It would be easy to read a chorus and a reverb as part of the anonymity
//! argument. They are not, and this crate should not let anybody think they
//! are. **The voiceprint is destroyed in [`crate::spectral`]** -- by discarding
//! measured phase and by mapping every speaker onto one canonical pitch
//! register and vocal-tract scale. That has already happened before a single
//! sample reaches this file.
//!
//! What these three add is *decorrelation at the margins*: a denser
//! spectrogram, a few detuned voices where there was a clean harmonic stack,
//! and some odd harmonics smearing whatever residual cues survived. Useful,
//! cheap, and nowhere near sufficient alone. Set all three mixes to zero and
//! the output is exactly as unlinkable as before.
//!
//! The reason to be exact about this is that a filter chain of precisely this
//! shape -- clip, chorus, reverb -- is what a *voice changer* ships, and a
//! voice changer offers no anonymity whatsoever. Everything that separates this
//! project from that one happens upstream of this file.
//!
//! # Why every mix defaults low
//!
//! Intelligibility is a requirement, not a preference. Each of these effects
//! trades clarity for density, and past a fairly low mix the words start to
//! cost more than the added decorrelation is worth. The defaults sit where a
//! listener does not notice the effect is there at all; they are a starting
//! point a user may raise, not a recommendation to raise them.
//!
//! # Real-time constraints
//!
//! These run per output sample inside an audio callback, on the continuous
//! stream rather than per FFT frame. Every buffer is allocated once at
//! construction: `process` allocates nothing, takes no lock and reads no clock.
//! [`Chorus`] and [`Reverb`] own their delay lines and index them with wrapping
//! arithmetic, so changing sample rate means building a new one rather than
//! resizing a live one.
//!
//! # In plain words
//!
//! A few small finishing touches applied to the sound after the main work is done.
//!
//! They do two things. They loosen what remains of the connection between the
//! result and the original recording, and they fill in the picture a spectrogram
//! would show, so it looks like a dense, ordinary voice rather than something
//! obviously processed.
//!
//! Every one of them is set gently by default, because all of them can hurt how
//! clear the words are if pushed, and clear words are the point.
use std::f32::consts::TAU;
/// Symmetric soft-clip (tanh) waveshaper. `drive` sets curvature, `mix` blends
/// dry/wet. Adds gentle odd harmonics that smear residual identity cues.
pub struct SoftClip {
drive: f32,
mix: f32,
norm: f32,
}
impl SoftClip {
/// Build a shaper. `drive` is clamped away from zero so the normalisation
/// below cannot divide by it, and `mix` to the dry/wet range.
pub fn new(drive: f32, mix: f32) -> Self {
let drive = drive.max(0.01);
Self {
drive,
mix: mix.clamp(0.0, 1.0),
norm: drive.tanh(),
}
}
/// Shape one sample. Dividing by `tanh(drive)` keeps the loudest output
/// at the same level whatever the drive is, so turning it up changes the
/// timbre without changing the volume.
#[inline]
pub fn process(&self, x: f32) -> f32 {
let wet = (self.drive * x).tanh() / self.norm;
x * (1.0 - self.mix) + wet * self.mix
}
}
/// A single modulated delay line, summed into a small ensemble to create the
/// impression of several slightly different voices.
struct DelayVoice {
buf: Vec<f32>,
write: usize,
base_delay: f32, // samples
depth: f32, // samples of LFO sweep
lfo_phase: f32,
lfo_inc: f32,
}
impl DelayVoice {
/// One delay line, sized once here for the deepest sweep it can be asked
/// for. The buffer is rounded up to a power of two so the read and write
/// positions wrap with a mask rather than a division.
fn new(sample_rate: f32, base_ms: f32, depth_ms: f32, rate_hz: f32, phase: f32) -> Self {
let base_delay = base_ms * 0.001 * sample_rate;
let depth = depth_ms * 0.001 * sample_rate;
let max = (base_delay + depth) as usize + 4;
Self {
buf: vec![0.0; max.next_power_of_two()],
write: 0,
base_delay,
depth,
lfo_phase: phase,
lfo_inc: TAU * rate_hz / sample_rate,
}
}
/// Write one sample and read one back from where the sweep currently
/// points, interpolating between the two neighbouring samples so the
/// moving read position does not step audibly.
#[inline]
fn process(&mut self, x: f32) -> f32 {
let mask = self.buf.len() - 1;
self.buf[self.write] = x;
let d = self.base_delay + self.depth * self.lfo_phase.sin();
let read = self.write as f32 - d;
let read = read.rem_euclid(self.buf.len() as f32);
let i0 = read.floor() as usize & mask;
let i1 = (i0 + 1) & mask;
let frac = read - read.floor();
let out = self.buf[i0] * (1.0 - frac) + self.buf[i1] * frac;
self.write = (self.write + 1) & mask;
self.lfo_phase = (self.lfo_phase + self.lfo_inc) % TAU;
out
}
}
/// Detuned chorus ensemble.
pub struct Chorus {
voices: Vec<DelayVoice>,
mix: f32,
}
impl Chorus {
/// Build the ensemble. Every buffer it will ever use is allocated here,
/// because [`Chorus::process`] runs in the audio callback.
pub fn new(sample_rate: f32, mix: f32) -> Self {
// Three voices at different rates and phases: cheap but effective spread.
let voices = vec![
DelayVoice::new(sample_rate, 14.0, 3.0, 0.17, 0.0),
DelayVoice::new(sample_rate, 21.0, 4.0, 0.23, 2.1),
DelayVoice::new(sample_rate, 9.0, 2.0, 0.31, 4.0),
];
Self {
voices,
mix: mix.clamp(0.0, 1.0),
}
}
/// Sum the voices, average them, and blend that against the dry sample.
#[inline]
pub fn process(&mut self, x: f32) -> f32 {
let mut wet = 0.0;
for v in &mut self.voices {
wet += v.process(x);
}
wet /= self.voices.len() as f32;
x * (1.0 - self.mix) + wet * self.mix
}
}
/// Minimal Schroeder-style reverb: one feedback comb + one all-pass. Kept very
/// light so speech stays dry enough to transcribe.
pub struct Reverb {
comb: Vec<f32>,
comb_w: usize,
comb_fb: f32,
ap: Vec<f32>,
ap_w: usize,
ap_g: f32,
mix: f32,
}
impl Reverb {
/// Size both delay lines for this sample rate, once. The lengths are the
/// classic Schroeder figures: 29.7ms for the comb, 5ms for the all-pass.
pub fn new(sample_rate: f32, mix: f32) -> Self {
let comb_len = ((0.0297 * sample_rate) as usize).max(1);
let ap_len = ((0.0050 * sample_rate) as usize).max(1);
Self {
comb: vec![0.0; comb_len],
comb_w: 0,
comb_fb: 0.72,
ap: vec![0.0; ap_len],
ap_w: 0,
ap_g: 0.5,
mix: mix.clamp(0.0, 1.0),
}
}
/// One sample through the comb and then the all-pass, blended against the
/// dry sample. Both delay lines are already the right size, so nothing
/// here allocates.
#[inline]
pub fn process(&mut self, x: f32) -> f32 {
// comb
let c = self.comb[self.comb_w];
let cw = x + c * self.comb_fb;
self.comb[self.comb_w] = cw;
self.comb_w = (self.comb_w + 1) % self.comb.len();
// all-pass
let a = self.ap[self.ap_w];
let aw = c + (-self.ap_g) * a;
let out = a + self.ap_g * aw;
self.ap[self.ap_w] = aw;
self.ap_w = (self.ap_w + 1) % self.ap.len();
x * (1.0 - self.mix) + out * self.mix
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn softclip_is_bounded_and_finite() {
let sc = SoftClip::new(2.0, 1.0);
for i in -1000..1000 {
let y = sc.process(i as f32 / 100.0);
assert!(y.is_finite());
}
}
#[test]
fn chorus_and_reverb_finite() {
let mut ch = Chorus::new(48_000.0, 0.3);
let mut rv = Reverb::new(48_000.0, 0.15);
let mut acc = 0.0f32;
for i in 0..48_000 {
let x = (i as f32 * 0.01).sin();
let y = rv.process(ch.process(x));
assert!(y.is_finite());
acc += y.abs();
}
assert!(acc > 0.0);
}
}