Uppercase operator glyphs, more effects, drop embedded spectrum
- Operators are now UPPERCASE (orca-style): O A U V N sources, L H B SVF filters (with resonance), E G R envelope/gain/ringmod, S C F D shapers (soft/clip/fold/decimate), M mix, ! out. Digits = constants, lowercase = CC. - TUI rewritten on crossterm reusing orca-rs's grid-view approach (centered canvas, per-glyph colour); the spectrum is gone - run spectrust on the side. - Drops the ratatui and rustfft deps. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GLUwWE2KmFPzhKaf67tWbx
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
d2778e169a
commit
e68d50a78f
+119
-65
@@ -4,11 +4,16 @@
|
||||
//! 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`.
|
||||
//!
|
||||
//! Glyph convention: UPPERCASE letters are operators, digits 0-9 are constant
|
||||
//! params, lowercase a-z read MIDI CC (a = CC 0). A neighbour to the left/right
|
||||
//! supplies a parameter; `M` instead reads the left cell as a second signal.
|
||||
|
||||
use crate::grid::{Grid, H, SLOTS, W};
|
||||
|
||||
pub const MAX_STATE: usize = 4 * SLOTS;
|
||||
pub const TAU: f32 = std::f32::consts::TAU;
|
||||
const PI: f32 = std::f32::consts::PI;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum Op {
|
||||
@@ -17,13 +22,20 @@ pub enum Op {
|
||||
Pulse,
|
||||
Tri,
|
||||
Noise,
|
||||
Lpf,
|
||||
Low,
|
||||
High,
|
||||
Band,
|
||||
Env,
|
||||
Shape,
|
||||
Soft,
|
||||
Clip,
|
||||
Fold,
|
||||
Decimate,
|
||||
Gain,
|
||||
Ring,
|
||||
Mix,
|
||||
Out,
|
||||
}
|
||||
|
||||
/// A parameter read from a horizontal neighbour glyph.
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum Param {
|
||||
Const(f32),
|
||||
@@ -47,6 +59,7 @@ pub struct Node {
|
||||
pub op: Op,
|
||||
pub out: usize,
|
||||
pub input: Option<usize>,
|
||||
pub lsig: Option<usize>,
|
||||
pub left: Param,
|
||||
pub right: Param,
|
||||
pub st: usize,
|
||||
@@ -57,17 +70,25 @@ pub struct Program {
|
||||
pub has_env: bool,
|
||||
}
|
||||
|
||||
/// (operator, persistent state slots per voice)
|
||||
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),
|
||||
'O' => (Op::Sine, 1),
|
||||
'A' => (Op::Saw, 1),
|
||||
'U' => (Op::Pulse, 1),
|
||||
'V' => (Op::Tri, 1),
|
||||
'N' => (Op::Noise, 0),
|
||||
'L' => (Op::Low, 2),
|
||||
'H' => (Op::High, 2),
|
||||
'B' => (Op::Band, 2),
|
||||
'E' => (Op::Env, 1),
|
||||
'S' => (Op::Soft, 0),
|
||||
'C' => (Op::Clip, 0),
|
||||
'F' => (Op::Fold, 0),
|
||||
'D' => (Op::Decimate, 2),
|
||||
'G' => (Op::Gain, 0),
|
||||
'R' => (Op::Ring, 1),
|
||||
'M' => (Op::Mix, 0),
|
||||
'!' => (Op::Out, 0),
|
||||
_ => return None,
|
||||
})
|
||||
@@ -95,22 +116,19 @@ pub fn compile(grid: &Grid) -> Program {
|
||||
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 input =
|
||||
if row > 0 && occupied(grid.get(col, row - 1)) { Some((row - 1) * W + col) } else { None };
|
||||
let lsig = if col > 0 { Some(row * W + col - 1) } 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 });
|
||||
nodes.push(Node { op, out: row * W + col, input, lsig, 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,
|
||||
@@ -141,12 +159,41 @@ impl Voice {
|
||||
|
||||
#[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]
|
||||
fn phasor(ph: &mut f32, inc: f32) -> f32 {
|
||||
let p = *ph;
|
||||
*ph += inc;
|
||||
if *ph >= 1.0 {
|
||||
*ph -= 1.0;
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// TPT state-variable filter step. Returns (low, band, high).
|
||||
#[inline]
|
||||
fn svf(inp: f32, fc: f32, k: f32, sr: f32, ic1: &mut f32, ic2: &mut f32) -> (f32, f32, f32) {
|
||||
let g = (PI * fc / sr).tan();
|
||||
let a1 = 1.0 / (1.0 + g * (g + k));
|
||||
let a2 = g * a1;
|
||||
let a3 = g * a2;
|
||||
let v3 = inp - *ic2;
|
||||
let v1 = a1 * *ic1 + a2 * v3;
|
||||
let v2 = *ic2 + a2 * *ic1 + a3 * v3;
|
||||
*ic1 = 2.0 * v1 - *ic1;
|
||||
*ic2 = 2.0 * v2 - *ic2;
|
||||
(v2, v1, inp - k * v1 - v2)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn res_k(right: Option<f32>) -> f32 {
|
||||
// higher right glyph -> more resonance (lower k = higher Q)
|
||||
(1.5 - right.unwrap_or(3.0) * 0.15).clamp(0.15, 1.5)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn eval_voice(v: &mut Voice, p: &Program, regs: &[f32], sr: f32) -> f32 {
|
||||
let mut s = [0f32; SLOTS];
|
||||
@@ -159,76 +206,85 @@ pub fn eval_voice(v: &mut Voice, p: &Program, regs: &[f32], sr: f32) -> f32 {
|
||||
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
|
||||
(phasor(&mut v.state[n.st], v.freq * ratio / sr) * TAU).sin()
|
||||
}
|
||||
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
|
||||
2.0 * phasor(&mut v.state[n.st], v.freq * ratio / sr) - 1.0
|
||||
}
|
||||
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
|
||||
let w = l.map(|x| (x / 9.0).clamp(0.05, 0.95)).unwrap_or(0.5);
|
||||
if phasor(&mut v.state[n.st], v.freq / sr) < w { 1.0 } else { -1.0 }
|
||||
}
|
||||
Op::Tri => 4.0 * (phasor(&mut v.state[n.st], v.freq / sr) - 0.5).abs() - 1.0,
|
||||
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 => {
|
||||
Op::Low | Op::High | Op::Band => {
|
||||
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
|
||||
let k = res_k(r);
|
||||
let (ic1, ic2) = {
|
||||
let (a, b) = v.state.split_at_mut(n.st + 1);
|
||||
(&mut a[n.st], &mut b[0])
|
||||
};
|
||||
let (lo, ba, hi) = svf(inp, fc, k, sr, ic1, ic2);
|
||||
match n.op {
|
||||
Op::High => hi,
|
||||
Op::Band => ba,
|
||||
_ => lo,
|
||||
}
|
||||
}
|
||||
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;
|
||||
*level += (target - *level) * (1.0 / (rate * sr)).min(1.0);
|
||||
if *level > env_max {
|
||||
env_max = *level;
|
||||
}
|
||||
inp * *level * v.vel
|
||||
}
|
||||
Op::Shape => {
|
||||
Op::Soft => {
|
||||
let drive = l.filter(|&x| x > 0.0).unwrap_or(1.0);
|
||||
(inp * drive).tanh()
|
||||
}
|
||||
Op::Clip => {
|
||||
let t = l.map(|x| (x / 9.0).clamp(0.05, 1.0)).unwrap_or(0.5);
|
||||
inp.clamp(-t, t)
|
||||
}
|
||||
Op::Fold => {
|
||||
let amt = 1.0 + l.unwrap_or(1.0);
|
||||
(inp * amt * PI * 0.5).sin()
|
||||
}
|
||||
Op::Decimate => {
|
||||
let factor = 1.0 + l.unwrap_or(4.0) * 3.0;
|
||||
let held = &mut v.state[n.st];
|
||||
let held_val = *held;
|
||||
let ph = &mut v.state[n.st + 1];
|
||||
*ph += 1.0;
|
||||
if *ph >= factor {
|
||||
*ph = 0.0;
|
||||
v.state[n.st] = inp;
|
||||
inp
|
||||
} else {
|
||||
held_val
|
||||
}
|
||||
}
|
||||
Op::Gain => {
|
||||
let g = l.map(|x| if x > 1.0 { x / 4.5 } else { x * 2.0 }).unwrap_or(1.0);
|
||||
inp * g
|
||||
}
|
||||
Op::Ring => {
|
||||
let ratio = l.filter(|&x| x > 0.0).unwrap_or(1.0);
|
||||
let m = (phasor(&mut v.state[n.st], v.freq * ratio / sr) * TAU).sin();
|
||||
inp * m
|
||||
}
|
||||
Op::Mix => inp + n.lsig.map(|i| s[i]).unwrap_or(0.0),
|
||||
Op::Out => {
|
||||
out += inp;
|
||||
inp
|
||||
@@ -236,8 +292,6 @@ pub fn eval_voice(v: &mut Voice, p: &Program, regs: &[f32], sr: f32) -> f32 {
|
||||
};
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user