2 Commits
Author SHA1 Message Date
Bendik Aagaard Lynghaug 01d490055e chore: Release galdr version 0.4.0
Release / build (aarch64, aarch64, /var/lib/gitea-runner/sccache, aarch64-unknown-linux-gnu) (push) Successful in 1m5s
Release / build (x86_64, ubuntu-latest, /sccache, x86_64-unknown-linux-gnu) (push) Successful in 1m43s
Release / update-aur (push) Successful in 17s
2026-10-19 22:00:15 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 e9836c2467 feat: modular glyphs — T I Q J P Y and control maths punctuation
Six new letters: T LFO (rate/shape, tremolo with an input), I slew
limiter, Q pitch quantizer (scale/octaves, transposes the oscillators
below it), J stepped random, P phaser (voice or master), Y comparator.
Any control op left of an operator feeds its left param, as K does.

Punctuation maths with the cell to the left: + - * (signal or constant),
% attenuverter, | max, & min, ~ sample-and-hold on a rising trigger.

A column's input is now only an operator above it, so a channel tag or
param glyph above T/J no longer flips them into VCA mode on silence.
Height resize moves from _ + to [ ] since + is a glyph now.

examples/modular.galdr: wobble bass, Q arpeggio, J crackle, Y/K pulse.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 22:00:13 +02:00
8 changed files with 661 additions and 31 deletions
Generated
+1 -1
View File
@@ -320,7 +320,7 @@ dependencies = [
[[package]] [[package]]
name = "galdr" name = "galdr"
version = "0.3.0" version = "0.4.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arc-swap", "arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "galdr" name = "galdr"
version = "0.3.0" version = "0.4.0"
edition = "2021" edition = "2021"
description = "A terminal grid synthesizer: signal flows spatially through single-glyph unit generators, voiced by MIDI, rendered low-latency to PipeWire" description = "A terminal grid synthesizer: signal flows spatially through single-glyph unit generators, voiced by MIDI, rendered low-latency to PipeWire"
license = "MIT" license = "MIT"
+113 -4
View File
@@ -35,12 +35,23 @@ a constant parameter; a lowercase letter `a`–`z` reads MIDI CC (`a` = CC 0).
| `X` | FM: sine at the note pitch, phase pushed by the cell to its **left** (and above) | — / index | | `X` | FM: sine at the note pitch, phase pushed by the cell to its **left** (and above) | — / index |
| `M` | mix — adds the cell to its **left** | — | | `M` | mix — adds the cell to its **left** | — |
| `K` | control envelope: 1 at note-on, decays to a floor; no audio input | decay / floor | | `K` | control envelope: 1 at note-on, decays to a floor; no audio input | decay / floor |
| `T` | LFO, 0–1, retriggered per note; with an input it is a tremolo | rate / shape |
| `J` | stepped random (sample-and-hold noise); with an input it chops it | rate / amount |
| `I` | slew limiter (lag) | rise / fall |
| `Q` | pitch quantizer: transposes the oscillators **below** it | scale / octaves |
| `Y` | comparator: 1 while the input is above the threshold | smoothing / threshold |
| `P` | 4-stage phaser | rate / depth |
| `+` `-` `*` | add / subtract / multiply the cell to its **left** | signal or constant |
| `%` | attenuverter | gain (0 = −1 … 9 = +1) / offset |
| `\|` `&` | max / min of input and the cell to its **left** (gate OR / AND) | signal or constant |
| `~` | sample-and-hold: holds the input when the cell to its **left** rises | trigger |
| `!` | output to master | — | | `!` | output to master | — |
`K` is the one operator whose output is a *param*: placed to the **left** of `K`, `T`, `J`, `I`, `Q`, `Y` and the maths glyphs output a *control value*:
another operator it becomes that operator's left param, scaled 0–9. `2KL5` is placed to the **left** of another operator any of them becomes that operator's
a lowpass whose cutoff starts open and decays (2) to the floor, with left param, scaled 0–9. `2KL5` is a lowpass whose cutoff starts open and
resonance 5. That's the 303 filter envelope. decays (2) to the floor, with resonance 5. That's the 303 filter envelope.
`4TL5` is the same filter wobbled by an LFO. See [the modular glyphs](#the-modular-glyphs).
Each `!` sums into the master, so parallel columns stack timbres or voices. Each `!` sums into the master, so parallel columns stack timbres or voices.
An untagged column plays on every MIDI channel. An untagged column plays on every MIDI channel.
@@ -83,6 +94,103 @@ E....!....
!......... !.........
``` ```
### The modular glyphs
The six letters and the punctuation are the eurorack side of galdr: control
sources, processors and utilities that patch together through the same
left-of-an-operator rule as `K`. Everything is on a 0–1 control scale, so the
pieces combine.
| glyph | module | left | right |
|---|---|---|---|
| `T` | LFO (0–1, restarts at note-on) | rate: 0 = 0.05 Hz … 9 = 25.6 Hz | shape: 0 sine, 1 tri, 2 saw, 3 ramp down, 4 square, 5 sample-and-hold |
| `J` | random voltage: a new value every step | rate (as `T`) | amount |
| `I` | slew limiter / lag processor | rise: 0 = 1 ms … 9 = 0.5 s | fall |
| `Q` | quantizer | scale (below) | span in octaves |
| `Y` | comparator | edge smoothing (0 = hard) | threshold, 0–9 = 0–1 |
| `P` | phaser (voice or master) | rate | depth |
`Q` scales: 0 chromatic, 1 major, 2 minor, 3 pentatonic major, 4 pentatonic
minor, 5 dorian, 6 phrygian, 7 lydian, 8 mixolydian, 9 blues. It reads a 0–1
control from the cell above, snaps it to the scale and transposes every
oscillator *below it in the column* by that many semitones. Its own output is
the snapped value, so it can also drive a param.
`T` and `J` with an audio input become VCAs on that input (tremolo, random
chopping). `Y` on an audio input is a comparator in the eurorack sense: a saw
into `Y4` is a pulse wave whose width is the threshold.
The punctuation is maths with the cell to the **left**. If that cell is an
operator its raw signal is used; a digit or register is a constant on the
0–1 scale (`9` = 1).
| glyph | out |
|---|---|
| `+` | input + left |
| `-` | input − left (no input: an inverter) |
| `*` | input × left: a VCA when a control is left of it, a ring mod when an oscillator is |
| `%` | input × gain + offset, gain from −1 (`0`) through 0 (`4`/`5`) to +1 (`9`) |
| `\|` | max(input, left) — OR for gates, half-wave rectifier with nothing to the left |
| `&` | min(input, left) — AND for gates |
| `~` | sample-and-hold: holds the input whenever the left cell rises through 0.5 |
A few patches, each one column plus its helpers:
```
..A. saw
7TL6 wobble bass: a 6.4 Hz sine LFO sweeps a resonant lowpass
..!.
```
```
..1T2 a 0.1 Hz ramp...
...Q. ...quantized to the major scale over one octave...
...A. ...transposes this saw: a slow arpeggio from a single held note
...!.
```
```
..N. noise
.9J* random voltage at 25.6 Hz as a VCA: crackle
..!.
```
```
...A. saw
.6T~. a 3.2 Hz LFO to the left of ~ clocks the hold: a stepped saw
...!.
```
```
..A..
..Y4. comparator turns the saw into a pulse
3K*.. K to the left of * is the VCA: a plucky pulse
..!..
```
`examples/modular.galdr` strings four of these into a kit, one per MIDI
channel, with a phaser on the master:
```
..0.....1......2....3..
..A....2T2.....N....A..
7TL6...1Q1...9J*....Y4.
..E3....A.....5B5.3K*..
..!....4L2.....E2..1H..
.3P5....E4.....!....!..
........!..............
```
- **wobble bass** (ch 1): saw, a 6.4 Hz LFO sweeping a resonant lowpass.
- **arpeggio** (ch 2): a 0.2 Hz ramp through `Q` (major, one octave) climbs the
saw a scale step every 0.7 s from a single held note.
- **crackle** (ch 3): noise chopped by `J` at 25.6 Hz through a bandpass.
- **plucky pulse** (ch 4): `Y` turns the saw into a pulse, `K` left of `*` is
the VCA, `H` removes the pulse's DC.
Watch the tag rule when you stack control ops: a digit directly above `T`,
`K` or any other operator at the top of its column is read as a channel tag.
### The master chain ### The master chain
Operators placed **below** a column's `!` no longer run per voice: they Operators placed **below** a column's `!` no longer run per voice: they
@@ -93,6 +201,7 @@ shaper works there, plus two master-only effects:
|---|---|---| |---|---|---|
| `Z` | delay | time (0 = 100 ms … 9 = 1 s) / feedback | | `Z` | delay | time (0 = 100 ms … 9 = 1 s) / feedback |
| `W` | stereo chorus | rate / depth | | `W` | stereo chorus | rate / depth |
| `P` | phaser (also works in a voice) | rate / depth |
``` ```
A A
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Bendik Aagaard Lynghaug <bendik.lynghaug@gmail.com> # Maintainer: Bendik Aagaard Lynghaug <bendik.lynghaug@gmail.com>
pkgname=galdr pkgname=galdr
pkgver=0.3.0 pkgver=0.4.0
pkgrel=1 pkgrel=1
pkgdesc="Terminal grid synthesizer: signal flows through single-glyph unit generators, MIDI in, low-latency PipeWire out" pkgdesc="Terminal grid synthesizer: signal flows through single-glyph unit generators, MIDI in, low-latency PipeWire out"
arch=('x86_64' 'aarch64') arch=('x86_64' 'aarch64')
+8
View File
@@ -0,0 +1,8 @@
# wobble bass (ch 1) | arpeggio (ch 2) | crackle (ch 3) | plucky pulse (ch 4): the modular glyphs
..0.....1......2....3..
..A....2T2.....N....A..
7TL6...1Q1...9J*....Y4.
..E3....A.....5B5.3K*..
..!....4L2.....E2..1H..
.3P5....E4.....!....!..
........!..............
+455 -16
View File
@@ -61,6 +61,57 @@ pub enum Op {
/// Placed LEFT of an operator it becomes that operator's left param: /// Placed LEFT of an operator it becomes that operator's left param:
/// `2KL5` sweeps the lowpass cutoff from open down to the floor. /// `2KL5` sweeps the lowpass cutoff from open down to the floor.
Ctrl, Ctrl,
/// `T`: LFO, unipolar 0..1, retriggered at note-on. Left = rate, right =
/// shape. With an input it is a tremolo / VCA on that input.
Lfo,
/// `I`: slew limiter (lag). Left = rise time, right = fall time.
Slew,
/// `Q`: pitch quantizer. Snaps its control input (0..1) to a scale (left)
/// over a span of octaves (right) and transposes every oscillator BELOW it
/// in the column. Outputs the snapped value.
Quant,
/// `J`: stepped random (sample-and-hold noise). Left = rate, right =
/// amount. With an input it chops that input.
Rnd,
/// `P`: 4-stage phaser. Left = rate, right = depth.
Phaser,
/// `Y`: comparator. 1 while the input is above the threshold (right),
/// else 0; left = edge smoothing.
Gate,
/// `+` `-` `*`: control maths with the cell to the left (a signal, a
/// control, or a constant /9).
Add,
Sub,
Mul,
/// `%`: attenuverter, left 0..9 = gain -1..+1, right = offset.
Att,
/// `|` `&`: max / min of the input and the left cell (gate OR / AND).
Or,
And,
/// `~`: sample-and-hold: holds the input whenever the left cell rises
/// through 0.5.
Hold,
}
/// Operators whose output is a control value: to the LEFT of another
/// operator they feed its left param (`Param::Sig`).
fn is_control(op: Op) -> bool {
matches!(
op,
Op::Ctrl
| Op::Lfo
| Op::Slew
| Op::Quant
| Op::Rnd
| Op::Gate
| Op::Add
| Op::Sub
| Op::Mul
| Op::Att
| Op::Or
| Op::And
| Op::Hold
)
} }
#[derive(Clone, Copy, PartialEq, Debug)] #[derive(Clone, Copy, PartialEq, Debug)]
@@ -156,6 +207,19 @@ fn op_of(c: char) -> Option<(Op, usize)> {
'Z' => (Op::Delay, 1), 'Z' => (Op::Delay, 1),
'W' => (Op::Chorus, 1), 'W' => (Op::Chorus, 1),
'K' => (Op::Ctrl, 0), 'K' => (Op::Ctrl, 0),
'T' => (Op::Lfo, 3),
'I' => (Op::Slew, 1),
'Q' => (Op::Quant, 0),
'J' => (Op::Rnd, 3),
'P' => (Op::Phaser, 5),
'Y' => (Op::Gate, 1),
'+' => (Op::Add, 0),
'-' => (Op::Sub, 0),
'*' => (Op::Mul, 0),
'%' => (Op::Att, 0),
'|' => (Op::Or, 0),
'&' => (Op::And, 0),
'~' => (Op::Hold, 2),
_ => return None, _ => return None,
}) })
} }
@@ -175,10 +239,6 @@ fn param_of(c: char) -> Param {
} }
} }
fn occupied(c: char) -> bool {
!matches!(c, ' ' | '.' | '\0')
}
/// orca's base-36 digit: 0-9, a-z (A-Z fold to the same values). /// orca's base-36 digit: 0-9, a-z (A-Z fold to the same values).
pub fn b36(c: char) -> Option<u8> { pub fn b36(c: char) -> Option<u8> {
match c { match c {
@@ -201,13 +261,14 @@ pub fn column_tag(grid: &Grid, col: usize) -> Option<(u8, bool)> {
Some((tag, grid.get(col + 1, top - 1) == '>')) Some((tag, grid.get(col + 1, top - 1) == '>'))
} }
/// A `K` to the left of an operator feeds that operator's left param. /// A control operator (`K`, `T`, ...) to the left of an operator feeds that
/// operator's left param.
fn left_param(grid: &Grid, col: usize, row: usize) -> Param { fn left_param(grid: &Grid, col: usize, row: usize) -> Param {
if col == 0 { if col == 0 {
return Param::None; return Param::None;
} }
let c = grid.get(col - 1, row); let c = grid.get(col - 1, row);
if matches!(op_of(c), Some((Op::Ctrl, _))) { if op_of(c).is_some_and(|(op, _)| is_control(op)) {
Param::Sig(row * MAX_W + col - 1) Param::Sig(row * MAX_W + col - 1)
} else { } else {
param_of(c) param_of(c)
@@ -276,7 +337,9 @@ pub fn compile(grid: &Grid) -> Program {
if matches!(op, Op::Env) { if matches!(op, Op::Env) {
has_env = true; has_env = true;
} }
let input = if row > 0 && occupied(grid.get(col, row - 1)) { // only an operator above is an input: a tag or param glyph there
// must not switch T/J into their VCA mode on a zero signal
let input = if row > 0 && is_operator(grid.get(col, row - 1)) {
Some((row - 1) * MAX_W + col) Some((row - 1) * MAX_W + col)
} else { } else {
None None
@@ -403,11 +466,187 @@ fn res_k(right: Option<f32>) -> f32 {
(1.5 - right.unwrap_or(3.0) * 0.15).clamp(0.15, 1.5) (1.5 - right.unwrap_or(3.0) * 0.15).clamp(0.15, 1.5)
} }
/// Control-rate frequency: 0..9 -> 0.05 Hz .. 25.6 Hz.
#[inline]
fn lfo_hz(v: f32) -> f32 {
0.05 * 2f32.powf(v.clamp(0.0, 9.0))
}
/// Slew / smoothing time: 0..9 -> 1 ms .. 512 ms.
#[inline]
fn lag_secs(v: f32) -> f32 {
0.001 * 2f32.powf(v.clamp(0.0, 9.0))
}
/// Cheap integer hash to 0..1, for stepped random sources.
#[inline]
fn hash01(x: u32) -> f32 {
let mut h = x.wrapping_mul(0x9E37_79B9);
h ^= h >> 16;
h = h.wrapping_mul(0x85EB_CA6B);
h ^= h >> 13;
(h >> 8) as f32 / (1u32 << 24) as f32
}
/// Scales for `Q`, as semitone sets.
const SCALES: [&[u8]; 10] = [
&[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], // 0 chromatic
&[0, 2, 4, 5, 7, 9, 11], // 1 major
&[0, 2, 3, 5, 7, 8, 10], // 2 minor
&[0, 2, 4, 7, 9], // 3 pentatonic major
&[0, 3, 5, 7, 10], // 4 pentatonic minor
&[0, 2, 3, 5, 7, 9, 10], // 5 dorian
&[0, 1, 3, 5, 7, 8, 10], // 6 phrygian
&[0, 2, 4, 6, 7, 9, 11], // 7 lydian
&[0, 2, 4, 5, 7, 9, 10], // 8 mixolydian
&[0, 3, 5, 6, 7, 10], // 9 blues
];
/// Snap a 0..1 control to `scale` over `span` octaves. Returns
/// (semitones, normalised 0..1).
pub fn quantize(x: f32, scale: usize, span: usize) -> (u8, f32) {
let sc = SCALES[scale.min(9)];
let span = span.max(1);
let steps = sc.len() * span;
let idx = ((x.clamp(0.0, 1.0) * steps as f32) as usize).min(steps - 1);
let semis = (idx / sc.len()) as u8 * 12 + sc[idx % sc.len()];
(semis, semis as f32 / (12 * span) as f32)
}
/// The left-hand value of a maths operator: the raw signal of the cell to the
/// left when that cell is an operator, otherwise the param on a 0..1 scale.
#[inline]
fn lval(n: &Node, l: Option<f32>, s: &[f32]) -> f32 {
match n.left {
Param::None => n.lsig.map(|i| s[i]).unwrap_or(0.0),
_ => l.unwrap_or(0.0) / 9.0,
}
}
/// `+ - * % | & ~`: shared by voices and the master chain. `lv` is the
/// left-hand value (see `lval`).
#[inline]
fn maths(
op: Op,
inp: f32,
lv: f32,
l: Option<f32>,
r: Option<f32>,
state: &mut [f32],
) -> Option<f32> {
Some(match op {
Op::Add => inp + lv,
Op::Sub => inp - lv,
Op::Mul => inp * lv,
Op::Att => {
let g = (l.unwrap_or(9.0) - 4.5) / 4.5;
inp * g + r.map(|x| x / 9.0).unwrap_or(0.0)
}
Op::Or => inp.max(lv),
Op::And => inp.min(lv),
Op::Hold => {
if lv > 0.5 && state[1] <= 0.5 {
state[0] = inp;
}
state[1] = lv;
state[0]
}
_ => return None,
})
}
/// The note-independent processors, shared by voices and the master chain. /// The note-independent processors, shared by voices and the master chain.
/// `state` is the node's own slice. Returns None for ops this does not handle. /// `state` is the node's own slice. Returns None for ops this does not handle.
#[inline] #[inline]
fn fx(op: Op, inp: f32, l: Option<f32>, r: Option<f32>, state: &mut [f32], sr: f32) -> Option<f32> { #[allow(clippy::too_many_arguments)]
fn fx(
op: Op,
inp: f32,
has_in: bool,
l: Option<f32>,
r: Option<f32>,
state: &mut [f32],
sr: f32,
seed: u32,
) -> Option<f32> {
Some(match op { Some(match op {
Op::Lfo => {
let ph = phasor(&mut state[0], lfo_hz(l.unwrap_or(4.0)) / sr);
let shape = r.unwrap_or(0.0) as u8;
let v = match shape {
0 => 0.5 + 0.5 * (ph * TAU).sin(),
1 => 1.0 - 2.0 * (ph - 0.5).abs(),
2 => ph,
3 => 1.0 - ph,
4 => {
if ph < 0.5 {
1.0
} else {
0.0
}
}
_ => {
if state[2] == 0.0 || state[0] < ph {
// first sample or phase wrapped: new step
state[2] += 1.0;
state[1] = hash01(seed ^ (state[2] as u32).wrapping_mul(0x2545_F491));
}
state[1]
}
};
if has_in {
inp * v
} else {
v
}
}
Op::Rnd => {
let ph = phasor(&mut state[1], lfo_hz(l.unwrap_or(4.0)) / sr);
if state[2] == 0.0 || state[1] < ph {
state[2] += 1.0;
state[0] = hash01(seed ^ (state[2] as u32).wrapping_mul(0x2545_F491));
}
let v = state[0] * r.map(|x| x / 9.0).unwrap_or(1.0);
if has_in {
inp * v
} else {
v
}
}
Op::Slew => {
let t = if inp > state[0] { l } else { r };
let step = 1.0 / (lag_secs(t.unwrap_or(3.0)) * sr);
state[0] += (inp - state[0]).clamp(-step, step);
state[0]
}
Op::Gate => {
let th = r.map(|x| x / 9.0).unwrap_or(0.5);
let target = if inp > th { 1.0 } else { 0.0 };
match l {
Some(x) if x > 0.0 => {
state[0] += (target - state[0]) * (1.0 / (lag_secs(x) * sr)).min(1.0);
state[0]
}
_ => {
state[0] = target;
target
}
}
}
Op::Phaser => {
let lfo = (phasor(&mut state[4], lfo_hz(l.unwrap_or(2.0)) / sr) * TAU).sin();
let octaves = r.unwrap_or(5.0) / 9.0 * 3.5;
let fc = 200.0 * 2f32.powf((0.5 + 0.5 * lfo) * octaves);
let t = (PI * fc / sr).min(1.5).tan();
let a = (t - 1.0) / (t + 1.0);
let mut x = inp;
for st in state.iter_mut().take(4) {
let v = x - a * *st;
x = a * v + *st;
*st = v;
}
0.5 * (inp + x)
}
Op::Low | Op::High | Op::Band => { Op::Low | Op::High | Op::Band => {
let fc = cutoff_hz(l.unwrap_or(9.0)); let fc = cutoff_hz(l.unwrap_or(9.0));
let k = res_k(r); let k = res_k(r);
@@ -462,7 +701,14 @@ pub fn eval_voice(v: &mut Voice, p: &Program, regs: &[f32], sr: f32) -> f32 {
note: v.note, note: v.note,
rand: v.rand, rand: v.rand,
}); });
// `Q` transposes the oscillators below it; reset per column.
let mut freq = v.freq;
let mut cur_col = u8::MAX;
for n in &p.nodes { for n in &p.nodes {
if n.col != cur_col {
cur_col = n.col;
freq = v.freq;
}
// A bang voice addresses one column directly and ignores channel tags. // A bang voice addresses one column directly and ignores channel tags.
match v.only_col { match v.only_col {
Some(c) if c != n.col => continue, Some(c) if c != n.col => continue,
@@ -485,21 +731,21 @@ pub fn eval_voice(v: &mut Voice, p: &Program, regs: &[f32], sr: f32) -> f32 {
} }
Op::Sine => { Op::Sine => {
let ratio = l.filter(|&x| x > 0.0).unwrap_or(1.0); let ratio = l.filter(|&x| x > 0.0).unwrap_or(1.0);
(phasor(&mut v.state[n.st], v.freq * ratio / sr) * TAU).sin() (phasor(&mut v.state[n.st], freq * ratio / sr) * TAU).sin()
} }
Op::Saw => { Op::Saw => {
let ratio = l.filter(|&x| x > 0.0).unwrap_or(1.0); let ratio = l.filter(|&x| x > 0.0).unwrap_or(1.0);
2.0 * phasor(&mut v.state[n.st], v.freq * ratio / sr) - 1.0 2.0 * phasor(&mut v.state[n.st], freq * ratio / sr) - 1.0
} }
Op::Pulse => { Op::Pulse => {
let w = l.map(|x| (x / 9.0).clamp(0.05, 0.95)).unwrap_or(0.5); 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 { if phasor(&mut v.state[n.st], freq / sr) < w {
1.0 1.0
} else { } else {
-1.0 -1.0
} }
} }
Op::Tri => 4.0 * (phasor(&mut v.state[n.st], v.freq / sr) - 0.5).abs() - 1.0, Op::Tri => 4.0 * (phasor(&mut v.state[n.st], freq / sr) - 0.5).abs() - 1.0,
Op::Noise => v.next_rand() * 2.0 - 1.0, Op::Noise => v.next_rand() * 2.0 - 1.0,
Op::Env => { Op::Env => {
let atk = l.map(|x| 0.002 + x * 0.05).unwrap_or(0.01); let atk = l.map(|x| 0.002 + x * 0.05).unwrap_or(0.01);
@@ -515,17 +761,28 @@ pub fn eval_voice(v: &mut Voice, p: &Program, regs: &[f32], sr: f32) -> f32 {
} }
Op::Ring => { Op::Ring => {
let ratio = l.filter(|&x| x > 0.0).unwrap_or(1.0); 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(); let m = (phasor(&mut v.state[n.st], freq * ratio / sr) * TAU).sin();
inp * m inp * m
} }
Op::Fm => { Op::Fm => {
// modulator = left cell (+ the cell above); index in cycles // modulator = left cell (+ the cell above); index in cycles
let index = r.map(|x| x * 0.25).unwrap_or(0.5); let index = r.map(|x| x * 0.25).unwrap_or(0.5);
let m = n.lsig.map(|i| s[i]).unwrap_or(0.0) + inp; let m = n.lsig.map(|i| s[i]).unwrap_or(0.0) + inp;
let ph = phasor(&mut v.state[n.st], v.freq / sr); let ph = phasor(&mut v.state[n.st], freq / sr);
((ph + m * index) * TAU).sin() ((ph + m * index) * TAU).sin()
} }
Op::Mix => inp + n.lsig.map(|i| s[i]).unwrap_or(0.0), Op::Mix => inp + n.lsig.map(|i| s[i]).unwrap_or(0.0),
Op::Quant => {
let (semis, norm) =
quantize(inp, l.unwrap_or(0.0) as usize, r.unwrap_or(1.0) as usize);
freq = v.freq * 2f32.powf(semis as f32 / 12.0);
norm
}
Op::Add | Op::Sub | Op::Mul | Op::Att | Op::Or | Op::And | Op::Hold => {
let lv = lval(n, l, &s);
let end = (n.st + n.ss).min(v.state.len());
maths(n.op, inp, lv, l, r, &mut v.state[n.st..end]).unwrap_or(inp)
}
Op::Out => { Op::Out => {
out += inp; out += inp;
inp inp
@@ -534,7 +791,18 @@ pub fn eval_voice(v: &mut Voice, p: &Program, regs: &[f32], sr: f32) -> f32 {
Op::Delay | Op::Chorus => inp, Op::Delay | Op::Chorus => inp,
op => { op => {
let end = (n.st + n.ss).min(v.state.len()); let end = (n.st + n.ss).min(v.state.len());
fx(op, inp, l, r, &mut v.state[n.st..end], sr).unwrap_or(inp) let has_in = n.input.is_some();
fx(
op,
inp,
has_in,
l,
r,
&mut v.state[n.st..end],
sr,
n.st as u32,
)
.unwrap_or(inp)
} }
}; };
s[n.out] = y; s[n.out] = y;
@@ -625,10 +893,17 @@ pub fn eval_post(
| Op::Ring | Op::Ring
| Op::Fm | Op::Fm
| Op::Mix | Op::Mix
| Op::Quant
| Op::Out => inp[c], | Op::Out => inp[c],
Op::Add | Op::Sub | Op::Mul | Op::Att | Op::Or | Op::And | Op::Hold => {
let lv = l.unwrap_or(0.0) / 9.0;
let end = (st + n.ss).min(state.len());
maths(n.op, inp[c], lv, l, r, &mut state[st..end]).unwrap_or(inp[c])
}
op => { op => {
let end = (st + n.ss).min(state.len()); let end = (st + n.ss).min(state.len());
fx(op, inp[c], l, r, &mut state[st..end], sr).unwrap_or(inp[c]) fx(op, inp[c], true, l, r, &mut state[st..end], sr, st as u32)
.unwrap_or(inp[c])
} }
}; };
} }
@@ -1042,4 +1317,168 @@ mod tests {
); );
assert!((y[0] - 0.4).abs() < 1e-6); assert!((y[0] - 0.4).abs() < 1e-6);
} }
#[test]
fn modular_glyphs_compile_and_control_ops_feed_left_params() {
let p = compile(&grid_from(".A.\nTL.\n.!."));
let l = p.nodes.iter().find(|n| n.op == Op::Low).unwrap();
assert_eq!(l.left, Param::Sig(MAX_W));
for c in "TIQJPY+-*%|&~".chars() {
assert!(is_operator(c), "{c} should be an operator");
}
}
#[test]
fn lfo_is_unipolar_and_cycles_at_its_rate() {
// rate 6 = 3.2 Hz: 15000 samples at 48 kHz is one full cycle
let p = compile(&grid_from("6T\n.!"));
let mut v = voice(440.0, 1.0, 1.0);
let x = render(&mut v, &p, 15000);
let (lo, hi) = x
.iter()
.fold((1.0f32, 0.0f32), |(a, b), &y| (a.min(y), b.max(y)));
assert!((0.0..0.01).contains(&lo), "min {lo}");
assert!(hi <= 1.0 && hi > 0.99, "max {hi}");
assert!((x[0] - 0.5).abs() < 0.01, "sine LFO starts at mid");
assert!((x[14999] - 0.5).abs() < 0.01, "back at mid after one cycle");
}
#[test]
fn lfo_with_input_is_a_tremolo() {
let p = compile(&grid_from(".A.\n9T4\n.!."));
let mut v = voice(100.0, 1.0, 1.0);
let x = render(&mut v, &p, 3000);
// square shape 4 at 25.6 Hz: first half-cycle (937 samples) passes, second mutes
assert!(x[..900].iter().any(|y| y.abs() > 0.5));
assert!(x[1000..1800].iter().all(|y| *y == 0.0), "muted half");
}
#[test]
fn slew_limits_rate_of_change() {
// square LFO through a slew with rise 9 (512 ms): never reaches 1 in 100 ms
let p = compile(&grid_from("4T4\n9I9\n.!."));
let mut v = voice(440.0, 1.0, 1.0);
let x = render(&mut v, &p, 4800);
assert!(
x.iter().all(|&y| y < 0.3),
"max {}",
x.iter().cloned().fold(0.0, f32::max)
);
assert!(x[4799] > x[100], "but it climbs");
}
#[test]
fn quantizer_snaps_to_scale() {
assert_eq!(quantize(0.0, 1, 1).0, 0);
assert_eq!(quantize(0.99, 1, 1).0, 11);
assert_eq!(quantize(0.5, 3, 1).0, 4); // pentatonic major index 2 of 5 = third
assert_eq!(quantize(0.99, 0, 2).0, 23);
assert_eq!(quantize(0.5, 0, 2).0, 12);
}
#[test]
fn quantizer_transposes_the_oscillator_below() {
// saw LFO at 0.05 Hz is ~0 for the first 100 ms: Q leaves the pitch alone
let p0 = compile(&grid_from("0T2\n1Q.\n.O.\n.!."));
// a constant 5/9 through Q (major) = index 3 = 5 semitones: a fourth up
let p1 = compile(&grid_from("5+.\n1Q.\n.O.\n.!."));
let zc = |x: &[f32]| x.windows(2).filter(|p| p[0] <= 0.0 && p[1] > 0.0).count();
let mut v = voice(100.0, 1.0, 1.0);
let a = render(&mut v, &p0, 4800);
let mut w = voice(100.0, 1.0, 1.0);
let b = render(&mut w, &p1, 4800);
assert_eq!(quantize(5.0 / 9.0, 1, 1).0, 5);
assert_eq!(zc(&a), 10, "100 Hz over 100 ms");
assert!(
(13..=14).contains(&zc(&b)),
"133.5 Hz over 100 ms, got {}",
zc(&b)
);
}
#[test]
fn stepped_random_holds_then_changes() {
let p = compile(&grid_from("9J\n.!"));
let mut v = voice(440.0, 1.0, 1.0);
let x = render(&mut v, &p, 6000);
assert!(
x[0] > 0.0 && x[0] <= 1.0,
"rolls a value on the first sample: {}",
x[0]
);
assert!(
x[..1500].windows(2).all(|w| w[0] == w[1]),
"holds within a step"
);
assert!(x.windows(2).any(|w| w[0] != w[1]), "steps eventually");
}
#[test]
fn phaser_is_bounded_and_changes_the_signal() {
let p = compile(&grid_from(".A.\n4P5\n.!."));
let mut v = voice(220.0, 1.0, 1.0);
let x = render(&mut v, &p, 4800);
// allpass stages preserve energy, not peak: a little overshoot is normal
let peak = x.iter().fold(0.0f32, |a, y| a.max(y.abs()));
assert!(peak <= 1.5, "peak {peak}");
let mut w = voice(220.0, 1.0, 1.0);
let plain = render(&mut w, &compile(&grid_from("A\n!")), 4800);
assert!(x.iter().zip(&plain).any(|(a, b)| (a - b).abs() > 0.05));
}
#[test]
fn comparator_makes_a_pulse_from_a_saw() {
// saw > 0.5 for the top quarter of each cycle
let p = compile(&grid_from("A.\nY4\n!."));
let mut v = voice(100.0, 1.0, 1.0);
let x = render(&mut v, &p, 480);
let high = x.iter().filter(|&&y| y == 1.0).count();
assert!(x.iter().all(|&y| y == 0.0 || y == 1.0));
// threshold 4/9: the top 27.8 % of the saw
assert!((125..=140).contains(&high), "{high} of 480 high");
}
#[test]
fn maths_ops_add_sub_mul_with_constants_and_signals() {
let regs = vec![0.0; 128];
let eval = |src: &str| {
let p = compile(&grid_from(src));
let mut v = voice(440.0, 1.0, 1.0);
eval_voice(&mut v, &p, &regs, 48000.0)
};
assert!((eval("9+\n.!") - 1.0).abs() < 1e-6, "0 + 9/9");
assert!((eval("3-\n.!") + 1.0 / 3.0).abs() < 1e-6, "0 - 3/9");
assert!((eval("9+\n3*\n.!") - 1.0 / 3.0).abs() < 1e-6, "1 * 3/9");
// K (=1 at note-on) left of * is a VCA: saw first sample -1 * 1
assert!((eval(".A\nK*\n.!") + 1.0).abs() < 1e-6);
// an audio op to the left is read as a raw signal: A(-1) * A(-1) = 1
assert!((eval(".A\nA*\n.!") - 1.0).abs() < 1e-6);
// attenuverter: 0 = invert, 9 = unity; right adds an offset
assert!((eval("9+\n0%\n.!") + 1.0).abs() < 1e-6);
assert!((eval("9+.\n9%9\n.!.") - 2.0).abs() < 1e-6);
// | and & are max / min
assert!((eval("9+\n3|\n.!") - 1.0).abs() < 1e-6);
assert!((eval("9+\n3&\n.!") - 1.0 / 3.0).abs() < 1e-6);
}
#[test]
fn sample_and_hold_tracks_only_on_rising_edge() {
// saw into ~, a 25.6 Hz sine LFO to its LEFT as the trigger
let p = compile(&grid_from("..A\n9T~\n..!"));
let mut v = voice(100.0, 1.0, 1.0);
let x = render(&mut v, &p, 2000);
// the LFO crosses 0.5 upward on sample 1: hold the saw there, keep it
// through the high half (to ~937) and the low half (to ~1875)
assert_eq!(x[0], 0.0, "nothing held yet");
assert!(
x[1] < -0.99 && x[1] > -1.0,
"held the second saw sample: {}",
x[1]
);
assert!(x[1..1850].iter().all(|&y| y == x[1]), "held");
assert!(
x[1900..].iter().any(|&y| y != x[1]),
"retriggered on the next rise"
);
}
} }
+68
View File
@@ -291,3 +291,71 @@ mod acid_example {
assert_eq!(e.active_voices(), 1); assert_eq!(e.active_voices(), 1);
} }
} }
#[cfg(test)]
mod modular_example {
use super::*;
use crate::dsp::{column_tag, compile};
use crate::engine::{Engine, Event};
/// Period in samples by autocorrelation over lags 100..600 (80 Hz .. 480 Hz).
fn period(x: &[f32]) -> usize {
(100..600)
.max_by(|&a, &b| {
let ac = |lag: usize| -> f32 { x.iter().zip(&x[lag..]).map(|(p, q)| p * q).sum() };
ac(a).partial_cmp(&ac(b)).unwrap()
})
.unwrap()
}
#[test]
fn modular_example_channels_sound_and_arpeggio_moves() {
let g = load(std::path::Path::new("examples/modular.galdr")).unwrap();
// only the four intended tags: a digit above a control op would be one too
let tags: Vec<u8> = (0..g.w)
.filter_map(|c| column_tag(&g, c).map(|(t, _)| t))
.collect();
assert_eq!(tags, vec![0, 1, 2, 3]);
let p = compile(&g);
assert_eq!(p.post.len(), 1, "phaser on the master");
for (ch, note, name) in [
(0u8, 36u8, "wobble"),
(1, 48, "arp"),
(2, 60, "crackle"),
(3, 52, "pulse"),
] {
let mut e = Engine::new(8, 48000.0);
e.apply(Event::NoteOn { ch, note, vel: 110 });
let out: Vec<f32> = (0..96000).map(|_| e.render(&p)[0]).collect();
let peak = out.iter().fold(0f32, |a, y| a.max(y.abs()));
assert!(
peak > 0.05 && peak <= 1.0,
"{name} silent or clipping ({peak})"
);
if name == "arp" {
// 0.5 s: still the root (367 samples at 130.8 Hz); 1.9 s: a third up
let early = period(&out[24000..33600]);
let late = period(&out[86400..96000]);
eprintln!("arp period {early} -> {late}");
assert!(
(355..=380).contains(&early),
"root expected, period {early}"
);
assert!(
(280..=300).contains(&late),
"4 semitones up expected, period {late}"
);
}
let mut e2 = Engine::new(8, 48000.0);
e2.apply(Event::NoteOn {
ch: 5,
note,
vel: 110,
});
let leak = (0..1000)
.map(|_| e2.render(&p)[0].abs())
.fold(0f32, f32::max);
assert!(leak == 0.0, "{name} leaks onto channel 6");
}
}
}
+14 -8
View File
@@ -40,15 +40,19 @@ const HELP: &[&str] = &[
"FILTERS L lowpass H highpass B bandpass left = cutoff, right = resonance", "FILTERS L lowpass H highpass B bandpass left = cutoff, right = resonance",
"SHAPERS S soft clip (left=drive) C clip (left) F fold (left) D decimate (left)", "SHAPERS S soft clip (left=drive) C clip (left) F fold (left) D decimate (left)",
"AMP/MOD E envelope (left=attack, right=release) G gain R ring mod (ratio)", "AMP/MOD E envelope (left=attack, right=release) G gain R ring mod (ratio)",
"CONTROL K decay envelope (left=decay, right=floor); LEFT of an operator it drives", "CONTROL K decay env (decay/floor) T LFO (rate/shape 0sin 1tri 2saw 3ramp 4sq 5s&h)",
" that operator's left param: 2KL5 = filter sweep. tag> = slide (legato channel)", " J stepped random (rate/amount) I slew (rise/fall) Y comparator (smooth/thresh)",
"ROUTING M mix — adds the cell to its LEFT ! output to master", " Q quantizer (scale/octaves): transposes the oscillators BELOW it",
"MASTER Z delay (left=time, right=feedback) W chorus (left=rate, right=depth)", " any of these LEFT of an operator drives its left param: 2KL5 = filter sweep",
"MATHS + - * with the LEFT cell (signal or constant/9) % attenuverter (gain/offset)",
" | max & min ~ sample&hold (LEFT cell = trigger) T/J with input = VCA",
"ROUTING M mix — adds the cell to its LEFT ! output to master tag> = slide",
"MASTER Z delay (time/feedback) W chorus (rate/depth) P phaser (rate/depth)",
" + any filter/shaper, placed below a !", " + any filter/shaper, placed below a !",
"UDP k7 reg k=7/35 · *3 bang column 3 · @34O write O at col 3,row 4 · #name load", "UDP k7 reg k=7/35 · *3 bang column 3 · @34O write O at col 3,row 4 · #name load",
"", "",
"KEYS arrows move · type a glyph · Backspace clears · Ctrl+S save patch", "KEYS arrows move · type a glyph · Backspace clears · Ctrl+S save patch",
" ( ) width -/+ _ + height -/+ Ctrl+arrows resize by one", " ( ) width -/+ [ ] height -/+ Ctrl+arrows resize by one",
" ? toggle this help · Esc quit", " ? toggle this help · Esc quit",
]; ];
@@ -63,7 +67,9 @@ fn glyph_color(c: char) -> Color {
'O' | 'A' | 'U' | 'V' | 'N' | 'X' => Color::Cyan, 'O' | 'A' | 'U' | 'V' | 'N' | 'X' => Color::Cyan,
'L' | 'H' | 'B' => Color::Blue, 'L' | 'H' | 'B' => Color::Blue,
'S' | 'C' | 'F' | 'D' => Color::Yellow, 'S' | 'C' | 'F' | 'D' => Color::Yellow,
'E' | 'G' | 'R' | 'M' | 'Z' | 'W' | 'K' => Color::Green, 'E' | 'G' | 'R' | 'M' | 'Z' | 'W' | 'P' => Color::Green,
'K' | 'T' | 'I' | 'Q' | 'J' | 'Y' => Color::DarkGreen,
'+' | '-' | '*' | '%' | '|' | '&' | '~' => Color::DarkCyan,
'!' => Color::Magenta, '!' => Color::Magenta,
'0'..='9' => Color::Grey, '0'..='9' => Color::Grey,
'a'..='z' => Color::DarkYellow, 'a'..='z' => Color::DarkYellow,
@@ -213,11 +219,11 @@ pub fn run(mut s: Session, mut grid: Grid) -> io::Result<()> {
grid.resize(grid.h, ruler_snap(grid.w, 1)); grid.resize(grid.h, ruler_snap(grid.w, 1));
resized = true; resized = true;
} }
KeyCode::Char('_') => { KeyCode::Char('[') => {
grid.resize(ruler_snap(grid.h, -1), grid.w); grid.resize(ruler_snap(grid.h, -1), grid.w);
resized = true; resized = true;
} }
KeyCode::Char('+') => { KeyCode::Char(']') => {
grid.resize(ruler_snap(grid.h, 1), grid.w); grid.resize(ruler_snap(grid.h, 1), grid.w);
resized = true; resized = true;
} }