ling-lang
↓ 0 downloadsLing - The Omniglot Systems Language
Keywords systems, polyglot, ai, crypto, game
lingfu install ling-langCode / Files (995)
crates/ling-daw/src/dsp/sampler.rs
//! A basic sampler: key zones of audio files with a root note, fine tuning, volume, pan, loops (forward / ping-pong), one-shot, reverse,
//! start / end trim and pitch tracking, an amplitude envelope, a low-pass filter and global tuning.
//!
//! Zones are edited through `Instrument::command` (add / remove / set / detect / autoloop / peaks) and saved as a JSON `config` inside the
//! project. Tracker modules import into one of these so their samples stay editable.
use crate::dsp::insert::{Insert, InsertParams};
use crate::dsp::{Instrument, TAU};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
pub struct Zone {
pub name: String,
pub file: String,
/// the key at which the sample plays at its recorded pitch
pub root: u8,
pub lo: u8,
pub hi: u8,
pub vlo: u8,
pub vhi: u8,
/// fine tuning in cents
pub tune: f32,
/// level in dB
pub vol: f32,
pub pan: f32,
/// 0 off, 1 forward, 2 ping-pong
pub loop_mode: u8,
pub loop_start: u32,
pub loop_end: u32,
/// play to the end whatever the note length is (drums)
pub one_shot: bool,
/// trim: first and last frame (end 0 = to the end)
pub start: u32,
pub end: u32,
pub reverse: bool,
/// how much the pitch follows the key (1 = normally, 0 = every key plays it at the same pitch)
pub track: f32,
/// this sample's own insert effects (filter, drive, crusher, punch, squash, echo, room, width); the defaults are a bypass
#[serde(default, skip_serializing_if = "fx_is_default")]
pub fx: InsertParams,
}
fn fx_is_default(p: &InsertParams) -> bool {
*p == InsertParams::default()
}
impl Zone {
pub fn new(name: &str, file: &str) -> Zone {
Zone { name: name.into(), file: file.into(), root: 60, lo: 0, hi: 127, vlo: 1, vhi: 127, tune: 0.0, vol: 0.0, pan: 0.0, loop_mode: 0, loop_start: 0, loop_end: 0, one_shot: false, start: 0, end: 0, reverse: false, track: 1.0, fx: InsertParams::default() }
}
}
/// A multi-point envelope: (seconds, value) points, a sustain point the held note stops at, and a loop it cycles in while held.
#[derive(Clone, Serialize, Deserialize, Debug, Default, PartialEq)]
pub struct Env {
pub pts: Vec<(f32, f32)>,
#[serde(default)]
pub sustain: Option<usize>,
#[serde(default)]
pub looped: Option<(usize, usize)>,
}
impl Env {
pub fn on(&self) -> bool {
self.pts.len() >= 2
}
/// the time a held note has reached: it stops at the sustain point and wraps inside the loop
pub fn eff(&self, t: f32, held: bool) -> f32 {
env_eff(&self.pts, self.sustain, self.looped, t, held)
}
pub fn at(&self, t: f32) -> f32 {
env_at(&self.pts, t)
}
pub fn end(&self) -> f32 {
self.pts.last().map(|p| p.0).unwrap_or(0.0)
}
}
fn env_eff(pts: &[(f32, f32)], sustain: Option<usize>, looped: Option<(usize, usize)>, t: f32, held: bool) -> f32 {
if held {
if let Some(p) = sustain.and_then(|si| pts.get(si)) {
if t >= p.0 {
return p.0;
}
}
if let Some((a, b)) = looped {
if let (Some(pa), Some(pb)) = (pts.get(a), pts.get(b)) {
if pb.0 > pa.0 && t >= pb.0 {
return pa.0 + (t - pa.0) % (pb.0 - pa.0);
}
}
}
}
t
}
fn env_at(e: &[(f32, f32)], t: f32) -> f32 {
if e.is_empty() {
return 1.0;
}
if t <= e[0].0 {
return e[0].1;
}
for w in e.windows(2) {
if t <= w[1].0 {
let span = (w[1].0 - w[0].0).max(1e-6);
return w[0].1 + (w[1].1 - w[0].1) * (t - w[0].0) / span;
}
}
e[e.len() - 1].1
}
#[derive(Clone, Serialize, Deserialize, Debug)]
pub struct Config {
#[serde(default)]
pub name: String,
pub zones: Vec<Zone>,
#[serde(default = "d_a")]
pub attack: f32,
#[serde(default = "d_d")]
pub decay: f32,
#[serde(default = "d_s")]
pub sustain: f32,
#[serde(default = "d_r")]
pub release: f32,
#[serde(default)]
pub tune: f32,
#[serde(default)]
pub gain: f32,
#[serde(default = "d_c")]
pub cutoff: f32,
#[serde(default)]
pub reso: f32,
/// a multi-point volume envelope (seconds, level 0..1) instead of the ADSR: it holds at `env_sustain` until the key is released,
/// loops between `env_loop` while held, and the fade-out (seconds to silence after the release) runs on top of it
#[serde(default)]
pub env: Vec<(f32, f32)>,
#[serde(default)]
pub env_sustain: Option<usize>,
#[serde(default)]
pub env_loop: Option<(usize, usize)>,
#[serde(default)]
pub fade: f32,
/// the panning envelope (value -1..1 added to the zone's pan) and the pitch envelope (semitones)
#[serde(default)]
pub pan_env: Env,
#[serde(default)]
pub pitch_env: Env,
/// the filter envelope: a factor 0..1 on the base cutoff `it_cut` (0..127, Impulse Tracker units) while held
#[serde(default)]
pub filter_env: Env,
#[serde(default)]
pub it_cut: f32,
/// random variation per note, 0..1 (OpenMPT's volume / pan / cutoff / resonance "swing")
#[serde(default)]
pub rnd_vol: f32,
#[serde(default)]
pub rnd_pan: f32,
#[serde(default)]
pub rnd_cut: f32,
#[serde(default)]
pub rnd_res: f32,
}
fn d_a() -> f32 {
0.0
}
fn d_d() -> f32 {
0.3
}
fn d_s() -> f32 {
1.0
}
fn d_r() -> f32 {
0.25
}
fn d_c() -> f32 {
1.0
}
struct Loaded {
/// interleaved stereo
data: Vec<f32>,
rate: u32,
}
impl Loaded {
fn frames(&self) -> usize {
self.data.len() / 2
}
}
#[derive(Clone, Copy, PartialEq)]
enum Stage {
Attack,
Decay,
Sustain,
Release,
}
struct Voice {
zone: usize,
key: u8,
pos: f64,
dir: f64,
step: f64,
level: f32,
stage: Stage,
gain: f32,
pan_l: f32,
pan_r: f32,
age: u64,
held: bool,
/// seconds into the envelope, and the fade-out left (1 = none yet)
env_t: f32,
pan_t: f32,
pitch_t: f32,
filt_t: f32,
pan_base: f32,
fade: f32,
/// this note's own cutoff (0..1) and resonance, drawn at its start when the random variation is on
cut_v: f32,
res_v: f32,
/// released as a cut: a very short fade, whatever the envelope and fade-out say
cut: bool,
lp1: [f32; 2],
lp2: [f32; 2],
}
pub struct Sampler {
sr: f32,
pub cfg: Config,
loaded: Vec<Option<Arc<Loaded>>>,
voices: Vec<Voice>,
clock: u64,
/// pitch wheel 0..1 (0.5 = centre) and its range (1 + 23 * v semitones)
bend: f32,
bend_range: f32,
/// last error of an add / load
pub last_error: String,
rng: crate::dsp::Rng,
/// one insert rack per zone, and the scratch buffer the zones with effects play into
zins: Vec<Insert>,
zbuf: Vec<f32>,
}
const MAX_VOICES: usize = 40;
/// constant-power pan with a little extra level (centre 0.85 each side, hard pan 1.2 on one)
fn pan_gains(p: f32) -> (f32, f32) {
let a = (p.clamp(-1.0, 1.0) + 1.0) * std::f32::consts::FRAC_PI_4;
(a.cos() * 1.2, a.sin() * 1.2)
}
fn atk_s(v: f32) -> f32 {
0.0005 + v * v * 4.0
}
fn dec_s(v: f32) -> f32 {
0.005 + v * v * 4.0
}
fn rel_s(v: f32) -> f32 {
0.004 + v * v * 6.0
}
fn cut_hz(v: f32) -> f32 {
40.0 * (20000.0f32 / 40.0).powf(v.clamp(0.0, 1.0))
}
/// ffmpeg on PATH or in a few usual places (empty when there is none)
pub fn find_ffmpeg() -> String {
let exe = if cfg!(windows) { "ffmpeg.exe" } else { "ffmpeg" };
if let Some(p) = std::env::var_os("PATH") {
for d in std::env::split_paths(&p) {
if d.join(exe).is_file() {
return d.join(exe).to_string_lossy().into_owned();
}
}
}
for p in ["C:/ffmpeg/bin/ffmpeg.exe", "C:/Program Files/ffmpeg/bin/ffmpeg.exe", "/usr/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/opt/homebrew/bin/ffmpeg"] {
if std::path::Path::new(p).is_file() {
return p.to_string();
}
}
String::new()
}
/// Decode a file the built-in decoder does not know (WavPack .wv, ...) through ffmpeg, into a cached 16-bit WAV.
fn via_ffmpeg(path: &str) -> Result<String, String> {
let ff = find_ffmpeg();
if ff.is_empty() {
return Err(format!("{path}: format not supported (install ffmpeg to read it)"));
}
let meta = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?;
let mut h: u64 = 1469598103934665603;
for b in path.bytes().chain(meta.len().to_le_bytes()) {
h = (h ^ b as u64).wrapping_mul(1099511628211);
}
let dir = std::env::temp_dir().join("lingstation-samples");
let _ = std::fs::create_dir_all(&dir);
let out = dir.join(format!("{h:016x}.wav"));
if !out.is_file() {
let r = std::process::Command::new(&ff).args(["-y", "-hide_banner", "-loglevel", "error", "-i", path, "-c:a", "pcm_s16le"]).arg(&out).output().map_err(|e| format!("ffmpeg: {e}"))?;
if !r.status.success() || !out.is_file() {
return Err(format!("{path}: ffmpeg could not read it"));
}
}
Ok(out.to_string_lossy().into_owned())
}
impl Default for Config {
fn default() -> Config {
Config { name: String::new(), zones: vec![], attack: d_a(), decay: d_d(), sustain: d_s(), release: d_r(), tune: 0.0, gain: 0.0, cutoff: 1.0, reso: 0.0, env: vec![], env_sustain: None, env_loop: None, fade: 0.0, pan_env: Env::default(), pitch_env: Env::default(), filter_env: Env::default(), it_cut: 127.0, rnd_vol: 0.0, rnd_pan: 0.0, rnd_cut: 0.0, rnd_res: 0.0 }
}
}
impl Config {
fn env_eff(&self, t: f32, held: bool) -> f32 {
env_eff(&self.env, self.env_sustain, self.env_loop, t, held)
}
fn env_at(&self, t: f32) -> f32 {
env_at(&self.env, t)
}
fn env_end(&self) -> f32 {
self.env.last().map(|p| p.0).unwrap_or(0.0)
}
}
pub fn load_audio(path: &str) -> Result<(Vec<f32>, u32), String> {
match ling_music::decode::load(path) {
Ok(d) => Ok((d.stereo, d.rate)),
Err(first) => {
// an Ogg Vorbis stream inside a RIFF / WAVE container (format tag 0x674F ...): decode the Ogg part
if let Ok(bytes) = std::fs::read(path) {
if bytes.starts_with(b"RIFF") {
if let Some(at) = bytes.windows(4).take(4096).position(|w| w == b"OggS") {
if let Ok(d) = ling_music::decode::from_bytes(&bytes[at..]) {
return Ok((d.stereo, d.rate));
}
}
}
}
let ext = std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_lowercase()).unwrap_or_default();
if ["wv", "ape", "tta", "wma", "opus", "m4a", "mka"].contains(&ext.as_str()) || first.contains("unsupported") {
let wav = via_ffmpeg(path)?;
let d = ling_music::decode::load(&wav)?;
Ok((d.stereo, d.rate))
} else {
Err(first)
}
}
}
}
impl Sampler {
pub fn new(sr: f32) -> Sampler {
Sampler { sr, cfg: Config::default(), loaded: vec![], voices: vec![], clock: 0, bend: 0.5, bend_range: 1.0 / 23.0, last_error: String::new(), rng: crate::dsp::Rng::new(0x5eed1234), zins: vec![], zbuf: vec![] }
}
/// Build from a saved config; relative file names are resolved against `base`.
pub fn from_config(sr: f32, json: &str, base: Option<&std::path::Path>) -> Result<Sampler, String> {
let mut cfg: Config = serde_json::from_str(json).map_err(|e| format!("sampler config: {e}"))?;
if let Some(b) = base {
for z in cfg.zones.iter_mut() {
if !z.file.is_empty() && !std::path::Path::new(&z.file).is_absolute() {
z.file = b.join(&z.file).to_string_lossy().replace('\\', "/");
}
}
}
let mut s = Sampler::new(sr);
s.cfg = cfg;
s.loaded = vec![None; s.cfg.zones.len()];
for i in 0..s.cfg.zones.len() {
s.load_zone(i);
}
Ok(s)
}
pub fn config_json(&self) -> String {
serde_json::to_string(&self.cfg).unwrap_or_default()
}
fn load_zone(&mut self, i: usize) {
let f = self.cfg.zones[i].file.clone();
if f.is_empty() {
return;
}
match load_audio(&f) {
Ok((data, rate)) => {
self.loaded[i] = Some(Arc::new(Loaded { data, rate }));
let fr = self.loaded[i].as_ref().map(|l| l.frames()).unwrap_or(0) as u32;
let z = &mut self.cfg.zones[i];
if z.loop_end == 0 || z.loop_end > fr {
z.loop_end = fr;
}
}
Err(e) => {
self.last_error = format!("{f}: {e}");
self.loaded[i] = None;
}
}
}
/// Add a zone from an audio file; returns its index.
pub fn add_file(&mut self, path: &str) -> Result<usize, String> {
let name = std::path::Path::new(path).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "sample".into());
let (data, rate) = load_audio(path)?;
let frames = data.len() / 2;
let mut z = Zone::new(&name, path);
z.loop_end = frames as u32;
self.cfg.zones.push(z);
self.loaded.push(Some(Arc::new(Loaded { data, rate })));
Ok(self.cfg.zones.len() - 1)
}
/// Add a zone from decoded samples (mono or interleaved stereo given as stereo) with the file they were saved to.
pub fn add_loaded(&mut self, zone: Zone, stereo: Vec<f32>, rate: u32) -> usize {
self.cfg.zones.push(zone);
self.loaded.push(Some(Arc::new(Loaded { data: stereo, rate })));
self.cfg.zones.len() - 1
}
fn zone_range(&self, i: usize) -> (usize, usize) {
let Some(Some(l)) = self.loaded.get(i) else { return (0, 0) };
let z = &self.cfg.zones[i];
let end = if z.end == 0 { l.frames() } else { (z.end as usize).min(l.frames()) };
((z.start as usize).min(end), end)
}
// ── analysis helpers ──────────────────────────────────────────────────────
/// pitch in Hz of the sample's body (YIN on a mono mix), 0 when not pitched
pub fn detect_pitch(&self, i: usize) -> f32 {
let Some(Some(l)) = self.loaded.get(i) else { return 0.0 };
let (s, e) = self.zone_range(i);
let n = e.saturating_sub(s);
if n < 2048 {
return 0.0;
}
// analyse a window after the attack
let w0 = s + (n / 5).min(n.saturating_sub(4096));
let len = 4096.min(e - w0);
let mono: Vec<f32> = (0..len).map(|k| (l.data[(w0 + k) * 2] + l.data[(w0 + k) * 2 + 1]) * 0.5).collect();
let rate = l.rate as f32;
let (tmin, tmax) = ((rate / 2000.0) as usize, (rate / 40.0) as usize);
let tmax = tmax.min(len / 2 - 1);
if tmax <= tmin + 2 {
return 0.0;
}
let mut d = vec![0f32; tmax + 1];
for t in 1..=tmax {
let mut acc = 0.0;
for k in 0..(len - tmax) {
let df = mono[k] - mono[k + t];
acc += df * df;
}
d[t] = acc;
}
let mut run = 0.0;
let mut cmnd = vec![1f32; tmax + 1];
for t in 1..=tmax {
run += d[t];
cmnd[t] = if run > 0.0 { d[t] * t as f32 / run } else { 1.0 };
}
let mut best = 0usize;
for t in tmin..tmax {
if cmnd[t] < 0.15 && cmnd[t] <= cmnd[t + 1] {
best = t;
break;
}
}
if best == 0 {
return 0.0;
}
// parabolic refinement
let (a, b, c) = (cmnd[best - 1], cmnd[best], cmnd[best + 1]);
let denom = a - 2.0 * b + c;
let off = if denom.abs() > 1e-9 { 0.5 * (a - c) / denom } else { 0.0 };
rate / (best as f32 + off)
}
/// root key (and the fine tuning in cents) of a sample, from its pitch
pub fn detect_root(&self, i: usize) -> Option<(u8, f32)> {
let hz = self.detect_pitch(i);
if hz < 30.0 {
return None;
}
let m = 69.0 + 12.0 * (hz / 440.0).log2();
let k = m.round();
Some((k.clamp(0.0, 127.0) as u8, (m - k) * 100.0))
}
/// A loop over whole periods of the sample's body, ending on zero crossings; false when the sample is not pitched.
pub fn auto_loop(&mut self, i: usize) -> bool {
let hz = self.detect_pitch(i);
let Some(Some(l)) = self.loaded.get(i).cloned() else { return false };
let (s, e) = self.zone_range(i);
let n = e.saturating_sub(s);
let period = if hz > 30.0 { l.rate as f32 / hz } else { 0.0 };
let zero = |from: usize| -> usize {
let mut k = from;
while k + 1 < e && !(l.data[k * 2] <= 0.0 && l.data[(k + 1) * 2] > 0.0) {
k += 1;
}
k
};
if period < 4.0 || n < (period * 6.0) as usize {
// not pitched: loop the last half
let z = &mut self.cfg.zones[i];
z.loop_start = (s + n / 2) as u32;
z.loop_end = e as u32;
z.loop_mode = 1;
return false;
}
let ls = zero(s + n * 2 / 5);
let periods = (((n as f32) * 0.35) / period).floor().max(2.0);
let le = zero(ls + (periods * period) as usize).min(e.saturating_sub(1));
let z = &mut self.cfg.zones[i];
z.loop_start = ls as u32;
z.loop_end = le as u32;
z.loop_mode = 1;
true
}
/// `n` (min, max) pairs of a zone's waveform, flattened
pub fn peaks(&self, i: usize, n: usize) -> Vec<f64> {
let Some(Some(l)) = self.loaded.get(i) else { return vec![] };
let fr = l.frames();
if fr == 0 || n == 0 {
return vec![];
}
let mut out = Vec::with_capacity(n * 2);
for b in 0..n {
let (a, z) = (b * fr / n, ((b + 1) * fr / n).max(b * fr / n + 1).min(fr));
let (mut lo, mut hi) = (0f32, 0f32);
for k in a..z {
let v = (l.data[k * 2] + l.data[k * 2 + 1]) * 0.5;
lo = lo.min(v);
hi = hi.max(v);
}
out.push(lo as f64);
out.push(hi as f64);
}
out
}
fn start_voice(&mut self, key: u8, vel: f32, pan: f32, hard: bool) {
let hit: Vec<usize> = (0..self.cfg.zones.len()).filter(|i| self.loaded.get(*i).map(|l| l.is_some()).unwrap_or(false)).filter(|i| {
let z = &self.cfg.zones[*i];
key >= z.lo && key <= z.hi && ((vel * 127.0).round() as u8) >= z.vlo && ((vel * 127.0).round() as u8) <= z.vhi
}).collect();
for i in hit {
let z = self.cfg.zones[i].clone();
let Some(Some(l)) = self.loaded.get(i).cloned() else { continue };
let (s, e) = self.zone_range(i);
if e <= s {
continue;
}
if self.voices.len() >= MAX_VOICES {
if let Some(k) = (0..self.voices.len()).min_by_key(|k| self.voices[*k].age) {
self.voices.remove(k);
}
}
let semis = (key as f32 - z.root as f32) * z.track + z.tune / 100.0 + self.cfg.tune;
let step = l.rate as f64 / self.sr as f64 * 2f64.powf(semis as f64 / 12.0);
// random variation: every note draws its own volume, pan and filter
let (rv, rp, rc, rr) = (self.rng.next(), self.rng.next(), self.rng.next(), self.rng.next());
let p = (z.pan + pan + rp * self.cfg.rnd_pan).clamp(-1.0, 1.0);
let cut_v = (self.cfg.cutoff + rc * self.cfg.rnd_cut * 0.5).clamp(0.0, 1.0);
let res_v = (self.cfg.reso + rr * self.cfg.rnd_res * 0.5).clamp(0.0, 1.0);
let (pl, pr) = pan_gains(p);
let gain = 10f32.powf((z.vol + self.cfg.gain) / 20.0) * vel.clamp(0.0, 1.0).powf(0.8) * (1.0 - self.cfg.rnd_vol * (rv * 0.5 + 0.5));
self.clock += 1;
self.voices.push(Voice {
zone: i,
key,
pos: if z.reverse { (e - 1) as f64 } else { s as f64 },
dir: if z.reverse { -1.0 } else { 1.0 },
step,
level: if atk_s(self.cfg.attack) < 0.002 { 1.0 } else { 0.0 },
stage: if atk_s(self.cfg.attack) < 0.002 { Stage::Decay } else { Stage::Attack },
gain,
pan_l: pl,
pan_r: pr,
age: self.clock,
held: true,
env_t: 0.0,
pan_t: 0.0,
pitch_t: 0.0,
filt_t: 0.0,
pan_base: p,
cut_v: cut_v,
res_v: res_v,
fade: 1.0,
cut: hard,
lp1: [0.0; 2],
lp2: [0.0; 2],
});
}
}
}
impl Instrument for Sampler {
fn note_on(&mut self, key: u8, vel: f32) {
self.start_voice(key, vel, 0.0, false);
}
fn note_on_ex(&mut self, key: u8, vel: f32, pan: f32, _cutoff: f32, reso: f32) {
// a note with its "reso" at 0 is a cut (tracker imports): no release tail
self.start_voice(key, vel, pan, reso < 0.01);
}
fn note_off(&mut self, key: u8) {
for v in self.voices.iter_mut().filter(|v| v.key == key && v.held) {
v.held = false;
let one = self.cfg.zones.get(v.zone).map(|z| z.one_shot).unwrap_or(false);
if !one {
v.stage = Stage::Release;
if self.cfg.env.len() >= 2 {
v.env_t = self.cfg.env_eff(v.env_t, true);
}
if self.cfg.pan_env.on() {
v.pan_t = self.cfg.pan_env.eff(v.pan_t, true);
}
if self.cfg.pitch_env.on() {
v.pitch_t = self.cfg.pitch_env.eff(v.pitch_t, true);
}
if self.cfg.filter_env.on() {
v.filt_t = self.cfg.filter_env.eff(v.filt_t, true);
}
}
}
}
fn all_off(&mut self) {
self.voices.clear();
}
fn render(&mut self, out: &mut [f32]) {
let frames = out.len() / 2;
let (a, d, s, r) = (atk_s(self.cfg.attack), dec_s(self.cfg.decay), self.cfg.sustain.clamp(0.0, 1.0), rel_s(self.cfg.release));
let (da, dd, dr) = (1.0 / (a * self.sr).max(1.0), 1.0 / (d * self.sr).max(1.0), 1.0 / (r * self.sr).max(1.0));
let filt_on = self.cfg.filter_env.on();
let swing = self.cfg.rnd_cut > 0.0 || self.cfg.rnd_res > 0.0;
let filt = self.cfg.cutoff < 0.995 || filt_on || swing;
let mut fc = cut_hz(self.cfg.cutoff);
let mut kq = 1.0 / (0.6 + self.cfg.reso * 7.0);
let use_env = self.cfg.env.len() >= 2;
let (pan_on, pitch_on, filt_on) = (self.cfg.pan_env.on(), self.cfg.pitch_env.on(), self.cfg.filter_env.on());
let dt = 1.0 / self.sr;
let fade_step = if self.cfg.fade > 0.0 { dt / self.cfg.fade } else { 0.0 };
let mut dead: Vec<usize> = vec![];
// per-sample effects: the zones that have a rack play into their own buffer, which goes through it afterwards
let sr_ = self.sr;
self.zins.truncate(self.cfg.zones.len());
while self.zins.len() < self.cfg.zones.len() {
self.zins.push(Insert::new(sr_));
}
let mut fxz: Vec<bool> = Vec::with_capacity(self.cfg.zones.len());
for (i, z) in self.cfg.zones.iter().enumerate() {
self.zins[i].set_params(z.fx);
fxz.push(self.zins[i].active());
}
let any_fx = fxz.iter().any(|b| *b);
let mut zb = std::mem::take(&mut self.zbuf);
if any_fx {
zb.clear();
zb.resize(self.cfg.zones.len() * frames * 2, 0.0);
}
let bend_mul = 2f64.powf(((self.bend as f64 - 0.5) * 2.0 * (1.0 + self.bend_range as f64 * 23.0)) / 12.0);
for (vi, v) in self.voices.iter_mut().enumerate() {
let Some(Some(l)) = self.loaded.get(v.zone) else {
dead.push(vi);
continue;
};
let z = &self.cfg.zones[v.zone];
let (st, en) = {
let end = if z.end == 0 { l.frames() } else { (z.end as usize).min(l.frames()) };
((z.start as usize).min(end), end)
};
let looped = z.loop_mode > 0 && z.loop_end > z.loop_start + 8 && (z.loop_end as usize) <= l.frames();
let (ls, le) = (z.loop_start as f64, z.loop_end as f64);
for f in 0..frames {
// the envelope
// the pan and pitch envelopes
let mut env_pitch = 1.0f64;
if swing {
fc = cut_hz(v.cut_v);
kq = 1.0 / (0.6 + v.res_v * 7.0);
}
if filt_on {
// Impulse Tracker: the envelope scales the base cutoff; its frequency is 110 * 2^(0.25 + cutoff / 24)
v.filt_t += dt;
let tt = self.cfg.filter_env.eff(v.filt_t, v.stage != Stage::Release);
let c = (self.cfg.it_cut * self.cfg.filter_env.at(tt)).clamp(0.0, 127.0);
let hz = 110.0 * 2f32.powf(0.25 + c / 24.0);
fc = hz;
}
if pan_on || pitch_on {
let held_now = v.stage != Stage::Release;
if pan_on {
v.pan_t += dt;
let tt = self.cfg.pan_env.eff(v.pan_t, held_now);
let p = (v.pan_base + self.cfg.pan_env.at(tt)).clamp(-1.0, 1.0);
let (pl, pr) = pan_gains(p);
v.pan_l = pl;
v.pan_r = pr;
}
if pitch_on {
v.pitch_t += dt;
let tt = self.cfg.pitch_env.eff(v.pitch_t, held_now);
env_pitch = 2f64.powf(self.cfg.pitch_env.at(tt) as f64 / 12.0);
}
}
if use_env {
v.env_t += dt;
let held = v.stage != Stage::Release;
let t = self.cfg.env_eff(v.env_t, held);
v.level = self.cfg.env_at(t);
if !held {
// after the key-off the envelope runs on and the fade-out takes the sound down; with no fade-out the sound ends when it does
if v.cut {
v.fade -= dt / 0.006;
} else if fade_step > 0.0 {
v.fade -= fade_step;
} else if v.env_t >= self.cfg.env_end() {
v.fade = 0.0;
}
if self.cfg.fade <= 0.0 && v.env_t < self.cfg.env_end() {
// no fade-out: FastTracker cuts the sound at the key-off unless the envelope carries on; it does here
}
v.level *= v.fade.max(0.0);
if v.level <= 0.0004 && (v.fade <= 0.0 || v.env_t >= self.cfg.env_end() || v.cut) {
dead.push(vi);
break;
}
}
} else {
match v.stage {
Stage::Attack => {
v.level += da;
if v.level >= 1.0 {
v.level = 1.0;
v.stage = Stage::Decay;
}
}
Stage::Decay => {
if v.level > s {
v.level = (v.level - dd * (1.0 - s).max(0.02)).max(s);
}
if v.level <= s + 1e-4 {
v.stage = Stage::Sustain;
}
}
Stage::Sustain => {}
Stage::Release => {
v.level -= dr * v.level.max(0.05) * 1.5 * if v.cut { 20.0 } else { 1.0 };
if v.level <= 0.0005 {
dead.push(vi);
break;
}
}
}
}
// position, loop and end
let mut p = v.pos;
if looped && (v.held || z.loop_mode > 0) {
if v.dir > 0.0 && p >= le {
if z.loop_mode == 2 {
v.dir = -1.0;
p = le - (p - le);
} else {
p = ls + (p - le);
}
} else if v.dir < 0.0 && p <= ls {
v.dir = 1.0;
p = ls + (ls - p);
}
}
v.pos = p;
if p < st as f64 || (!looped && p >= (en - 1) as f64) || p >= en as f64 {
dead.push(vi);
break;
}
let i0 = p.floor() as usize;
let fr = (p - i0 as f64) as f32;
// the frame after: at the very end of a loop it is the loop's start
let i1 = if i0 + 1 >= en { if looped { z.loop_start as usize } else { i0 } } else { i0 + 1 };
let (a0l, a0r) = (l.data[i0 * 2], l.data[i0 * 2 + 1]);
let (a1l, a1r) = (l.data[i1 * 2], l.data[i1 * 2 + 1]);
let mut sl = a0l + (a1l - a0l) * fr;
let mut sr_ = a0r + (a1r - a0r) * fr;
if filt {
// a two-pole state-variable low-pass (trapezoidal, stable at every cutoff)
let gg = (std::f32::consts::PI * fc.min(self.sr * 0.45) / self.sr).tan();
let a1 = 1.0 / (1.0 + gg * (gg + kq));
let (a2, a3) = (gg * a1, gg * gg * a1);
for (ch, x) in [(0usize, &mut sl), (1usize, &mut sr_)] {
let v3 = *x - v.lp2[ch];
let v1 = a1 * v.lp1[ch] + a2 * v3;
let v2 = v.lp2[ch] + a2 * v.lp1[ch] + a3 * v3;
v.lp1[ch] = 2.0 * v1 - v.lp1[ch];
v.lp2[ch] = 2.0 * v2 - v.lp2[ch];
*x = v2;
}
}
let amp = v.level * v.gain;
let (tgt, off): (&mut [f32], usize) = if fxz.get(v.zone).copied().unwrap_or(false) { (&mut zb[..], v.zone * frames * 2) } else { (&mut out[..], 0) };
tgt[off + f * 2] += sl * amp * v.pan_l;
tgt[off + f * 2 + 1] += sr_ * amp * v.pan_r;
v.pos += v.step * bend_mul * env_pitch * v.dir;
}
}
dead.sort_unstable();
dead.dedup();
for vi in dead.into_iter().rev() {
if vi < self.voices.len() {
self.voices.remove(vi);
}
}
if any_fx {
for (zi, on) in fxz.iter().enumerate() {
if !*on {
continue;
}
let off = zi * frames * 2;
for f in 0..frames {
let (l, r) = self.zins[zi].process(zb[off + f * 2], zb[off + f * 2 + 1]);
out[f * 2] += l;
out[f * 2 + 1] += r;
}
}
}
self.zbuf = zb;
}
fn params(&self) -> Vec<(&'static str, f32)> {
let c = &self.cfg;
vec![("attack", c.attack), ("decay", c.decay), ("sustain", c.sustain), ("release", c.release), ("tune", (c.tune / 24.0 + 0.5).clamp(0.0, 1.0)), ("gain", ((c.gain + 24.0) / 36.0).clamp(0.0, 1.0)), ("cutoff", c.cutoff), ("reso", c.reso), ("bend", self.bend), ("bend range", self.bend_range), ("rnd cut", c.rnd_cut), ("rnd res", c.rnd_res), ("rnd vol", c.rnd_vol), ("rnd pan", c.rnd_pan)]
}
fn set_param(&mut self, name: &str, v: f32) {
let v = v.clamp(0.0, 1.0);
match name {
"attack" => self.cfg.attack = v,
"decay" => self.cfg.decay = v,
"sustain" => self.cfg.sustain = v,
"release" => self.cfg.release = v,
"tune" => self.cfg.tune = (v - 0.5) * 24.0,
"gain" => self.cfg.gain = v * 36.0 - 24.0,
"cutoff" => self.cfg.cutoff = v,
"reso" => self.cfg.reso = v,
"bend" => self.bend = v,
"bend range" => self.bend_range = v,
"rnd cut" => self.cfg.rnd_cut = v,
"rnd res" => self.cfg.rnd_res = v,
"rnd vol" => self.cfg.rnd_vol = v,
"rnd pan" => self.cfg.rnd_pan = v,
_ => {}
}
}
fn name(&self) -> &'static str {
"Sampler"
}
fn command(&mut self, cmd: &str, args: &[f64], text: &str) -> Vec<f64> {
let zi = args.first().copied().unwrap_or(0.0).max(0.0) as usize;
match cmd {
"count" => vec![self.cfg.zones.len() as f64],
"add" => match self.add_file(text) {
Ok(i) => {
// a pitched sample gets its root key from its pitch
if let Some((k, cents)) = self.detect_root(i) {
let z = &mut self.cfg.zones[i];
z.root = k;
z.tune = -cents;
}
vec![i as f64]
}
Err(e) => {
self.last_error = e;
vec![-1.0]
}
},
// a library sample as a one-shot zone on one key: args [key, track pitch 0/1]
"add_lib" => match self.add_file(text) {
Ok(i) => {
let key = args.first().copied().unwrap_or(60.0).clamp(0.0, 127.0) as u8;
let z = &mut self.cfg.zones[i];
z.lo = key;
z.hi = key;
z.root = key;
z.one_shot = true;
z.track = args.get(1).copied().unwrap_or(0.0).clamp(0.0, 1.0) as f32;
vec![i as f64]
}
Err(e) => {
self.last_error = e;
vec![-1.0]
}
},
// the zone of another sampler's config that covers middle C, put on one key: args [key]
"merge" => {
let Ok(other) = serde_json::from_str::<Config>(text) else { return vec![-1.0] };
let key = args.first().copied().unwrap_or(60.0).clamp(0.0, 127.0) as u8;
let Some(src) = other.zones.iter().find(|z| z.lo <= 60 && 60 <= z.hi).or(other.zones.first()) else { return vec![-1.0] };
match self.add_file(&src.file) {
Ok(i) => {
let frames = self.cfg.zones[i].loop_end;
let z = &mut self.cfg.zones[i];
z.name = src.name.clone();
z.lo = key;
z.hi = key;
// the sample plays at the pitch it has when middle C is played: that is the pitch it gets on its new key
z.root = (key as i32 - (60 - src.root as i32)).clamp(0, 127) as u8;
z.tune = src.tune;
z.pan = src.pan;
z.one_shot = src.loop_mode == 0;
z.loop_mode = src.loop_mode;
z.loop_start = src.loop_start;
z.loop_end = if src.loop_mode > 0 { src.loop_end.min(frames) } else { frames };
vec![i as f64]
}
Err(e) => {
self.last_error = e;
vec![-1.0]
}
}
}
"remove" => {
if zi < self.cfg.zones.len() {
self.cfg.zones.remove(zi);
self.loaded.remove(zi);
self.voices.clear();
vec![1.0]
} else {
vec![0.0]
}
}
// 20 numbers per zone: root lo hi vlo vhi tune vol pan loop loop_start loop_end one_shot start end reverse track frames rate detected-root-or--1 detected-hz
// the zone's insert rack, in `insert::NAMES` order (14 numbers; empty for an unknown zone)
"zfx" => match self.cfg.zones.get(zi) {
Some(z) => z.fx.list().iter().map(|(_, v)| *v as f64).collect(),
None => vec![],
},
"zones" => {
let mut v = vec![];
for (i, z) in self.cfg.zones.iter().enumerate() {
let (fr, rate) = self.loaded.get(i).and_then(|l| l.as_ref()).map(|l| (l.frames(), l.rate)).unwrap_or((0, 0));
v.extend([z.root as f64, z.lo as f64, z.hi as f64, z.vlo as f64, z.vhi as f64, z.tune as f64, z.vol as f64, z.pan as f64, z.loop_mode as f64, z.loop_start as f64, z.loop_end as f64, z.one_shot as u8 as f64, z.start as f64, z.end as f64, z.reverse as u8 as f64, z.track as f64, fr as f64, rate as f64, 0.0, 0.0]);
}
v
}
"set" => {
let (field, val) = (args.get(1).copied().unwrap_or(0.0) as usize, args.get(2).copied().unwrap_or(0.0));
let fr = self.loaded.get(zi).and_then(|l| l.as_ref()).map(|l| l.frames() as u32).unwrap_or(0);
let Some(z) = self.cfg.zones.get_mut(zi) else { return vec![0.0] };
match field {
0 => z.root = val.clamp(0.0, 127.0) as u8,
1 => z.lo = val.clamp(0.0, 127.0) as u8,
2 => z.hi = val.clamp(0.0, 127.0) as u8,
3 => z.vlo = val.clamp(0.0, 127.0) as u8,
4 => z.vhi = val.clamp(0.0, 127.0) as u8,
5 => z.tune = val.clamp(-1200.0, 1200.0) as f32,
6 => z.vol = val.clamp(-48.0, 18.0) as f32,
7 => z.pan = val.clamp(-1.0, 1.0) as f32,
8 => z.loop_mode = val.clamp(0.0, 2.0) as u8,
9 => z.loop_start = (val.max(0.0) as u32).min(fr),
10 => z.loop_end = (val.max(0.0) as u32).min(fr),
11 => z.one_shot = val > 0.5,
12 => z.start = (val.max(0.0) as u32).min(fr),
13 => z.end = (val.max(0.0) as u32).min(fr),
14 => z.reverse = val > 0.5,
15 => z.track = val.clamp(0.0, 1.0) as f32,
// 100.. : the insert rack knobs
100..=113 => {
z.fx.set_index(field - 100, val as f32);
},
_ => {}
}
if z.lo > z.hi {
std::mem::swap(&mut z.lo, &mut z.hi);
}
vec![1.0]
}
"detect" => match self.detect_root(zi) {
Some((k, cents)) => {
let z = &mut self.cfg.zones[zi];
z.root = k;
z.tune = -cents;
vec![k as f64, cents as f64]
}
None => vec![-1.0],
},
"autoloop" => {
if zi < self.cfg.zones.len() {
let ok = self.auto_loop(zi);
vec![ok as u8 as f64]
} else {
vec![0.0]
}
}
"spread" => {
// spread the zones over the keyboard: the boundary between two zones is halfway between their roots
let mut idx: Vec<usize> = (0..self.cfg.zones.len()).collect();
idx.sort_by_key(|i| self.cfg.zones[*i].root);
let roots: Vec<u8> = idx.iter().map(|i| self.cfg.zones[*i].root).collect();
for (n, &i) in idx.iter().enumerate() {
let lo = if n == 0 { 0 } else { ((roots[n - 1] as u16 + roots[n] as u16) / 2 + 1).min(roots[n] as u16) as u8 };
let hi = if n + 1 == idx.len() { 127 } else { ((roots[n] as u16 + roots[n + 1] as u16) / 2).max(roots[n] as u16) as u8 };
let z = &mut self.cfg.zones[i];
z.lo = lo;
z.hi = hi;
}
vec![1.0]
}
// the envelopes: 0 volume, 1 pan, 2 pitch -> [sustain or -1, loop start or -1, loop end or -1, fade-out s, count, (time, value)...]
"env" => {
let which = args.first().copied().unwrap_or(0.0) as usize;
let (pts, sus, lp): (Vec<(f32, f32)>, Option<usize>, Option<(usize, usize)>) = match which {
0 => (self.cfg.env.clone(), self.cfg.env_sustain, self.cfg.env_loop),
1 => (self.cfg.pan_env.pts.clone(), self.cfg.pan_env.sustain, self.cfg.pan_env.looped),
2 => (self.cfg.pitch_env.pts.clone(), self.cfg.pitch_env.sustain, self.cfg.pitch_env.looped),
_ => (self.cfg.filter_env.pts.clone(), self.cfg.filter_env.sustain, self.cfg.filter_env.looped),
};
let mut v = vec![sus.map(|x| x as f64).unwrap_or(-1.0), lp.map(|x| x.0 as f64).unwrap_or(-1.0), lp.map(|x| x.1 as f64).unwrap_or(-1.0), self.cfg.fade as f64, pts.len() as f64];
for p in pts {
v.extend([p.0 as f64, p.1 as f64]);
}
v
}
"drop_env" => {
self.cfg.env.clear();
self.cfg.env_sustain = None;
self.cfg.env_loop = None;
self.cfg.fade = 0.0;
vec![1.0]
}
"peaks" => self.peaks(zi, args.get(1).copied().unwrap_or(256.0).clamp(8.0, 2048.0) as usize),
_ => vec![],
}
}
fn info(&self, what: &str) -> String {
match what {
"names" => self.cfg.zones.iter().map(|z| z.name.clone()).collect::<Vec<_>>().join("\n"),
"files" => self.cfg.zones.iter().map(|z| z.file.clone()).collect::<Vec<_>>().join("\n"),
"config" => self.config_json(),
"error" => self.last_error.clone(),
_ => String::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sine(hz: f32, secs: f32, rate: u32) -> Vec<f32> {
(0..(secs * rate as f32) as usize).flat_map(|i| { let v = (i as f32 / rate as f32 * hz * TAU).sin() * 0.7; [v, v] }).collect()
}
fn with_sine(hz: f32) -> Sampler {
let mut s = Sampler::new(48000.0);
let mut z = Zone::new("sine", "");
z.loop_end = 48000;
s.add_loaded(z, sine(hz, 1.0, 48000), 48000);
s
}
fn zero_crossings(buf: &[f32]) -> f32 {
let l: Vec<f32> = buf.iter().step_by(2).copied().collect();
l.windows(2).filter(|w| w[0] <= 0.0 && w[1] > 0.0).count() as f32 / (l.len() as f32 / 48000.0)
}
fn rms_of(buf: &[f32]) -> f32 {
(buf.iter().step_by(2).map(|x| x * x).sum::<f32>() / (buf.len() / 2).max(1) as f32).sqrt()
}
#[test]
fn every_sample_has_its_own_effects() {
// two zones, one sample each: a bright 9 kHz tone on C3 and the same tone on C5; only the first gets a low-pass
let mut s = Sampler::new(48000.0);
for (name, lo, hi) in [("a", 0u8, 59u8), ("b", 60, 127)] {
let mut z = Zone::new(name, "");
z.lo = lo;
z.hi = hi;
z.root = if lo == 0 { 48 } else { 72 };
z.loop_mode = 1;
z.loop_start = 4800;
z.loop_end = 24000;
s.add_loaded(z, sine(9000.0, 1.0, 48000), 48000);
}
let play = |s: &mut Sampler, key: u8| {
s.all_off();
s.note_on(key, 1.0);
let mut buf = vec![0f32; 2 * 9600];
s.render(&mut buf);
rms_of(&buf[2 * 4800..])
};
let (dry_a, dry_b) = (play(&mut s, 48), play(&mut s, 72));
assert!(dry_a > 0.2 && dry_b > 0.2, "both sound: {dry_a} {dry_b}");
// the zone's rack: filter mode low-pass, cutoff low
assert_eq!(s.command("set", &[0.0, 100.0, 0.4], ""), vec![1.0]);
assert_eq!(s.command("set", &[0.0, 101.0, 0.3], ""), vec![1.0]);
let fx = s.command("zfx", &[0.0], "");
assert_eq!(fx.len(), 14);
assert!((fx[0] - 0.4).abs() < 1e-6 && (fx[1] - 0.3).abs() < 1e-6, "{fx:?}");
assert_eq!(s.command("zfx", &[1.0], "")[0], 0.0, "the other zone keeps its defaults");
let (wet_a, wet_b) = (play(&mut s, 48), play(&mut s, 72));
assert!(wet_a < dry_a * 0.3, "the filtered sample loses its highs: {wet_a} vs {dry_a}");
assert!((wet_b - dry_b).abs() < dry_b * 0.02, "the other sample is untouched: {wet_b} vs {dry_b}");
// an echo keeps sounding after the note is gone
s.command("set", &[1.0, 108.0, 0.6], "");
s.command("set", &[1.0, 109.0, 0.2], "");
s.all_off();
s.note_on(72, 1.0);
let mut buf = vec![0f32; 2 * 2400];
s.render(&mut buf);
s.all_off();
let mut tail = vec![0f32; 2 * 14400];
s.render(&mut tail);
assert!(rms_of(&tail) > 0.01, "the echo rings on after the voice ended: {}", rms_of(&tail));
// saved with the project, and a project without the field still loads
let json = serde_json::to_string(&s.cfg).unwrap();
assert!(json.contains("\"fx\""));
let back: Config = serde_json::from_str(&json).unwrap();
assert_eq!(back.zones[0].fx, s.cfg.zones[0].fx);
let plain = serde_json::to_string(&Zone::new("x", "")).unwrap();
assert!(!plain.contains("\"fx\""), "a bypassed rack is not written");
assert!(serde_json::from_str::<Zone>(&plain).unwrap().fx.is_off());
}
#[test]
fn detects_root_and_cents() {
let mut s = with_sine(440.0 * 2f32.powf(0.3 / 12.0)); // A4, 30 cents sharp
let r = s.command("detect", &[0.0], "");
assert_eq!(r[0], 69.0, "{r:?}");
assert!((r[1] - 30.0).abs() < 6.0, "{r:?}");
}
#[test]
fn plays_at_the_pitch_of_the_key_and_loops_until_release() {
let mut s = with_sine(220.0);
s.command("set", &[0.0, 0.0, 57.0], ""); // root A3
s.command("set", &[0.0, 8.0, 1.0], ""); // loop
s.command("set", &[0.0, 9.0, 4800.0], "");
s.command("set", &[0.0, 10.0, 24000.0], "");
s.note_on(69, 1.0); // an octave up: 440 Hz
let mut buf = vec![0f32; 2 * 48000 * 2];
s.render(&mut buf);
let hz = zero_crossings(&buf[..2 * 24000]);
assert!((hz - 440.0).abs() < 12.0, "pitch {hz}");
// 2 s of sound from a 1 s sample: it looped
let tail: f32 = buf[2 * 80000..2 * 90000].iter().map(|x| x.abs()).sum();
assert!(tail > 100.0, "looping keeps sounding: {tail}");
s.note_off(69);
let mut rel = vec![0f32; 2 * 48000 * 2];
s.render(&mut rel);
let late: f32 = rel[2 * 90000..].iter().map(|x| x.abs()).sum();
assert!(late < 1.0, "released: {late}");
}
#[test]
fn one_shot_ignores_note_off_and_key_zones_select() {
let mut s = with_sine(220.0);
s.command("set", &[0.0, 11.0, 1.0], "");
s.command("set", &[0.0, 1.0, 60.0], "");
s.command("set", &[0.0, 2.0, 72.0], "");
s.note_on(30, 1.0);
let mut b = vec![0f32; 2048];
s.render(&mut b);
assert!(b.iter().all(|x| *x == 0.0), "outside the key range: silent");
s.note_on(60, 1.0);
s.note_off(60);
let mut b = vec![0f32; 2 * 24000];
s.render(&mut b);
assert!(b[2 * 20000..].iter().map(|x| x.abs()).sum::<f32>() > 10.0, "one-shot keeps playing after the note off");
}
#[test]
fn config_round_trips() {
let mut s = with_sine(330.0);
s.command("set", &[0.0, 5.0, 25.0], "");
let j = s.config_json();
let t = Sampler::from_config(48000.0, &j, None).unwrap();
assert_eq!(t.cfg.zones[0].tune, 25.0);
}
fn saw(secs: f32) -> Vec<f32> {
(0..(secs * 48000.0) as usize).flat_map(|i| { let v = ((i as f32 * 220.0 / 48000.0) % 1.0) * 2.0 - 1.0; [v * 0.6, v * 0.6] }).collect()
}
fn hf_energy(buf: &[f32]) -> f32 {
// energy of the first difference: a crude high-frequency measure
let l: Vec<f32> = buf.iter().step_by(2).copied().collect();
l.windows(2).map(|w| (w[1] - w[0]).powi(2)).sum::<f32>()
}
#[test]
fn a_filter_envelope_closes_the_filter_over_time() {
let mut s = Sampler::new(48000.0);
let mut z = Zone::new("saw", "");
z.loop_mode = 1;
z.loop_start = 4800;
z.loop_end = 40000;
s.add_loaded(z, saw(1.0), 48000);
s.cfg.it_cut = 100.0;
s.cfg.cutoff = 1.0;
s.cfg.filter_env = Env { pts: vec![(0.0, 1.0), (0.3, 0.2)], sustain: Some(1), looped: None };
s.note_on(60, 1.0);
let mut b = vec![0f32; 2 * 24000];
s.render(&mut b);
let early = hf_energy(&b[2 * 200..2 * 4000]);
let late = hf_energy(&b[2 * 18000..2 * 22000]);
assert!(late < early * 0.5, "the filter closes: {early} -> {late}");
}
#[test]
fn random_cutoff_gives_every_note_its_own_brightness() {
let mut s = Sampler::new(48000.0);
let mut z = Zone::new("saw", "");
z.loop_mode = 1;
z.loop_start = 4800;
z.loop_end = 40000;
s.add_loaded(z, saw(1.0), 48000);
s.cfg.cutoff = 0.5;
s.cfg.rnd_cut = 1.0;
let mut vals = vec![];
for _ in 0..6 {
s.all_off();
s.note_on(60, 1.0);
let mut b = vec![0f32; 2 * 4800];
s.render(&mut b);
vals.push(hf_energy(&b[2 * 1000..]));
}
let (lo, hi) = (vals.iter().cloned().fold(f32::MAX, f32::min), vals.iter().cloned().fold(0.0, f32::max));
assert!(hi > lo * 1.2, "notes differ: {vals:?}");
// and with the variation off they are all the same
s.cfg.rnd_cut = 0.0;
s.all_off();
s.note_on(60, 1.0);
let mut a = vec![0f32; 2 * 4800];
s.render(&mut a);
s.all_off();
s.note_on(60, 1.0);
let mut c = vec![0f32; 2 * 4800];
s.render(&mut c);
assert!((hf_energy(&a[2000..]) - hf_energy(&c[2000..])).abs() < 1e-3);
}
#[test]
fn pan_and_pitch_envelopes_move_the_sound() {
let mut s = with_sine(220.0);
s.command("set", &[0.0, 0.0, 57.0], "");
s.command("set", &[0.0, 8.0, 1.0], "");
s.command("set", &[0.0, 9.0, 4800.0], "");
s.command("set", &[0.0, 10.0, 24000.0], "");
s.cfg.pan_env = Env { pts: vec![(0.0, -1.0), (0.2, 1.0)], sustain: Some(1), looped: None };
s.note_on(57, 1.0);
let mut b = vec![0f32; 2 * 24000];
s.render(&mut b);
let side = |a: usize, z: usize| -> (f32, f32) { (b[2 * a..2 * z].iter().step_by(2).map(|x| x.abs()).sum(), b[2 * a + 1..2 * z].iter().step_by(2).map(|x| x.abs()).sum()) };
let (l0, r0) = side(0, 2000);
let (l1, r1) = side(16000, 20000);
assert!(l0 > r0 * 1.5, "starts left: {l0} {r0}");
assert!(r1 > l1 * 1.5, "ends right: {l1} {r1}");
}
}