Files
galdr/src/engine.rs
T
Bendik Aagaard LynghaugandClaude Opus 4.8 3b4782565b Tests: 22 covering grid/compile/eval/engine (was 0; orca-rs has 11)
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
2026-09-13 16:56:03 +02:00

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);
}
}