248 lines
7.0 KiB
Rust
248 lines
7.0 KiB
Rust
//! Grid -> compiled signal program, and the per-sample voice evaluator.
|
|||
|
|
//!
|
||
|
|
//! The grid is the editor surface only. On every edit it compiles to a flat,
|
||
|
|
//! row-ordered list of nodes (topological by construction: signal flows
|
||
|
|
//! downward, so a cell's input - the cell above - is always evaluated first).
|
||
|
|
//! The audio thread never sees the grid, only the compiled `Program`.
|
||
|
|
|
||
|
|
use crate::grid::{Grid, H, SLOTS, W};
|
||
|
|
|
||
|
|
pub const MAX_STATE: usize = 4 * SLOTS;
|
||
|
|
pub const TAU: f32 = std::f32::consts::TAU;
|
||
|
|
|
||
|
|
#[derive(Clone, Copy)]
|
||
|
|
pub enum Op {
|
||
|
|
Sine,
|
||
|
|
Saw,
|
||
|
|
Pulse,
|
||
|
|
Tri,
|
||
|
|
Noise,
|
||
|
|
Lpf,
|
||
|
|
Env,
|
||
|
|
Shape,
|
||
|
|
Out,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// A parameter read from a horizontal neighbour glyph.
|
||
|
|
#[derive(Clone, Copy)]
|
||
|
|
pub enum Param {
|
||
|
|
Const(f32),
|
||
|
|
Reg(usize),
|
||
|
|
None,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Param {
|
||
|
|
#[inline]
|
||
|
|
fn get(self, regs: &[f32]) -> Option<f32> {
|
||
|
|
match self {
|
||
|
|
Param::Const(c) => Some(c),
|
||
|
|
Param::Reg(i) => Some(regs[i]),
|
||
|
|
Param::None => None,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Clone, Copy)]
|
||
|
|
pub struct Node {
|
||
|
|
pub op: Op,
|
||
|
|
pub out: usize,
|
||
|
|
pub input: Option<usize>,
|
||
|
|
pub left: Param,
|
||
|
|
pub right: Param,
|
||
|
|
pub st: usize,
|
||
|
|
}
|
||
|
|
|
||
|
|
pub struct Program {
|
||
|
|
pub nodes: Vec<Node>,
|
||
|
|
pub has_env: bool,
|
||
|
|
}
|
||
|
|
|
||
|
|
fn op_of(c: char) -> Option<(Op, usize)> {
|
||
|
|
// (operator, number of persistent state slots it needs per voice)
|
||
|
|
Some(match c {
|
||
|
|
'~' => (Op::Sine, 1),
|
||
|
|
'^' => (Op::Saw, 1),
|
||
|
|
'_' => (Op::Pulse, 1),
|
||
|
|
'v' => (Op::Tri, 1),
|
||
|
|
'*' => (Op::Noise, 0),
|
||
|
|
'f' => (Op::Lpf, 2),
|
||
|
|
'e' => (Op::Env, 2),
|
||
|
|
's' => (Op::Shape, 0),
|
||
|
|
'!' => (Op::Out, 0),
|
||
|
|
_ => return None,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
fn param_of(c: char) -> Param {
|
||
|
|
match c {
|
||
|
|
'0'..='9' => Param::Const((c as u8 - b'0') as f32),
|
||
|
|
'a'..='z' => Param::Reg((c as u8 - b'a') as usize),
|
||
|
|
_ => Param::None,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn occupied(c: char) -> bool {
|
||
|
|
!matches!(c, ' ' | '.' | '\0')
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn compile(grid: &Grid) -> Program {
|
||
|
|
let mut nodes = Vec::new();
|
||
|
|
let mut st = 0usize;
|
||
|
|
let mut has_env = false;
|
||
|
|
for row in 0..H {
|
||
|
|
for col in 0..W {
|
||
|
|
let Some((op, ssize)) = op_of(grid.get(col, row)) else { continue };
|
||
|
|
if matches!(op, Op::Env) {
|
||
|
|
has_env = true;
|
||
|
|
}
|
||
|
|
let input = if row > 0 && occupied(grid.get(col, row - 1)) {
|
||
|
|
Some((row - 1) * W + col)
|
||
|
|
} else {
|
||
|
|
None
|
||
|
|
};
|
||
|
|
let left = if col > 0 { param_of(grid.get(col - 1, row)) } else { Param::None };
|
||
|
|
let right = if col + 1 < W { param_of(grid.get(col + 1, row)) } else { Param::None };
|
||
|
|
let this_st = st;
|
||
|
|
st = (st + ssize).min(MAX_STATE - 4);
|
||
|
|
nodes.push(Node { op, out: row * W + col, input, left, right, st: this_st });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
Program { nodes, has_env }
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Per-voice mutable state consumed by the evaluator.
|
||
|
|
pub struct Voice {
|
||
|
|
pub active: bool,
|
||
|
|
pub note: u8,
|
||
|
|
pub freq: f32,
|
||
|
|
pub gate: f32,
|
||
|
|
pub vel: f32,
|
||
|
|
pub env_level: f32,
|
||
|
|
pub age: u64,
|
||
|
|
pub rng: u32,
|
||
|
|
pub state: Vec<f32>,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Voice {
|
||
|
|
pub fn new() -> Self {
|
||
|
|
Voice {
|
||
|
|
active: false,
|
||
|
|
note: 0,
|
||
|
|
freq: 0.0,
|
||
|
|
gate: 0.0,
|
||
|
|
vel: 0.0,
|
||
|
|
env_level: 0.0,
|
||
|
|
age: 0,
|
||
|
|
rng: 0x1234_5678,
|
||
|
|
state: vec![0.0; MAX_STATE],
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[inline]
|
||
|
|
fn cutoff_hz(v: f32) -> f32 {
|
||
|
|
// 0..=9 or a 0..1 CC value -> ~40 Hz .. ~16 kHz, exponential.
|
||
|
|
let n = if v > 1.0 { v / 9.0 } else { v };
|
||
|
|
40.0 * 400f32.powf(n.clamp(0.0, 1.0))
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Evaluate one output sample for one voice. Returns the summed `!` outputs.
|
||
|
|
#[inline]
|
||
|
|
pub fn eval_voice(v: &mut Voice, p: &Program, regs: &[f32], sr: f32) -> f32 {
|
||
|
|
let mut s = [0f32; SLOTS];
|
||
|
|
let mut out = 0.0f32;
|
||
|
|
let mut env_max = 0.0f32;
|
||
|
|
for n in &p.nodes {
|
||
|
|
let inp = n.input.map(|i| s[i]).unwrap_or(0.0);
|
||
|
|
let l = n.left.get(regs);
|
||
|
|
let r = n.right.get(regs);
|
||
|
|
let y = match n.op {
|
||
|
|
Op::Sine => {
|
||
|
|
let ratio = l.filter(|&x| x > 0.0).unwrap_or(1.0);
|
||
|
|
let ph = &mut v.state[n.st];
|
||
|
|
let y = (*ph * TAU).sin();
|
||
|
|
*ph += v.freq * ratio / sr;
|
||
|
|
if *ph >= 1.0 {
|
||
|
|
*ph -= 1.0;
|
||
|
|
}
|
||
|
|
y
|
||
|
|
}
|
||
|
|
Op::Saw => {
|
||
|
|
let ratio = l.filter(|&x| x > 0.0).unwrap_or(1.0);
|
||
|
|
let ph = &mut v.state[n.st];
|
||
|
|
let y = 2.0 * *ph - 1.0;
|
||
|
|
*ph += v.freq * ratio / sr;
|
||
|
|
if *ph >= 1.0 {
|
||
|
|
*ph -= 1.0;
|
||
|
|
}
|
||
|
|
y
|
||
|
|
}
|
||
|
|
Op::Pulse => {
|
||
|
|
let width = l.map(|x| (x / 9.0).clamp(0.05, 0.95)).unwrap_or(0.5);
|
||
|
|
let ph = &mut v.state[n.st];
|
||
|
|
let y = if *ph < width { 1.0 } else { -1.0 };
|
||
|
|
*ph += v.freq / sr;
|
||
|
|
if *ph >= 1.0 {
|
||
|
|
*ph -= 1.0;
|
||
|
|
}
|
||
|
|
y
|
||
|
|
}
|
||
|
|
Op::Tri => {
|
||
|
|
let ph = &mut v.state[n.st];
|
||
|
|
let y = 4.0 * (*ph - 0.5).abs() - 1.0;
|
||
|
|
*ph += v.freq / sr;
|
||
|
|
if *ph >= 1.0 {
|
||
|
|
*ph -= 1.0;
|
||
|
|
}
|
||
|
|
y
|
||
|
|
}
|
||
|
|
Op::Noise => {
|
||
|
|
// xorshift32
|
||
|
|
v.rng ^= v.rng << 13;
|
||
|
|
v.rng ^= v.rng >> 17;
|
||
|
|
v.rng ^= v.rng << 5;
|
||
|
|
(v.rng as f32 / u32::MAX as f32) * 2.0 - 1.0
|
||
|
|
}
|
||
|
|
Op::Lpf => {
|
||
|
|
let fc = cutoff_hz(l.unwrap_or(9.0));
|
||
|
|
let a = (1.0 - (-TAU * fc / sr).exp()).clamp(0.0, 1.0);
|
||
|
|
let z = &mut v.state[n.st];
|
||
|
|
*z += a * (inp - *z);
|
||
|
|
let _res = r; // reserved for resonance later
|
||
|
|
*z
|
||
|
|
}
|
||
|
|
Op::Env => {
|
||
|
|
// Attack/Release, sustain = 1. l = attack, r = release (0..9).
|
||
|
|
let atk = l.map(|x| 0.002 + x * 0.05).unwrap_or(0.01);
|
||
|
|
let rel = r.map(|x| 0.01 + x * 0.15).unwrap_or(0.15);
|
||
|
|
let level = &mut v.state[n.st];
|
||
|
|
let target = if v.gate > 0.5 { 1.0 } else { 0.0 };
|
||
|
|
let rate = if v.gate > 0.5 { atk } else { rel };
|
||
|
|
let step = (1.0 / (rate * sr)).min(1.0);
|
||
|
|
*level += (target - *level) * step;
|
||
|
|
if *level > env_max {
|
||
|
|
env_max = *level;
|
||
|
|
}
|
||
|
|
inp * *level * v.vel
|
||
|
|
}
|
||
|
|
Op::Shape => {
|
||
|
|
let drive = l.filter(|&x| x > 0.0).unwrap_or(1.0);
|
||
|
|
(inp * drive).tanh()
|
||
|
|
}
|
||
|
|
Op::Out => {
|
||
|
|
out += inp;
|
||
|
|
inp
|
||
|
|
}
|
||
|
|
};
|
||
|
|
s[n.out] = y;
|
||
|
|
}
|
||
|
|
// With no envelope, gate directly gates the voice so notes don't sustain
|
||
|
|
// forever.
|
||
|
|
if !p.has_env {
|
||
|
|
out *= v.gate * v.vel;
|
||
|
|
env_max = v.gate;
|
||
|
|
}
|
||
|
|
v.env_level = env_max;
|
||
|
|
out
|
||
|
|
}
|