galdr 0.1.0: terminal grid synthesizer (MIDI in, PipeWire out)
Signal flows downward through single-glyph unit generators; each MIDI note voices the whole grid; the mix renders low-latency to PipeWire (reusing the native path from spectrust). Grid edits recompile and hot-swap without dropping audio. Pairs with orca (note/CC source) and spectrust (visualizer). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GLUwWE2KmFPzhKaf67tWbx
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
//! 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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user