Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01d490055e | ||
|
|
e9836c2467 |
Generated
+1
-1
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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')
|
||||||
|
|||||||
@@ -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
@@ -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, ®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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user