use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
use crate::seams::ClipDuration;
use crate::signal::{ChannelLayout, Pcm};
use crate::time::SampleRate;
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Vibrato {
pub rate_hz: f32,
pub depth_hz: f32,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Partial {
pub harmonic: u32,
pub amplitude: f32,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Formant {
pub center_hz: f32,
pub bandwidth_hz: f32,
pub gain: f32,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Stratum {
Harmonics {
f0_hz: f32,
partials: Vec<Partial>,
vibrato: Option<Vibrato>,
},
Beats {
tempo_bpm: f32,
carrier_hz: f32,
decay_seconds: f32,
},
Vowel {
f0_hz: f32,
formants: Vec<Formant>,
},
Wash {
seed: u64,
amplitude: f32,
},
}
impl Stratum {
fn validate(&self) -> Result<()> {
let positive = |value: f32, what: &str| -> Result<()> {
if value.is_finite() && value > 0.0 {
Ok(())
} else {
Err(Error::validation(format!(
"probe {what} must be finite and positive, got {value}"
)))
}
};
let non_negative = |value: f32, what: &str| -> Result<()> {
if value.is_finite() && value >= 0.0 {
Ok(())
} else {
Err(Error::validation(format!(
"probe {what} must be finite and non-negative, got {value}"
)))
}
};
match self {
Stratum::Harmonics {
f0_hz,
partials,
vibrato,
} => {
positive(*f0_hz, "harmonic f0")?;
if partials.is_empty() {
return Err(Error::validation("a harmonic stack needs ≥ 1 partial"));
}
for partial in partials {
if partial.harmonic == 0 {
return Err(Error::validation("harmonic numbers start at 1"));
}
non_negative(partial.amplitude, "partial amplitude")?;
}
if let Some(v) = vibrato {
positive(v.rate_hz, "vibrato rate")?;
non_negative(v.depth_hz, "vibrato depth")?;
if v.depth_hz >= *f0_hz {
return Err(Error::validation(
"vibrato depth must stay below f0 (frequency must stay positive)",
));
}
}
Ok(())
}
Stratum::Beats {
tempo_bpm,
carrier_hz,
decay_seconds,
} => {
positive(*tempo_bpm, "tempo")?;
positive(*carrier_hz, "beat carrier")?;
positive(*decay_seconds, "beat decay")
}
Stratum::Vowel { f0_hz, formants } => {
positive(*f0_hz, "vowel f0")?;
if formants.is_empty() {
return Err(Error::validation("a vowel needs ≥ 1 formant"));
}
for formant in formants {
positive(formant.center_hz, "formant center")?;
positive(formant.bandwidth_hz, "formant bandwidth")?;
non_negative(formant.gain, "formant gain")?;
}
Ok(())
}
Stratum::Wash { amplitude, .. } => non_negative(*amplitude, "wash amplitude"),
}
}
fn max_frequency(&self) -> f32 {
match self {
Stratum::Harmonics {
f0_hz,
partials,
vibrato,
} => {
let top = partials.iter().map(|p| p.harmonic).max().unwrap_or(1) as f32;
let excursion = vibrato.map_or(0.0, |v| v.depth_hz);
top * (f0_hz + excursion)
}
Stratum::Beats { carrier_hz, .. } => *carrier_hz,
Stratum::Vowel { f0_hz, .. } => *f0_hz,
Stratum::Wash { .. } => 0.0,
}
}
fn render(&self, rate: SampleRate, len: usize) -> Vec<f32> {
let sr = f64::from(rate.get());
let mut out = vec![0.0f32; len];
match self {
Stratum::Harmonics {
f0_hz,
partials,
vibrato,
} => {
let f0 = f64::from(*f0_hz);
let mut phases = vec![0.0f64; partials.len()];
for (n, sample) in out.iter_mut().enumerate() {
let t = n as f64 / sr;
let dev = vibrato.map_or(0.0, |v| {
f64::from(v.depth_hz)
* (std::f64::consts::TAU * f64::from(v.rate_hz) * t).sin()
});
let mut acc = 0.0f64;
for (partial, phase) in partials.iter().zip(phases.iter_mut()) {
acc += f64::from(partial.amplitude) * phase.sin();
*phase +=
std::f64::consts::TAU * f64::from(partial.harmonic) * (f0 + dev) / sr;
}
*sample = acc as f32;
}
}
Stratum::Beats {
tempo_bpm,
carrier_hz,
decay_seconds,
} => {
let period = 60.0 / f64::from(*tempo_bpm);
for (n, sample) in out.iter_mut().enumerate() {
let t = n as f64 / sr;
let t_rel = t - (t / period).floor() * period;
let envelope = (-t_rel / f64::from(*decay_seconds)).exp();
*sample = (envelope
* (std::f64::consts::TAU * f64::from(*carrier_hz) * t_rel).sin())
as f32;
}
}
Stratum::Vowel { f0_hz, formants } => {
let f0 = f64::from(*f0_hz);
let nyquist = sr / 2.0;
let mut stack = Vec::new();
let mut k = 1u32;
while f64::from(k) * f0 < nyquist {
let freq = f64::from(k) * f0;
let mut amp = 0.0f64;
for formant in formants {
let half = f64::from(formant.bandwidth_hz) / 2.0;
let detune = (freq - f64::from(formant.center_hz)) / half;
amp += f64::from(formant.gain) / (1.0 + detune * detune);
}
stack.push((freq, amp / f64::from(k)));
k += 1;
}
let mut phases = vec![0.0f64; stack.len()];
for sample in out.iter_mut() {
let mut acc = 0.0f64;
for ((freq, amp), phase) in stack.iter().zip(phases.iter_mut()) {
acc += amp * phase.sin();
*phase += std::f64::consts::TAU * freq / sr;
}
*sample = acc as f32;
}
}
Stratum::Wash { seed, amplitude } => {
let mut state = *seed;
for sample in out.iter_mut() {
state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^= z >> 31;
let unit = (z >> 11) as f64 / (1u64 << 53) as f64; *sample = (f64::from(*amplitude) * (2.0 * unit - 1.0)) as f32;
}
}
}
out
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Layer {
stratum: Stratum,
gain: f32,
}
impl Layer {
pub fn new(stratum: Stratum, gain: f32) -> Result<Self> {
if !(gain.is_finite() && gain >= 0.0) {
return Err(Error::validation(format!(
"layer gain must be finite and non-negative, got {gain}"
)));
}
stratum.validate()?;
Ok(Self { stratum, gain })
}
pub fn stratum(&self) -> &Stratum {
&self.stratum
}
pub fn gain(&self) -> f32 {
self.gain
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(try_from = "ProbeRepr")]
pub struct Probe {
layers: Vec<Layer>,
}
impl Probe {
pub fn new(layers: Vec<Layer>) -> Result<Self> {
if layers.is_empty() {
return Err(Error::validation("a probe needs at least one layer"));
}
Ok(Self { layers })
}
pub fn layers(&self) -> &[Layer] {
&self.layers
}
pub fn render_layer(
&self,
index: usize,
rate: SampleRate,
duration: ClipDuration,
) -> Result<Pcm> {
let layer = self.layers.get(index).ok_or_else(|| {
Error::validation(format!(
"probe has {} layers; no layer {index}",
self.layers.len()
))
})?;
self.check_nyquist(rate)?;
let len = samples_for(rate, duration);
let mut buffer = layer.stratum.render(rate, len);
for sample in &mut buffer {
*sample *= layer.gain;
}
Pcm::new(rate, ChannelLayout::Mono, buffer)
}
pub fn render(&self, rate: SampleRate, duration: ClipDuration) -> Result<Pcm> {
self.check_nyquist(rate)?;
let len = samples_for(rate, duration);
let mut mix = vec![0.0f32; len];
for layer in &self.layers {
let buffer = layer.stratum.render(rate, len);
for (out, sample) in mix.iter_mut().zip(buffer) {
*out += layer.gain * sample;
}
}
Pcm::new(rate, ChannelLayout::Mono, mix)
}
fn check_nyquist(&self, rate: SampleRate) -> Result<()> {
let nyquist = f64::from(rate.get()) / 2.0;
for (i, layer) in self.layers.iter().enumerate() {
let top = f64::from(layer.stratum.max_frequency());
if top >= nyquist {
return Err(Error::validation(format!(
"layer {i} reaches {top} Hz, at or above Nyquist ({nyquist} Hz) for {rate}"
)));
}
}
Ok(())
}
}
fn samples_for(rate: SampleRate, duration: ClipDuration) -> usize {
(f64::from(duration.seconds()) * f64::from(rate.get())).round() as usize
}
#[derive(Deserialize)]
struct ProbeRepr {
layers: Vec<LayerRepr>,
}
#[derive(Deserialize)]
struct LayerRepr {
stratum: Stratum,
gain: f32,
}
impl TryFrom<ProbeRepr> for Probe {
type Error = Error;
fn try_from(repr: ProbeRepr) -> Result<Self> {
Probe::new(
repr.layers
.into_iter()
.map(|layer| Layer::new(layer.stratum, layer.gain))
.collect::<Result<Vec<_>>>()?,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rate() -> SampleRate {
SampleRate::try_from(16_000).unwrap()
}
fn secs(s: f32) -> ClipDuration {
ClipDuration::new(s).unwrap()
}
fn island() -> Probe {
Probe::new(vec![
Layer::new(
Stratum::Harmonics {
f0_hz: 220.0,
partials: vec![
Partial {
harmonic: 1,
amplitude: 1.0,
},
Partial {
harmonic: 3,
amplitude: 0.4,
},
],
vibrato: Some(Vibrato {
rate_hz: 5.0,
depth_hz: 6.0,
}),
},
0.5,
)
.unwrap(),
Layer::new(
Stratum::Beats {
tempo_bpm: 120.0,
carrier_hz: 1_000.0,
decay_seconds: 0.03,
},
0.3,
)
.unwrap(),
Layer::new(
Stratum::Vowel {
f0_hz: 110.0,
formants: vec![
Formant {
center_hz: 700.0,
bandwidth_hz: 110.0,
gain: 1.0,
},
Formant {
center_hz: 1_220.0,
bandwidth_hz: 120.0,
gain: 0.5,
},
],
},
0.4,
)
.unwrap(),
Layer::new(
Stratum::Wash {
seed: 7,
amplitude: 1.0,
},
0.05,
)
.unwrap(),
])
.unwrap()
}
fn energy_at(pcm: &Pcm, freq: f64) -> f64 {
let sr = f64::from(pcm.rate().get());
let (mut re, mut im) = (0.0f64, 0.0f64);
for (n, &x) in pcm.samples().iter().enumerate() {
let phase = std::f64::consts::TAU * freq * n as f64 / sr;
re += f64::from(x) * phase.cos();
im -= f64::from(x) * phase.sin();
}
(re * re + im * im).sqrt() / pcm.samples().len() as f64
}
#[test]
fn superposition_is_bit_exact() {
let probe = island();
let mix = probe.render(rate(), secs(0.25)).unwrap();
let mut fold = vec![0.0f32; mix.samples().len()];
for i in 0..probe.layers().len() {
let solo = probe.render_layer(i, rate(), secs(0.25)).unwrap();
for (acc, s) in fold.iter_mut().zip(solo.samples()) {
*acc += *s;
}
}
assert_eq!(mix.samples(), fold.as_slice());
}
#[test]
fn renders_are_deterministic_and_fingerprintable() {
let probe = island();
let a = probe.render(rate(), secs(0.1)).unwrap();
let b = probe.render(rate(), secs(0.1)).unwrap();
assert_eq!(a, b);
crate::laws::fingerprint_stable(&probe).unwrap();
let sea = |seed| {
Probe::new(vec![
Layer::new(
Stratum::Wash {
seed,
amplitude: 1.0,
},
1.0,
)
.unwrap(),
])
.unwrap()
.render(rate(), secs(0.05))
.unwrap()
};
assert_ne!(sea(1), sea(2));
}
#[test]
fn harmonics_land_on_their_partials() {
let probe = Probe::new(vec![
Layer::new(
Stratum::Harmonics {
f0_hz: 440.0,
partials: vec![
Partial {
harmonic: 1,
amplitude: 1.0,
},
Partial {
harmonic: 2,
amplitude: 0.5,
},
],
vibrato: None,
},
1.0,
)
.unwrap(),
])
.unwrap();
let pcm = probe.render(rate(), secs(0.5)).unwrap();
assert!(energy_at(&pcm, 440.0) > 10.0 * energy_at(&pcm, 617.0));
assert!(energy_at(&pcm, 880.0) > 10.0 * energy_at(&pcm, 617.0));
}
#[test]
fn vibrato_spreads_the_line() {
let stack = |vibrato| {
Probe::new(vec![
Layer::new(
Stratum::Harmonics {
f0_hz: 440.0,
partials: vec![Partial {
harmonic: 1,
amplitude: 1.0,
}],
vibrato,
},
1.0,
)
.unwrap(),
])
.unwrap()
.render(rate(), secs(0.5))
.unwrap()
};
let plain = stack(None);
let waved = stack(Some(Vibrato {
rate_hz: 6.0,
depth_hz: 15.0,
}));
assert_ne!(plain, waved);
assert!(energy_at(&waved, 440.0) < energy_at(&plain, 440.0));
}
#[test]
fn beats_decay_between_onsets() {
let probe = Probe::new(vec![
Layer::new(
Stratum::Beats {
tempo_bpm: 120.0, carrier_hz: 1_000.0,
decay_seconds: 0.02,
},
1.0,
)
.unwrap(),
])
.unwrap();
let pcm = probe.render(rate(), secs(1.0)).unwrap();
let sr = pcm.rate().get() as usize;
let rms = |from: usize, to: usize| {
let window = &pcm.samples()[from..to];
(window
.iter()
.map(|x| f64::from(*x) * f64::from(*x))
.sum::<f64>()
/ window.len() as f64)
.sqrt()
};
let attack = rms(sr / 2, sr / 2 + sr / 100);
let tail = rms(sr * 2 / 5, sr * 2 / 5 + sr / 100);
assert!(attack > 5.0 * tail, "attack {attack} vs tail {tail}");
}
#[test]
fn vowel_partials_follow_the_formants() {
let probe = Probe::new(vec![
Layer::new(
Stratum::Vowel {
f0_hz: 100.0,
formants: vec![Formant {
center_hz: 700.0,
bandwidth_hz: 100.0,
gain: 1.0,
}],
},
1.0,
)
.unwrap(),
])
.unwrap();
let pcm = probe.render(rate(), secs(0.5)).unwrap();
assert!(energy_at(&pcm, 700.0) > 10.0 * energy_at(&pcm, 3_000.0));
}
#[test]
fn construction_and_render_fail_closed() {
assert!(Probe::new(vec![]).is_err());
assert!(
Layer::new(
Stratum::Harmonics {
f0_hz: 0.0,
partials: vec![],
vibrato: None
},
1.0
)
.is_err()
);
assert!(
Layer::new(
Stratum::Wash {
seed: 0,
amplitude: -1.0
},
1.0
)
.is_err()
);
assert!(
Layer::new(
Stratum::Wash {
seed: 0,
amplitude: 0.0
},
f32::NAN
)
.is_err()
);
let hot = Probe::new(vec![
Layer::new(
Stratum::Beats {
tempo_bpm: 60.0,
carrier_hz: 9_000.0,
decay_seconds: 0.01,
},
1.0,
)
.unwrap(),
])
.unwrap();
assert!(hot.render(rate(), secs(0.1)).is_err());
}
#[test]
fn serde_round_trips_and_re_proves_invariants() {
let probe = island();
let json = serde_json::to_string(&probe).unwrap();
assert_eq!(serde_json::from_str::<Probe>(&json).unwrap(), probe);
let bad = json.replace(r#""gain":0.5"#, r#""gain":-0.5"#);
assert!(serde_json::from_str::<Probe>(&bad).is_err());
}
}