grid: ruler_snap, resize clamps, bounds, fixed-stride preservation.
dsp: op mapping, input/param/CC wiring, env detection, sine bounds,
exact saw/mix samples, output summing, note gating, envelope
rise+release, cutoff curve.
engine: A4->440, oldest-voice steal, note reaping, CC registers, limiter.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GLUwWE2KmFPzhKaf67tWbx
166 lines
4.8 KiB
Rust
166 lines
4.8 KiB
Rust
//! Voice allocation and the polyphonic mixer. Owned by the audio thread.
|
|
|
|
use crate::dsp::{eval_voice, Program, Voice};
|
|
|
|
#[derive(Clone, Copy)]
|
|
pub enum Event {
|
|
NoteOn { note: u8, vel: u8 },
|
|
NoteOff { note: u8 },
|
|
Cc { idx: u8, val: u8 },
|
|
}
|
|
|
|
pub struct Engine {
|
|
voices: Vec<Voice>,
|
|
regs: Vec<f32>,
|
|
sr: f32,
|
|
clock: u64,
|
|
}
|
|
|
|
impl Engine {
|
|
pub fn new(poly: usize, sr: f32) -> Self {
|
|
Engine {
|
|
voices: (0..poly).map(|_| Voice::new()).collect(),
|
|
regs: vec![0.0; 128],
|
|
sr,
|
|
clock: 0,
|
|
}
|
|
}
|
|
|
|
pub fn active_voices(&self) -> usize {
|
|
self.voices.iter().filter(|v| v.active).count()
|
|
}
|
|
|
|
pub fn apply(&mut self, ev: Event) {
|
|
match ev {
|
|
Event::NoteOn { note, vel } => {
|
|
self.clock += 1;
|
|
// Prefer a free voice, else steal the oldest.
|
|
let idx = self
|
|
.voices
|
|
.iter()
|
|
.position(|v| !v.active)
|
|
.unwrap_or_else(|| {
|
|
let mut best = 0;
|
|
for i in 1..self.voices.len() {
|
|
if self.voices[i].age < self.voices[best].age {
|
|
best = i;
|
|
}
|
|
}
|
|
best
|
|
});
|
|
let v = &mut self.voices[idx];
|
|
v.active = true;
|
|
v.note = note;
|
|
v.freq = 440.0 * 2f32.powf((note as f32 - 69.0) / 12.0);
|
|
v.gate = 1.0;
|
|
v.vel = vel as f32 / 127.0;
|
|
v.age = self.clock;
|
|
// fresh phases/env so a stolen voice doesn't click from old state
|
|
for x in v.state.iter_mut() {
|
|
*x = 0.0;
|
|
}
|
|
v.env_level = 0.0;
|
|
}
|
|
Event::NoteOff { note } => {
|
|
for v in self.voices.iter_mut() {
|
|
if v.active && v.note == note {
|
|
v.gate = 0.0;
|
|
}
|
|
}
|
|
}
|
|
Event::Cc { idx, val } => {
|
|
self.regs[idx as usize] = val as f32 / 127.0;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[inline]
|
|
pub fn render(&mut self, prog: &Program) -> f32 {
|
|
let mut mix = 0.0f32;
|
|
for v in self.voices.iter_mut() {
|
|
if !v.active {
|
|
continue;
|
|
}
|
|
mix += eval_voice(v, prog, &self.regs, self.sr);
|
|
// Reap voices whose gate is closed and envelope has decayed.
|
|
if v.gate < 0.5 && v.env_level < 1e-4 {
|
|
v.active = false;
|
|
}
|
|
}
|
|
// Gentle headroom limiter.
|
|
(mix * 0.3).tanh()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::dsp::compile;
|
|
use crate::grid::Grid;
|
|
|
|
#[cfg(test)]
|
|
impl Engine {
|
|
fn first_freq(&self) -> Option<f32> {
|
|
self.voices.iter().find(|v| v.active).map(|v| v.freq)
|
|
}
|
|
fn reg(&self, i: usize) -> f32 {
|
|
self.regs[i]
|
|
}
|
|
}
|
|
|
|
fn grid_from(src: &str) -> Grid {
|
|
let mut g = Grid::new();
|
|
for (r, line) in src.lines().enumerate() {
|
|
for (c, ch) in line.chars().enumerate() {
|
|
g.set(c, r, ch);
|
|
}
|
|
}
|
|
g
|
|
}
|
|
|
|
#[test]
|
|
fn noteon_maps_a4_to_440() {
|
|
let mut e = Engine::new(4, 48000.0);
|
|
e.apply(Event::NoteOn { note: 69, vel: 100 });
|
|
assert!((e.first_freq().unwrap() - 440.0).abs() < 0.01);
|
|
assert_eq!(e.active_voices(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn noteoff_reaps_gated_voice() {
|
|
let p = compile(&grid_from("A\n!")); // no envelope -> gate reaps immediately
|
|
let mut e = Engine::new(4, 48000.0);
|
|
e.apply(Event::NoteOn { note: 60, vel: 100 });
|
|
e.render(&p);
|
|
assert_eq!(e.active_voices(), 1);
|
|
e.apply(Event::NoteOff { note: 60 });
|
|
e.render(&p);
|
|
assert_eq!(e.active_voices(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn monophonic_steals_the_oldest_voice() {
|
|
let mut e = Engine::new(1, 48000.0);
|
|
e.apply(Event::NoteOn { note: 60, vel: 100 });
|
|
e.apply(Event::NoteOn { note: 72, vel: 100 });
|
|
assert_eq!(e.active_voices(), 1);
|
|
assert!((e.first_freq().unwrap() - 440.0 * 2f32.powf((72.0 - 69.0) / 12.0)).abs() < 0.01);
|
|
}
|
|
|
|
#[test]
|
|
fn cc_writes_register() {
|
|
let mut e = Engine::new(2, 48000.0);
|
|
e.apply(Event::Cc { idx: 0, val: 127 });
|
|
assert!((e.reg(0) - 1.0).abs() < 1e-6);
|
|
}
|
|
|
|
#[test]
|
|
fn master_output_is_limited() {
|
|
// many stacked saws would exceed 1.0 without the limiter
|
|
let p = compile(&grid_from("A.A.A.A\n!.!.!.!"));
|
|
let mut e = Engine::new(4, 48000.0);
|
|
e.apply(Event::NoteOn { note: 60, vel: 127 });
|
|
assert!(e.render(&p).abs() <= 1.0);
|
|
}
|
|
}
|