217 lines
7.9 KiB
Rust
217 lines
7.9 KiB
Rust
//! The sung pitch: a note target reached through a damped spring (legato
|
|
//! glides with a slight overshoot, a scoop into a fresh note), vibrato that
|
|
//! waits and then fades in (and dies away into the next note rather than
|
|
//! stopping mid-swing), and slow drift plus fast jitter. All of it is a
|
|
//! deterministic function of the notes and a seed, advanced once a block.
|
|
//!
|
|
//! In tune: vibrato swings evenly about the note, drift and jitter are
|
|
//! zero-mean and small enough that a held note's centre stays within a few
|
|
//! cents, and a glide is timed from the syllable's start to land on its
|
|
//! vowel (a vowel-initial join, which has no consonant to glide through,
|
|
//! lands within about 60 ms).
|
|
|
|
/// A deterministic generator (xorshift).
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub struct Rng(pub u32);
|
|
|
|
impl Rng {
|
|
pub fn new(seed: u32) -> Rng {
|
|
Rng(seed.wrapping_mul(0x9E37_79B9).wrapping_add(0x7F4A_7C15) | 1)
|
|
}
|
|
|
|
pub fn next_u32(&mut self) -> u32 {
|
|
self.0 ^= self.0 << 13;
|
|
self.0 ^= self.0 >> 17;
|
|
self.0 ^= self.0 << 5;
|
|
self.0
|
|
}
|
|
|
|
/// Uniform in -1..1.
|
|
pub fn bipolar(&mut self) -> f32 {
|
|
self.next_u32() as f32 * (2.0 / 4_294_967_296.0) - 1.0
|
|
}
|
|
|
|
/// Uniform in 0..1.
|
|
pub fn unit(&mut self) -> f32 {
|
|
self.next_u32() as f32 * (1.0 / 4_294_967_296.0)
|
|
}
|
|
}
|
|
|
|
/// Low-passed noise of a given standard deviation.
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub struct Wander {
|
|
y: f32,
|
|
a: f32,
|
|
scale: f32,
|
|
}
|
|
|
|
impl Wander {
|
|
/// `cutoff` Hz at `ctrl_rate` updates a second.
|
|
pub fn new(cutoff: f32, std: f32, ctrl_rate: f32) -> Wander {
|
|
let a = (-std::f32::consts::TAU * cutoff / ctrl_rate).exp();
|
|
// One pole over uniform noise (variance 1/3): output variance is
|
|
// (1/3)(1-a)/(1+a) per unit input.
|
|
let scale = std / ((1.0 - a) / (1.0 + a) / 3.0).sqrt();
|
|
Wander { y: 0.0, a, scale }
|
|
}
|
|
|
|
pub fn next(&mut self, rng: &mut Rng) -> f32 {
|
|
self.y = self.a * self.y + (1.0 - self.a) * rng.bipolar() * self.scale;
|
|
self.y
|
|
}
|
|
}
|
|
|
|
/// How a voice sings: the expressive parameters of the pitch and timbre.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub struct Style {
|
|
/// Vibrato rate (Hz) and peak depth (semitones).
|
|
pub vibrato_rate: f32,
|
|
pub vibrato_depth: f32,
|
|
/// Seconds after the vowel starts before vibrato begins, and its fade in.
|
|
pub vibrato_delay: f32,
|
|
pub vibrato_fade: f32,
|
|
/// Slow pitch drift and fast jitter, standard deviation in cents. The
|
|
/// drift wanders on a quarter-second scale, so a note's centre moves by
|
|
/// about 0.6 of it: a few cents is a trained singer, 6 an unsteady one.
|
|
pub drift_cents: f32,
|
|
pub jitter_cents: f32,
|
|
/// Legato glide time (seconds; wider intervals take a little longer).
|
|
/// A glide lands by the vowel plus half this at the latest.
|
|
pub portamento: f32,
|
|
/// How far below a fresh note the voice starts (semitones).
|
|
pub scoop: f32,
|
|
/// Breathiness 0..1 (added to the profile's).
|
|
pub breath: f32,
|
|
/// Formant scale (1 = the profile's own vocal tract).
|
|
pub formant_scale: f32,
|
|
/// A fixed pitch offset (cents): a choir voice's detune.
|
|
pub detune_cents: f32,
|
|
/// Coarticulation: the width of a transition between phonemes (s).
|
|
pub transition: f32,
|
|
/// Output gain.
|
|
pub gain: f32,
|
|
}
|
|
|
|
impl Default for Style {
|
|
fn default() -> Self {
|
|
Style {
|
|
vibrato_rate: 5.4, vibrato_depth: 0.3, vibrato_delay: 0.22, vibrato_fade: 0.35, drift_cents: 2.5, jitter_cents: 2.5,
|
|
portamento: 0.08, scoop: 0.3, breath: 0.0, formant_scale: 1.0, detune_cents: 0.0, transition: 0.05, gain: 1.0,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Style {
|
|
/// A dead-straight voice: no vibrato, drift, jitter or scoop (tests).
|
|
pub fn straight() -> Style {
|
|
Style { vibrato_depth: 0.0, drift_cents: 0.0, jitter_cents: 0.0, scoop: 0.0, ..Style::default() }
|
|
}
|
|
}
|
|
|
|
pub struct Pitch {
|
|
/// Spring state (MIDI note) and velocity.
|
|
x: f32,
|
|
v: f32,
|
|
target: f32,
|
|
omega: f32,
|
|
vib_phase: f32,
|
|
/// The vibrato's current depth (semitones): it fades in on each note's
|
|
/// envelope and, when a new note's envelope drops, ramps down within
|
|
/// [`VIBRATO_RELEASE`] instead of stopping dead mid-swing.
|
|
vib_depth: f32,
|
|
drift: Wander,
|
|
jitter: Wander,
|
|
rate_wobble: Wander,
|
|
dt: f32,
|
|
pub rng: Rng,
|
|
}
|
|
|
|
/// Overshoot 0.6% of the interval (7 cents on an octave).
|
|
const DAMPING: f32 = 0.85;
|
|
|
|
/// How long the vibrato takes to die away at a new note (s, from full
|
|
/// depth). Cut at once, a join stepped the pitch by the swing it was on: up
|
|
/// to the full depth (30 cents) within one block. Ramped, the pitch moves no
|
|
/// faster than the vibrato's own swing, and a quick passing note is straight
|
|
/// again well before its middle.
|
|
const VIBRATO_RELEASE: f32 = 0.03;
|
|
|
|
impl Pitch {
|
|
pub fn new(ctrl_rate: f32, style: &Style, seed: u32) -> Pitch {
|
|
Pitch {
|
|
x: 60.0, v: 0.0, target: 60.0, omega: 40.0, vib_phase: 0.0, vib_depth: 0.0,
|
|
drift: Wander::new(0.6, style.drift_cents / 100.0, ctrl_rate),
|
|
jitter: Wander::new(25.0, style.jitter_cents / 100.0, ctrl_rate),
|
|
rate_wobble: Wander::new(0.4, 0.06, ctrl_rate),
|
|
dt: 1.0 / ctrl_rate, rng: Rng::new(seed),
|
|
}
|
|
}
|
|
|
|
/// A new note: `legato` glides from where the voice is, else it starts
|
|
/// `scoop` below. `lead` is the time from now to its vowel (the onset
|
|
/// consonants): the glide or scoop happens through them and lands by
|
|
/// the vowel plus half the portamento, so the vowel is sung on pitch.
|
|
pub fn note(&mut self, note: f32, legato: bool, lead: f32, style: &Style) {
|
|
let from = if legato { self.x } else { note - style.scoop };
|
|
let jump = (note - from).abs();
|
|
let time = (style.portamento + 0.004 * jump).min(lead.max(0.0) + 0.5 * style.portamento).max(0.015);
|
|
self.omega = 4.0 / time;
|
|
if !legato {
|
|
self.x = note - style.scoop;
|
|
self.v = 0.0;
|
|
}
|
|
self.target = note;
|
|
}
|
|
|
|
/// Advance one block; `vowel_time` is seconds since this note's vowel
|
|
/// began (vibrato waits for it). Returns the sung MIDI pitch.
|
|
pub fn step(&mut self, vowel_time: f32, style: &Style) -> f32 {
|
|
let dt = self.dt;
|
|
// Semi-implicit Euler: stable for omega * dt well below one.
|
|
self.v += (self.omega * self.omega * (self.target - self.x) - 2.0 * DAMPING * self.omega * self.v) * dt;
|
|
self.x += self.v * dt;
|
|
let rate = style.vibrato_rate * (1.0 + self.rate_wobble.next(&mut self.rng));
|
|
self.vib_phase = (self.vib_phase + std::f32::consts::TAU * rate * dt) % std::f32::consts::TAU;
|
|
let t = ((vowel_time - style.vibrato_delay) / style.vibrato_fade.max(1e-3)).clamp(0.0, 1.0);
|
|
let depth = style.vibrato_depth * t * t * (3.0 - 2.0 * t);
|
|
self.vib_depth = depth.max(self.vib_depth - style.vibrato_depth * dt / VIBRATO_RELEASE);
|
|
let drift = self.drift.next(&mut self.rng);
|
|
let jitter = self.jitter.next(&mut self.rng);
|
|
self.x + self.vib_depth * self.vib_phase.sin() + drift + jitter + style.detune_cents / 100.0
|
|
}
|
|
|
|
pub fn current(&self) -> f32 {
|
|
self.x
|
|
}
|
|
}
|
|
|
|
pub fn midi_to_hz(note: f32) -> f32 {
|
|
440.0 * ((note - 69.0) / 12.0).exp2()
|
|
}
|
|
|
|
pub fn hz_to_midi(hz: f32) -> f32 {
|
|
69.0 + 12.0 * (hz / 440.0).log2()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn spring_lands_on_the_note_with_a_small_overshoot() {
|
|
let style = Style::straight();
|
|
let mut p = Pitch::new(750.0, &style, 1);
|
|
p.note(60.0, false, 0.0, &style);
|
|
for _ in 0..300 {
|
|
p.step(0.0, &style);
|
|
}
|
|
assert!((p.current() - 60.0).abs() < 0.01);
|
|
p.note(65.0, true, 0.1, &style);
|
|
let mut peak = 0.0f32;
|
|
for _ in 0..600 {
|
|
peak = peak.max(p.step(0.0, &style));
|
|
}
|
|
assert!(peak > 65.0 && peak < 65.1, "overshoot to {peak}");
|
|
assert!((p.current() - 65.0).abs() < 0.01);
|
|
}
|
|
}
|