From e9836c24677c86efdc5588ca5d7c591651841a66 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Mon, 19 Oct 2026 22:00:13 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20modular=20glyphs=20=E2=80=94=20T=20I=20?= =?UTF-8?q?Q=20J=20P=20Y=20and=20control=20maths=20punctuation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 117 +++++++++- examples/modular.galdr | 8 + src/dsp.rs | 471 +++++++++++++++++++++++++++++++++++++++-- src/patch.rs | 68 ++++++ src/tui.rs | 22 +- 5 files changed, 658 insertions(+), 28 deletions(-) create mode 100644 examples/modular.galdr diff --git a/README.md b/README.md index 7074e89..c3c657d 100644 --- a/README.md +++ b/README.md @@ -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 | | `M` | mix — adds the cell to its **left** | — | | `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 | — | -`K` is the one operator whose output is a *param*: placed to the **left** of -another operator it becomes that operator's left param, scaled 0–9. `2KL5` is -a lowpass whose cutoff starts open and decays (2) to the floor, with -resonance 5. That's the 303 filter envelope. +`K`, `T`, `J`, `I`, `Q`, `Y` and the maths glyphs output a *control value*: +placed to the **left** of another operator any of them becomes that operator's +left param, scaled 0–9. `2KL5` is a lowpass whose cutoff starts open and +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. 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 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 | | `W` | stereo chorus | rate / depth | +| `P` | phaser (also works in a voice) | rate / depth | ``` A diff --git a/examples/modular.galdr b/examples/modular.galdr new file mode 100644 index 0000000..aa64b93 --- /dev/null +++ b/examples/modular.galdr @@ -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.....!....!.. +........!.............. diff --git a/src/dsp.rs b/src/dsp.rs index 7082483..c21ff62 100644 --- a/src/dsp.rs +++ b/src/dsp.rs @@ -61,6 +61,57 @@ pub enum Op { /// Placed LEFT of an operator it becomes that operator's left param: /// `2KL5` sweeps the lowpass cutoff from open down to the floor. 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)] @@ -156,6 +207,19 @@ fn op_of(c: char) -> Option<(Op, usize)> { 'Z' => (Op::Delay, 1), 'W' => (Op::Chorus, 1), '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, }) } @@ -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). pub fn b36(c: char) -> Option { 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) == '>')) } -/// 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 { if col == 0 { return Param::None; } 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) } else { param_of(c) @@ -276,7 +337,9 @@ 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)) { + // 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) } else { None @@ -403,11 +466,187 @@ fn res_k(right: Option) -> f32 { (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, 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, + r: Option, + state: &mut [f32], +) -> Option { + 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. /// `state` is the node's own slice. Returns None for ops this does not handle. #[inline] -fn fx(op: Op, inp: f32, l: Option, r: Option, state: &mut [f32], sr: f32) -> Option { +#[allow(clippy::too_many_arguments)] +fn fx( + op: Op, + inp: f32, + has_in: bool, + l: Option, + r: Option, + state: &mut [f32], + sr: f32, + seed: u32, +) -> Option { 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 => { let fc = cutoff_hz(l.unwrap_or(9.0)); 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, 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 { + if n.col != cur_col { + cur_col = n.col; + freq = v.freq; + } // A bang voice addresses one column directly and ignores channel tags. match v.only_col { 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 => { 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 => { 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 => { 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 } else { -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::Env => { 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 => { 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 } Op::Fm => { // modulator = left cell (+ the cell above); index in cycles 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 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() } 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 => { out += 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 => { 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; @@ -625,10 +893,17 @@ pub fn eval_post( | Op::Ring | Op::Fm | Op::Mix + | Op::Quant | 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 => { 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); } + + #[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, ®s, 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" + ); + } } diff --git a/src/patch.rs b/src/patch.rs index 50f56b6..e7ad074 100644 --- a/src/patch.rs +++ b/src/patch.rs @@ -291,3 +291,71 @@ mod acid_example { 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 = (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 = (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"); + } + } +} diff --git a/src/tui.rs b/src/tui.rs index a3d718e..9c532fa 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -40,15 +40,19 @@ const HELP: &[&str] = &[ "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)", "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", - " that operator's left param: 2KL5 = filter sweep. tag> = slide (legato channel)", - "ROUTING M mix — adds the cell to its LEFT ! output to master", - "MASTER Z delay (left=time, right=feedback) W chorus (left=rate, right=depth)", + "CONTROL K decay env (decay/floor) T LFO (rate/shape 0sin 1tri 2saw 3ramp 4sq 5s&h)", + " J stepped random (rate/amount) I slew (rise/fall) Y comparator (smooth/thresh)", + " Q quantizer (scale/octaves): transposes the oscillators BELOW it", + " 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 !", "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", - " ( ) width -/+ _ + height -/+ Ctrl+arrows resize by one", + " ( ) width -/+ [ ] height -/+ Ctrl+arrows resize by one", " ? toggle this help · Esc quit", ]; @@ -63,7 +67,9 @@ fn glyph_color(c: char) -> Color { 'O' | 'A' | 'U' | 'V' | 'N' | 'X' => Color::Cyan, 'L' | 'H' | 'B' => Color::Blue, '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, '0'..='9' => Color::Grey, '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)); resized = true; } - KeyCode::Char('_') => { + KeyCode::Char('[') => { grid.resize(ruler_snap(grid.h, -1), grid.w); resized = true; } - KeyCode::Char('+') => { + KeyCode::Char(']') => { grid.resize(ruler_snap(grid.h, 1), grid.w); resized = true; }