4 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
Bendik Aagaard Lynghaug e836ee0e1d chore: Release galdr version 0.3.0
Release / build (aarch64, aarch64, /var/lib/gitea-runner/sccache, aarch64-unknown-linux-gnu) (push) Successful in 1m19s
Release / build (x86_64, ubuntu-latest, /sccache, x86_64-unknown-linux-gnu) (push) Successful in 1m42s
Release / update-aur (push) Successful in 25s
2026-10-08 18:44:45 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 4bf13a2bf0 Channel tags, stems, master chain, FM, K envelope, slide, UDP control, reconnect
Grid grammar:
- a base-36 glyph above a column's top operator tags it with a MIDI channel;
  '>' right of the tag makes the channel mono-legato (60 ms slide)
- operators below a '!' form the stereo master chain: Z delay, W chorus,
  plus any filter/shaper
- X: phase modulation by the cell to the left (and above), index from the right
- K: control envelope; left of an operator it drives that operator's left
  param (2KL5 = 303 filter sweep)
- per-note params: @ velocity, ^ key tracking, ? random; registers now read
  on the digit scale, so MIDI CC and UDP registers have full range

I/O:
- MIDI keeps the channel nibble
- UDP (orca's ';', port 49161): k7 register, *3 bang a column, @34O write
  a glyph, #name load a patch
- --stems n: one mono PipeWire node per channel tag (galdr.ch<tag>),
  sample-aligned with the master; separate nodes because a playback
  stream's AUX ports deliver no data in PipeWire 1.6
- the audio thread reconnects when PipeWire restarts; status line shows it
- patches: ~/hometaping/<date>.galdr by default, .galdr extension, Ctrl+S,
  GALDR_DEBUG=<file> logs events

Examples with render tests: drums, orchestra, acid.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 18:44:40 +02:00
17 changed files with 3127 additions and 364 deletions
Generated
+1 -1
View File
@@ -320,7 +320,7 @@ dependencies = [
[[package]]
name = "galdr"
version = "0.2.4"
version = "0.4.0"
dependencies = [
"anyhow",
"arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "galdr"
version = "0.2.4"
version = "0.4.0"
edition = "2021"
description = "A terminal grid synthesizer: signal flows spatially through single-glyph unit generators, voiced by MIDI, rendered low-latency to PipeWire"
license = "MIT"
+294 -15
View File
@@ -1,14 +1,14 @@
# galdr
A terminal **grid synthesizer**. Signal flows downward through a field of
single-glyph unit generators; every incoming MIDI note voices the whole grid;
the mix is rendered low-latency to PipeWire. It is orca's voice — the same
esoteric, spatial approach to programming, but for making sound instead of
sending events.
single-glyph unit generators; every incoming MIDI note voices the grid (or the
columns tagged with its channel); the mix is rendered low-latency to PipeWire.
It is orca's voice — the same esoteric, spatial approach to programming, but
for making sound instead of sending events.
```
orca-rs ──MIDI/OSC/UDP──▶ galdr ──PipeWire──▶ speakers
compose the pattern voice it (spectrust -p visualizes it)
orca-rs ──MIDI + UDP──▶ galdr ──PipeWire──▶ speakers / stems
compose the pattern voice it (spectrust -p visualizes it)
```
## The grid
@@ -32,11 +32,288 @@ a constant parameter; a lowercase letter `a`–`z` reads MIDI CC (`a` = CC 0).
| `D` | decimate / bitcrush | rate |
| `G` | gain | amount |
| `R` | ring modulator | ratio |
| `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`, `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.
A note plays every column at once.
An untagged column plays on every MIDI channel.
### Params
A param cell is a digit `0`–`9` (constant), a lowercase letter `a`–`z` (a
register: MIDI CC `a` = CC 0, or set over UDP), or one of three per-note
values: `@` velocity, `^` key tracking (note number), `?` a random value rolled
at note-on. All read on the same 0–9 scale, so `@L` is velocity-to-cutoff and
`?O` detunes every hit a little differently.
### Channel tags
A base-36 glyph directly **above** a column's top operator is that column's
MIDI channel: `0`–`9`, `a`–`f` for channels 1–16 (orca's numbering). The
column then only voices notes on that channel. A `>` right of the tag makes
the channel **mono-legato**: a note that overlaps the previous one slides the
running voice to the new pitch over 60 ms without retriggering the envelopes,
the 303 slide. From orca, make the step's note length longer than the gap.
### Acid
`examples/acid.galdr`, a 303 line on channel 1:
```
...0>
...A. saw (U for the square)
.2KL@ K sweeps the cutoff (decay 2, floor open-to-closed); accent = velocity -> resonance
...E1 short amp decay
..3S. overdrive
...!.
```
```
1....2.... column 0 is channel 1 (a filtered saw),
A....O.... column 5 is channel 2 (a plain sine)
L....E....
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
process the summed stereo master, in series, in column order. Any filter or
shaper works there, plus two master-only effects:
| glyph | effect | left / right param |
|---|---|---|
| `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
!
5L lowpass on the whole mix
3Z6 then a 400 ms delay with lots of feedback
.W then a slow chorus for width
```
Editing the grid recompiles and hot-swaps the running program without dropping
audio. There is no embedded scope — run a spectrum analyzer (e.g. `spectrust`)
on the side if you want one.
## A drum kit (one drum per MIDI channel)
A note voices every column tagged with its channel, so give each drum its own
channel and let orca pick the channel per hit. `examples/drums.galdr`:
```
.0..9....a...b.
.A..O9...N...N.
@L5.EX9.6B5.8H.
.E2..E1..E1..E0
2S...!..3S...!.
.!.......!.....
```
- **bass** (tag `0`, MIDI ch 1): saw → lowpass with the cutoff from velocity
(`@`) and resonance 5 → envelope, release 2 → soft clip 2 → out.
- **kick** (tag `9`, ch 10): the left column is a sine into an envelope with
no `!`, it exists only as a modulator. The `X` next to it is a sine at the
note pitch, phase-modulated by that decaying signal with index 9: bright at
the hit, a clean low sine a moment later. Play it a low note (C1–C2).
- **snare** (tag `a`, ch 11): noise → bandpass 6, resonance 5 → short
envelope → soft clip 3. A mid note sets the bandpass body.
- **hats** (tag `b`, ch 12): noise → highpass 8 → envelope with release 0.
Note length from orca is the open/closed control.
From orca, `:` with the channel glyph per drum, e.g. `:93C` for the kick,
`:a3D` snare, `:b5F` hats, `:02C` bass. With `--stems 12` each drum also has
its own `galdr.ch*` node to record.
## An orchestra (the big example)
`examples/orchestra.galdr` is ten sections on ten MIDI channels, each in a
four-column block `[left param | chain | right param | gap]`, so every digit
is a knob. It uses most of the vocabulary: velocity (`@`) into a filter and
into an FM index, key tracking (`^`), per-note random (`?`), two modulator
columns feeding `X`, registers set from orca (`k` and `z`), and a hall on the
master chain (highpass, chorus, delay with feedback from `z`).
```
.0...1...2...3...44..5...6...7...8...9..
.A...A...A2.2U...NO.1U..5U...O7..A...V..
@L2.3L3.1EX@4L1.9HE36B6.6L1..EX3^L?.kL2.
4E5.3E4..1E23E3.1E!.2E2.2E2..0E40E3.7E7.
.!..2S...2S.1S..2G..1F...!....!..!..9G..
1H...!....!..!...!...!...............!..
2W3.....................................
4Zz.....................................
```
| ch | tag | section | how |
|---|---|---|---|
| 1 | `0` | strings | saw, lowpass cutoff from velocity, slow envelope; the hall hangs under its `!` |
| 2 | `1` | low strings | saw, lowpass 3/3, soft clip |
| 3 | `2` | brass | a saw-envelope modulator column drives `X`, FM index from velocity |
| 4 | `3` | horns | narrow pulse, lowpass, soft clip |
| 5 | `4` | flute | breath noise (highpass, short env, gain) beside a sine |
| 6 | `5` | reed | pulse width 1, resonant bandpass, fold |
| 7 | `6` | clarinet | square, lowpass |
| 8 | `7` | timpani | sine-envelope modulator into `X`, long release, play C1 to C2 |
| 9 | `8` | harp | lowpass cutoff key-tracked, resonance random per pluck, pluck envelope |
| 10 | `9` | choir | triangle, lowpass cutoff from register `k`, very slow envelope |
Load it with `galdr examples/orchestra.galdr`, or over UDP with `#orchestra`
once it sits in the patches directory. `;k5` and `;z3` from orca set the choir
brightness and the hall feedback live.
## Stems
`--stems n` adds one mono PipeWire node per channel tag, `galdr.ch0` …
`galdr.ch<n-1>` (named by the tag glyph), each carrying the dry sum of that
channel's voices, sample-aligned with the master. They are not auto-connected:
patch them in a graph tool, or record them with a multitrack recorder such as
`stems`, and master the session later.
## UDP control from orca
galdr listens on UDP port 49161 (orca's `;` operator sends there). One
datagram carries one or more commands, separated by whitespace or `;`:
| command | effect |
|---|---|
| `k7` | set register `k` to 7 of 35 (base-36), read by `k` param cells |
| `*3` | bang column 3: a 5 ms gate on that column alone, no note needed (drums, plucks) |
| `@34O` | write glyph `O` at column 3, row 4 — orca patches the synth itself |
| `@34.` | clear that cell |
| `#verse` | load the patch `verse.galdr` from the patches directory |
Bangs and register sets go straight into the audio thread; grid commands are
applied by the editor and recompiled like a keystroke.
## Patches
A patch is plain text: one line per row, `.` for an empty cell, `#` lines
ignored. `galdr file.galdr` opens one and `Ctrl+S` writes it back. With no
file, galdr works on **today's** patch, `~/hometaping/<YYYY-MM-DD>.galdr`:
opened at start if it exists, written by `Ctrl+S`. That is the same dated
naming as the orca files kept in that directory, so a session's orca pattern
and its galdr voice sort together. `#name` over UDP loads
`<patches dir>/name.galdr` (`--patches` overrides the directory).
Example — a filtered saw with an envelope:
@@ -47,22 +324,24 @@ E ...enveloped by the note gate
! ...to master
```
Editing the grid recompiles and hot-swaps the running program without dropping
audio. There is no embedded scope — run a spectrum analyzer (e.g. `spectrust`)
on the side if you want one.
## Usage
```
galdr [--latency <frames>] [--poly <n>] [--rate <hz>] [--midi-name <s>]
galdr [--latency <frames>] [--poly <n>] [--rate <hz>] [--stems <n>]
[--udp <port>] [--patches <dir>] [--midi-name <s>] [patch.galdr]
```
galdr creates a virtual MIDI input port named **galdr**; connect orca or any
sequencer/DAW to it. `--midi-name` connects to an existing port instead.
Keys: cursor keys move; type a glyph to place it; Backspace clears; `?` toggles
help; Esc quits. Resize the grid with `(` / `)` (width) and `_` / `+` (height)
Keys: cursor keys move; type a glyph to place it; Backspace clears; `Ctrl+S`
saves the patch; `?` toggles help; Esc quits. Resize the grid with `(` / `)` (width) and `_` / `+` (height)
by one ruler division, or Ctrl+arrows by one cell (orca's bindings).
galdr survives a PipeWire restart: the status line shows `PIPEWIRE DOWN —
reconnecting…` and the stream comes back on its own, voices and master-chain
state intact.
Runtime deps: PipeWire and ALSA (MIDI). Built in Rust; reuses the native
PipeWire path from spectrust.
PipeWire path from spectrust. `GALDR_DEBUG=<file>` appends every event that
reaches the engine, handy when orca and galdr disagree.
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Bendik Aagaard Lynghaug <bendik.lynghaug@gmail.com>
pkgname=galdr
pkgver=0.2.4
pkgver=0.4.0
pkgrel=1
pkgdesc="Terminal grid synthesizer: signal flows through single-glyph unit generators, MIDI in, low-latency PipeWire out"
arch=('x86_64' 'aarch64')
+9
View File
@@ -0,0 +1,9 @@
# acid: a 303-style line on MIDI channel 1 (tag 0, '>' = slide on overlapping notes).
# A saw > lowpass whose cutoff is swept by K (decay 2, floor 1); accent = velocity -> resonance (@);
# short amp decay, overdrive. Swap A for U (square), raise K's decay for longer wows.
...0>
...A.
.2KL@
...E1
..3S.
...!.
+7
View File
@@ -0,0 +1,7 @@
# bass (ch 1) | kick (ch 10) | snare (ch 11) | hats (ch 12): one drum per MIDI channel
.0..9....a...b.
.A..O9...N...N.
@L5.EX9.6B5.8H.
.E2..E1..E1..E0
2S...!..3S...!.
.!.......!.....
+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.....!....!..
........!..............
+21
View File
@@ -0,0 +1,21 @@
# galdr orchestra: one section per MIDI channel (tag glyph above each column).
# Sections are 4 columns: [left param | chain | right param | gap]. Tweak the digits;
# @ = velocity, ^ = key tracking, ? = random per note, a-z = registers (MIDI CC / UDP).
# strings ch1 saw > lowpass(cutoff=@ velocity, res 2) > env 4/5 > out; the HALL below it: highpass 1 > chorus 2/3 > delay 4/fb from register z (orca: ;z3)
# low str ch2 saw > lowpass 3/3 > env 3/4 > soft clip 2
# brass ch3 left col = saw>env modulator (no !); right = X FM with index from @ velocity > env 1/2 > soft clip 2
# horns ch4 pulse width 2 > lowpass 4/1 > env 3/3 > soft clip 1
# flute ch5 left = noise breath (highpass 9 > env 1 > gain 2); right = sine > env rel 3
# reed ch6 narrow pulse 1 > bandpass 6/6 > env 2/2 > fold 1
# clarinet ch7 square (width 5) > lowpass 6/1 > env 2/2
# timpani ch8 left = sine>env modulator; right = X index 3 > env 0/4 (play C1-C2)
# harp ch9 saw > lowpass: cutoff ^ key-tracked, resonance ? random per pluck > env 0/3
# choir ch10 triangle > lowpass: cutoff from register k (orca: ;k5), res 2 > slow env 7/7 > gain 9
.0...1...2...3...44..5...6...7...8...9..
.A...A...A2.2U...NO.1U..5U...O7..A...V..
@L2.3L3.1EX@4L1.9HE36B6.6L1..EX3^L?.kL2.
4E5.3E4..1E23E3.1E!.2E2.2E2..0E40E3.7E7.
.!..2S...2S.1S..2G..1F...!....!..!..9G..
1H...!....!..!...!...!...............!..
2W3.....................................
4Zz.....................................
+279 -77
View File
@@ -1,11 +1,28 @@
//! Low-latency PipeWire playback. The RT process callback drains input events,
//! renders the voice mixer, and fills the output buffer. No allocation or locks
//! on this path.
//! Low-latency PipeWire playback. The RT process callbacks drain input events,
//! render the voice mixer and master chain, and fill the output buffers. No
//! allocation or locks on this path.
//!
//! Nodes: `galdr` is the stereo master (after the master chain) and is linked
//! to the default sink by the session manager. With `--stems n`, one extra
//! mono node per MIDI channel tag (`galdr.ch0` .. `galdr.ch<n-1>`, named by
//! the base-36 glyph used in the grid) carries that channel's dry sum. Stem
//! nodes are not auto-connected: patch or record them as you like.
//!
//! All streams of this client are driven on one data-loop thread, one
//! callback each per graph cycle. The first callback of a cycle renders the
//! frames for every output into a shared scratch buffer; the others copy
//! their channel out of it, so master and stems stay sample-aligned.
//!
//! If the daemon goes away (restart, crash) the connection is torn down and
//! rebuilt every second until it is back; the engine state survives.
use std::sync::atomic::{AtomicU32, Ordering};
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::mpsc::Sender;
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use arc_swap::ArcSwap;
use pipewire as pw;
@@ -15,50 +32,180 @@ use rtrb::Consumer;
use spa::param::audio::{AudioFormat, AudioInfoRaw};
use crate::dsp::Program;
use crate::engine::{Engine, Event};
use crate::engine::{Engine, Event, CHANNELS};
pub type Shared = Arc<ArcSwap<Program>>;
#[allow(clippy::too_many_arguments)]
/// Largest cycle PipeWire will ask for (clock.quantum-limit default).
const MAX_FRAMES: usize = 8192;
pub struct Config {
pub sr: u32,
pub latency: u32,
pub poly: usize,
/// Number of per-channel stem nodes (0..=16).
pub stems: usize,
}
/// Start the audio thread. Returns once the first PipeWire connection is up
/// (or with the error if it never comes up). Afterwards the thread keeps the
/// engine and reconnects on its own; `connected` tells the UI which state it
/// is in.
pub fn spawn(
prog: Shared,
events: Consumer<Event>,
voices: Arc<AtomicU32>,
sr: u32,
channels: u8,
latency: u32,
poly: usize,
connected: Arc<AtomicBool>,
cfg: Config,
) -> Result<(), String> {
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<(), String>>();
thread::Builder::new()
.name("galdr-audio".into())
.spawn(move || {
if let Err(e) =
run(prog, events, voices, sr, channels, latency, poly, ready_tx.clone())
{
let _ = ready_tx.send(Err(e));
let render = Rc::new(RefCell::new(Render {
engine: Engine::new(cfg.poly, cfg.sr as f32),
events,
prog,
voices,
latency: cfg.latency as usize,
cycle: 0,
frames: 0,
master: vec![[0.0; 2]; MAX_FRAMES],
stems: vec![[0.0; CHANNELS]; MAX_FRAMES],
}));
let mut first = Some(ready_tx);
loop {
let outcome = run(&render, &cfg, &connected, &mut first);
connected.store(false, Ordering::Relaxed);
match outcome {
Err(e) => {
if let Some(tx) = first.take() {
// never connected: report once and give up
let _ = tx.send(Err(e));
return;
}
eprintln!("\r\n[galdr] PipeWire connection lost ({e}); reconnecting\r");
}
Ok(()) => eprintln!("\r\n[galdr] PipeWire connection closed; reconnecting\r"),
}
thread::sleep(Duration::from_secs(1));
}
})
.map_err(|e| format!("spawn audio thread: {e}"))?;
ready_rx.recv().map_err(|_| "audio thread exited before start".to_string())?
ready_rx
.recv()
.map_err(|_| "audio thread exited before start".to_string())?
}
#[allow(clippy::too_many_arguments)]
fn run(
/// One graph cycle's worth of rendered audio, shared by all stream callbacks.
struct Render {
engine: Engine,
events: Consumer<Event>,
prog: Shared,
mut events: Consumer<Event>,
voices: Arc<AtomicU32>,
sr: u32,
channels: u8,
latency: u32,
poly: usize,
ready: Sender<Result<(), String>>,
latency: usize,
/// Cycle counter: bumped each time a new cycle is rendered.
cycle: u64,
frames: usize,
master: Vec<[f32; 2]>,
stems: Vec<[f32; CHANNELS]>,
}
impl Render {
/// Render `want` frames if this callback is the first of a new cycle.
/// `seen` is the calling stream's last rendered cycle.
fn ensure(&mut self, seen: u64, want: usize) {
if seen != self.cycle {
return; // another stream already rendered this cycle
}
// Render only what the graph asked for this cycle. The mapped
// buffer is sized by clock.quantum-limit (8192 frames by default),
// so filling it whole would do ~32x the work of a 256-frame
// quantum and PipeWire would drop everything past the quantum:
// choppy output and envelopes that race through their stages.
let want = if want > 0 { want } else { self.latency }.min(MAX_FRAMES);
let program = self.prog.load();
// Drain all pending input events once per cycle.
while let Ok(ev) = self.events.pop() {
self.engine.apply(ev);
}
for f in 0..want {
self.master[f] = self.engine.render(&program);
self.stems[f] = self.engine.stems;
}
self.frames = want;
self.cycle += 1;
self.voices
.store(self.engine.active_voices() as u32, Ordering::Relaxed);
}
}
fn format_pod(sr: u32, positions: &[u32]) -> Result<Vec<u8>, String> {
let mut audio_info = AudioInfoRaw::new();
audio_info.set_format(AudioFormat::F32LE);
audio_info.set_rate(sr);
audio_info.set_channels(positions.len() as u32);
let mut pos = [0u32; spa::sys::SPA_AUDIO_MAX_CHANNELS as usize];
pos[..positions.len()].copy_from_slice(positions);
audio_info.set_position(pos);
let obj = spa::pod::Object {
type_: spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
id: spa::param::ParamType::EnumFormat.as_raw(),
properties: audio_info.into(),
};
Ok(spa::pod::serialize::PodSerializer::serialize(
std::io::Cursor::new(Vec::new()),
&spa::pod::Value::Object(obj),
)
.map_err(|e| format!("pod: {e}"))?
.0
.into_inner())
}
/// The base-36 glyph for a channel index, as used for grid tags.
pub fn glyph(ch: usize) -> char {
b"0123456789abcdefghijklmnopqrstuvwxyz"[ch % 36] as char
}
/// One connection to the daemon: build the streams, run the loop until the
/// connection fails (core error, stream error), then return so the caller
/// can retry with the same `Render`.
fn run(
render: &Rc<RefCell<Render>>,
cfg: &Config,
connected: &Arc<AtomicBool>,
ready: &mut Option<Sender<Result<(), String>>>,
) -> Result<(), String> {
pw::init();
let mainloop = pw::main_loop::MainLoopRc::new(None).map_err(|e| format!("mainloop: {e}"))?;
let context = pw::context::ContextRc::new(&mainloop, None).map_err(|e| format!("context: {e}"))?;
let core = context.connect_rc(None).map_err(|e| format!("connect PipeWire: {e}"))?;
let context =
pw::context::ContextRc::new(&mainloop, None).map_err(|e| format!("context: {e}"))?;
let core = context
.connect_rc(None)
.map_err(|e| format!("connect PipeWire: {e}"))?;
let sr = cfg.sr;
let latency = cfg.latency;
let stems = cfg.stems.min(CHANNELS);
// The daemon going away surfaces as an error on the core object itself
// (id 0). Quit the loop; the caller reconnects.
let ml = mainloop.clone();
let _core_listener = core
.add_listener_local()
.error(move |id, _seq, res, msg| {
if id == 0 {
eprintln!("\r\n[galdr] PipeWire core error {res}: {msg}\r");
ml.quit();
}
})
.register();
// Keep every stream and listener alive for the connection's lifetime.
let mut streams = Vec::new();
let mut listeners = Vec::new();
// ---- master: stereo, auto-connected ----
let props = properties! {
*pw::keys::MEDIA_TYPE => "Audio",
*pw::keys::MEDIA_CATEGORY => "Playback",
@@ -66,71 +213,56 @@ fn run(
*pw::keys::NODE_NAME => "galdr",
*pw::keys::NODE_LATENCY => format!("{latency}/{sr}"),
};
let stream = pw::stream::StreamBox::new(&core, "galdr", props).map_err(|e| format!("stream: {e}"))?;
let ch = channels.max(1) as usize;
let mut engine = Engine::new(poly, sr as f32);
let _listener = stream
let stream =
pw::stream::StreamBox::new(&core, "galdr", props).map_err(|e| format!("stream: {e}"))?;
let r = render.clone();
let mut seen: u64 = render.borrow().cycle;
let ml = mainloop.clone();
let listener = stream
.add_local_listener_with_user_data(())
.state_changed(move |_, _, _old, new| {
if let pw::stream::StreamState::Error(e) = new {
eprintln!("\r\n[galdr] stream error: {e}\r");
ml.quit();
}
})
.process(move |stream, _| {
let Some(mut buffer) = stream.dequeue_buffer() else { return };
let Some(mut buffer) = stream.dequeue_buffer() else {
return;
};
let requested = buffer.requested() as usize;
let datas = buffer.datas_mut();
let Some(data) = datas.first_mut() else { return };
let stride = ch * 4;
let program = prog.load();
// Drain all pending input events once per buffer.
while let Ok(ev) = events.pop() {
engine.apply(ev);
}
// Render only what the graph asked for this cycle. The mapped
// buffer is sized by clock.quantum-limit (8192 frames by default),
// so filling it whole would do ~32x the work of a 256-frame
// quantum and PipeWire would drop everything past the quantum:
// choppy output and envelopes that race through their stages.
let want = if requested > 0 { requested } else { latency as usize };
let mut n_frames = 0usize;
let Some(data) = datas.first_mut() else {
return;
};
let stride = 2 * 4;
let mut r = r.borrow_mut();
r.ensure(seen, requested);
seen = r.cycle;
let mut n = 0usize;
if let Some(slice) = data.data() {
n_frames = want.min(slice.len() / stride);
for f in 0..n_frames {
let smp = engine.render(&program);
let bytes = smp.to_le_bytes();
for c in 0..ch {
let o = f * stride + c * 4;
slice[o..o + 4].copy_from_slice(&bytes);
}
n = r.frames.min(slice.len() / stride);
for (f, m) in r.master[..n].iter().enumerate() {
let o = f * stride;
slice[o..o + 4].copy_from_slice(&m[0].to_le_bytes());
slice[o + 4..o + 8].copy_from_slice(&m[1].to_le_bytes());
}
}
let chunk = data.chunk_mut();
*chunk.offset_mut() = 0;
*chunk.stride_mut() = stride as _;
*chunk.size_mut() = (stride * n_frames) as _;
voices.store(engine.active_voices() as u32, Ordering::Relaxed);
*chunk.size_mut() = (stride * n) as _;
})
.register()
.map_err(|e| format!("listener: {e}"))?;
let mut audio_info = AudioInfoRaw::new();
audio_info.set_format(AudioFormat::F32LE);
audio_info.set_rate(sr);
audio_info.set_channels(channels as u32);
let obj = spa::pod::Object {
type_: spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
id: spa::param::ParamType::EnumFormat.as_raw(),
properties: audio_info.into(),
};
let values: Vec<u8> = spa::pod::serialize::PodSerializer::serialize(
std::io::Cursor::new(Vec::new()),
&spa::pod::Value::Object(obj),
)
.map_err(|e| format!("pod: {e}"))?
.0
.into_inner();
let values = format_pod(
sr,
&[
spa::sys::SPA_AUDIO_CHANNEL_FL,
spa::sys::SPA_AUDIO_CHANNEL_FR,
],
)?;
let mut params = [Pod::from_bytes(&values).ok_or("bad pod")?];
stream
.connect(
spa::utils::Direction::Output,
@@ -141,8 +273,78 @@ fn run(
&mut params,
)
.map_err(|e| format!("connect stream: {e}"))?;
listeners.push(listener);
streams.push(stream);
let _ = ready.send(Ok(()));
// ---- stems: mono, one node per channel tag, not auto-connected ----
for s in 0..stems {
let name = format!("galdr.ch{}", glyph(s));
let props = properties! {
*pw::keys::MEDIA_TYPE => "Audio",
*pw::keys::MEDIA_CATEGORY => "Playback",
*pw::keys::MEDIA_ROLE => "Music",
*pw::keys::NODE_NAME => name.as_str(),
*pw::keys::NODE_DESCRIPTION => format!("galdr stem: MIDI channel tag {}", glyph(s)),
*pw::keys::NODE_AUTOCONNECT => "false",
*pw::keys::NODE_LATENCY => format!("{latency}/{sr}"),
};
let stream = pw::stream::StreamBox::new(&core, &name, props)
.map_err(|e| format!("stem stream: {e}"))?;
let r = render.clone();
let mut seen: u64 = render.borrow().cycle;
let ml = mainloop.clone();
let listener = stream
.add_local_listener_with_user_data(())
.state_changed(move |_, _, _old, new| {
if let pw::stream::StreamState::Error(e) = new {
eprintln!("\r\n[galdr] stem stream error: {e}\r");
ml.quit();
}
})
.process(move |stream, _| {
let Some(mut buffer) = stream.dequeue_buffer() else {
return;
};
let requested = buffer.requested() as usize;
let datas = buffer.datas_mut();
let Some(data) = datas.first_mut() else {
return;
};
let mut r = r.borrow_mut();
r.ensure(seen, requested);
seen = r.cycle;
let mut n = 0usize;
if let Some(slice) = data.data() {
n = r.frames.min(slice.len() / 4);
for (f, st) in r.stems[..n].iter().enumerate() {
slice[f * 4..f * 4 + 4].copy_from_slice(&st[s].to_le_bytes());
}
}
let chunk = data.chunk_mut();
*chunk.offset_mut() = 0;
*chunk.stride_mut() = 4;
*chunk.size_mut() = (4 * n) as _;
})
.register()
.map_err(|e| format!("stem listener: {e}"))?;
let values = format_pod(sr, &[spa::sys::SPA_AUDIO_CHANNEL_MONO])?;
let mut params = [Pod::from_bytes(&values).ok_or("bad pod")?];
stream
.connect(
spa::utils::Direction::Output,
None,
pw::stream::StreamFlags::MAP_BUFFERS | pw::stream::StreamFlags::RT_PROCESS,
&mut params,
)
.map_err(|e| format!("connect stem stream: {e}"))?;
listeners.push(listener);
streams.push(stream);
}
connected.store(true, Ordering::Relaxed);
if let Some(tx) = ready.take() {
let _ = tx.send(Ok(()));
}
mainloop.run();
Ok(())
}
+1176 -129
View File
File diff suppressed because it is too large Load Diff
+355 -47
View File
@@ -1,12 +1,36 @@
//! Voice allocation and the polyphonic mixer. Owned by the audio thread.
//! Voice allocation, the polyphonic mixer, per-channel stems and the master
//! chain. Owned by the audio thread.
use crate::dsp::{eval_voice, Program, Voice};
use crate::dsp::{eval_post, eval_voice, Program, Voice, BUF_SECONDS, MAX_BUFS, MAX_STATE};
#[derive(Clone, Copy)]
pub const CHANNELS: usize = 16;
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Event {
NoteOn { note: u8, vel: u8 },
NoteOff { note: u8 },
Cc { idx: u8, val: u8 },
NoteOn {
ch: u8,
note: u8,
vel: u8,
},
NoteOff {
ch: u8,
note: u8,
},
/// MIDI control change -> register (0..=127)
Cc {
idx: u8,
val: u8,
},
/// Register set from UDP (0.0..=1.0)
Reg {
idx: u8,
val: f32,
},
/// Retrigger one column, no note: a short gate at the column channel's
/// last pitch.
Bang {
col: u8,
},
}
pub struct Engine {
@@ -14,15 +38,35 @@ pub struct Engine {
regs: Vec<f32>,
sr: f32,
clock: u64,
last_freq: [f32; CHANNELS],
/// Dry per-MIDI-channel sums of the last rendered frame.
pub stems: [f32; CHANNELS],
post_state: Vec<f32>,
bufs: Vec<Vec<f32>>,
buf_len: usize,
buf_pos: usize,
/// Copied from the program each render: which channels slide.
legato: [bool; CHANNELS],
/// Per-sample glide coefficient (~60 ms).
glide: f32,
}
impl Engine {
pub fn new(poly: usize, sr: f32) -> Self {
let buf_len = (sr * BUF_SECONDS) as usize;
Engine {
voices: (0..poly).map(|_| Voice::new()).collect(),
regs: vec![0.0; 128],
sr,
clock: 0,
last_freq: [220.0; CHANNELS],
stems: [0.0; CHANNELS],
post_state: vec![0.0; MAX_STATE],
bufs: (0..MAX_BUFS).map(|_| vec![0.0; 2 * buf_len]).collect(),
buf_len,
buf_pos: 0,
legato: [false; CHANNELS],
glide: 1.0 / (0.06 * sr),
}
}
@@ -30,65 +74,158 @@ impl Engine {
self.voices.iter().filter(|v| v.active).count()
}
/// A free voice, else the oldest one (stolen).
fn allocate(&mut self) -> usize {
self.clock += 1;
self.voices
.iter()
.position(|v| !v.active)
.unwrap_or_else(|| {
let mut best = 0;
for i in 1..self.voices.len() {
if self.voices[i].age < self.voices[best].age {
best = i;
}
}
best
})
}
fn start(&mut self, idx: usize, ch: u8, note: u8, freq: f32, vel: f32) {
let clock = self.clock;
let v = &mut self.voices[idx];
v.active = true;
v.ch = ch;
v.note = note;
v.freq = freq;
v.gate = 1.0;
v.vel = vel;
v.age = clock;
v.only_col = None;
v.trig_left = 0;
v.resolve_ch = false;
v.t = 0;
v.target = freq;
v.rand = v.next_rand();
// fresh phases/env so a stolen voice doesn't click from old state
for x in v.state.iter_mut() {
*x = 0.0;
}
v.env_level = 0.0;
}
pub fn apply(&mut self, ev: Event) {
match ev {
Event::NoteOn { note, vel } => {
self.clock += 1;
// Prefer a free voice, else steal the oldest.
let idx = self
.voices
.iter()
.position(|v| !v.active)
.unwrap_or_else(|| {
let mut best = 0;
for i in 1..self.voices.len() {
if self.voices[i].age < self.voices[best].age {
best = i;
}
}
best
});
let v = &mut self.voices[idx];
v.active = true;
v.note = note;
v.freq = 440.0 * 2f32.powf((note as f32 - 69.0) / 12.0);
v.gate = 1.0;
v.vel = vel as f32 / 127.0;
v.age = self.clock;
// fresh phases/env so a stolen voice doesn't click from old state
for x in v.state.iter_mut() {
*x = 0.0;
Event::NoteOn { ch, note, vel } => {
let ch = ch % CHANNELS as u8;
let freq = 440.0 * 2f32.powf((note as f32 - 69.0) / 12.0);
self.last_freq[ch as usize] = freq;
// Slide: on a legato channel an overlapping note retunes the
// held voice instead of starting one (envelopes keep running).
if self.legato[ch as usize] {
if let Some(v) = self
.voices
.iter_mut()
.find(|v| v.active && v.ch == ch && v.gate > 0.5 && v.only_col.is_none())
{
v.target = freq;
v.note = note;
return;
}
}
v.env_level = 0.0;
let idx = self.allocate();
self.start(idx, ch, note, freq, vel as f32 / 127.0);
}
Event::NoteOff { note } => {
Event::NoteOff { ch, note } => {
let ch = ch % CHANNELS as u8;
for v in self.voices.iter_mut() {
if v.active && v.note == note {
if v.active && v.note == note && v.ch == ch && v.only_col.is_none() {
v.gate = 0.0;
}
}
}
Event::Cc { idx, val } => {
self.regs[idx as usize] = val as f32 / 127.0;
self.regs[idx as usize & 127] = val as f32 / 127.0;
}
Event::Reg { idx, val } => {
self.regs[idx as usize & 127] = val.clamp(0.0, 1.0);
}
Event::Bang { col } => {
// A bang voice plays one column at the most recent pitch
// heard, with a 5 ms gate. Its channel (hence its stem) is
// resolved from the column's tag at the next render.
let freq = self.last_freq.iter().cloned().fold(0.0, f32::max).max(55.0);
let idx = self.allocate();
let note = (69.0 + 12.0 * (freq / 440.0).log2())
.round()
.clamp(0.0, 127.0) as u8;
self.start(idx, 0, note, freq, 1.0);
let v = &mut self.voices[idx];
v.only_col = Some(col);
v.trig_left = (self.sr * 0.005) as u32;
v.resolve_ch = true;
}
}
}
/// Render one stereo frame of the master (after the master chain) and
/// refresh `stems` with the dry per-channel sums.
#[inline]
pub fn render(&mut self, prog: &Program) -> f32 {
pub fn render(&mut self, prog: &Program) -> [f32; 2] {
let mut mix = 0.0f32;
self.stems = [0.0; CHANNELS];
self.legato = prog.legato;
for v in self.voices.iter_mut() {
if !v.active {
continue;
}
mix += eval_voice(v, prog, &self.regs, self.sr);
if v.target != v.freq {
v.freq += (v.target - v.freq) * self.glide;
if (v.target - v.freq).abs() < 0.01 {
v.freq = v.target;
}
}
if v.resolve_ch {
v.resolve_ch = false;
if let Some(col) = v.only_col {
v.ch = prog
.nodes
.iter()
.find(|n| n.col == col)
.and_then(|n| n.ch)
.unwrap_or(0);
}
}
let y = eval_voice(v, prog, &self.regs, self.sr);
mix += y;
self.stems[v.ch as usize] += y;
if v.trig_left > 0 {
v.trig_left -= 1;
if v.trig_left == 0 {
v.gate = 0.0;
}
}
// Reap voices whose gate is closed and envelope has decayed.
if v.gate < 0.5 && v.env_level < 1e-4 {
v.active = false;
}
}
for s in self.stems.iter_mut() {
*s = (*s * 0.3).tanh();
}
let m = mix * 0.3;
let y = eval_post(
prog,
[m, m],
&self.regs,
&mut self.post_state,
&mut self.bufs,
self.buf_len,
&mut self.buf_pos,
self.sr,
);
// Gentle headroom limiter.
(mix * 0.3).tanh()
[y[0].tanh(), y[1].tanh()]
}
}
@@ -121,7 +258,11 @@ mod tests {
#[test]
fn noteon_maps_a4_to_440() {
let mut e = Engine::new(4, 48000.0);
e.apply(Event::NoteOn { note: 69, vel: 100 });
e.apply(Event::NoteOn {
ch: 0,
note: 69,
vel: 100,
});
assert!((e.first_freq().unwrap() - 440.0).abs() < 0.01);
assert_eq!(e.active_voices(), 1);
}
@@ -130,10 +271,31 @@ mod tests {
fn noteoff_reaps_gated_voice() {
let p = compile(&grid_from("A\n!")); // no envelope -> gate reaps immediately
let mut e = Engine::new(4, 48000.0);
e.apply(Event::NoteOn { note: 60, vel: 100 });
e.apply(Event::NoteOn {
ch: 0,
note: 60,
vel: 100,
});
e.render(&p);
assert_eq!(e.active_voices(), 1);
e.apply(Event::NoteOff { note: 60 });
e.apply(Event::NoteOff { ch: 0, note: 60 });
e.render(&p);
assert_eq!(e.active_voices(), 0);
}
#[test]
fn noteoff_matches_channel() {
let p = compile(&grid_from("A\n!"));
let mut e = Engine::new(4, 48000.0);
e.apply(Event::NoteOn {
ch: 1,
note: 60,
vel: 100,
});
e.apply(Event::NoteOff { ch: 2, note: 60 }); // wrong channel: ignored
e.render(&p);
assert_eq!(e.active_voices(), 1);
e.apply(Event::NoteOff { ch: 1, note: 60 });
e.render(&p);
assert_eq!(e.active_voices(), 0);
}
@@ -141,17 +303,27 @@ mod tests {
#[test]
fn monophonic_steals_the_oldest_voice() {
let mut e = Engine::new(1, 48000.0);
e.apply(Event::NoteOn { note: 60, vel: 100 });
e.apply(Event::NoteOn { note: 72, vel: 100 });
e.apply(Event::NoteOn {
ch: 0,
note: 60,
vel: 100,
});
e.apply(Event::NoteOn {
ch: 0,
note: 72,
vel: 100,
});
assert_eq!(e.active_voices(), 1);
assert!((e.first_freq().unwrap() - 440.0 * 2f32.powf((72.0 - 69.0) / 12.0)).abs() < 0.01);
}
#[test]
fn cc_writes_register() {
fn cc_and_udp_write_registers() {
let mut e = Engine::new(2, 48000.0);
e.apply(Event::Cc { idx: 0, val: 127 });
assert!((e.reg(0) - 1.0).abs() < 1e-6);
e.apply(Event::Reg { idx: 1, val: 0.5 });
assert!((e.reg(1) - 0.5).abs() < 1e-6);
}
#[test]
@@ -159,7 +331,143 @@ mod tests {
// many stacked saws would exceed 1.0 without the limiter
let p = compile(&grid_from("A.A.A.A\n!.!.!.!"));
let mut e = Engine::new(4, 48000.0);
e.apply(Event::NoteOn { note: 60, vel: 127 });
assert!(e.render(&p).abs() <= 1.0);
e.apply(Event::NoteOn {
ch: 0,
note: 60,
vel: 127,
});
let y = e.render(&p);
assert!(y[0].abs() <= 1.0 && y[1].abs() <= 1.0);
}
#[test]
fn stems_follow_the_note_channel() {
let p = compile(&grid_from("A\n!"));
let mut e = Engine::new(4, 48000.0);
e.apply(Event::NoteOn {
ch: 3,
note: 60,
vel: 127,
});
e.render(&p);
assert!(e.stems[3].abs() > 0.1);
assert!(e.stems.iter().enumerate().all(|(i, s)| i == 3 || *s == 0.0));
}
#[test]
fn tagged_columns_split_by_channel() {
// col 0 -> channel 1 ('1'), col 2 -> channel 2 ('2')
let p = compile(&grid_from("1.2\nA.A\n!.!"));
let mut e = Engine::new(4, 48000.0);
e.apply(Event::NoteOn {
ch: 1,
note: 60,
vel: 127,
});
let y = e.render(&p);
assert!(y[0].abs() > 0.1);
assert!(e.stems[1].abs() > 0.1 && e.stems[2] == 0.0);
let mut e = Engine::new(4, 48000.0);
e.apply(Event::NoteOn {
ch: 5,
note: 60,
vel: 127,
});
assert_eq!(e.render(&p), [0.0, 0.0]);
}
#[test]
fn bang_plays_one_column_briefly() {
let p = compile(&grid_from("A.A\n!.!"));
let mut e = Engine::new(4, 48000.0);
e.apply(Event::Bang { col: 2 });
let y = e.render(&p);
assert!(y[0].abs() > 0.1);
for _ in 0..300 {
e.render(&p);
}
assert_eq!(e.active_voices(), 0, "bang voice gates off after 5 ms");
}
#[test]
fn bang_lands_in_the_tagged_stem() {
let p = compile(&grid_from("3\nA\n!"));
let mut e = Engine::new(4, 48000.0);
e.apply(Event::Bang { col: 0 });
e.render(&p);
assert!(e.stems[3].abs() > 0.1);
assert_eq!(e.stems[0], 0.0);
}
#[test]
fn legato_channel_slides_instead_of_retriggering() {
let p = compile(&grid_from("1>\nA.\n!."));
let mut e = Engine::new(4, 48000.0);
e.apply(Event::NoteOn {
ch: 1,
note: 60,
vel: 100,
});
e.render(&p);
e.apply(Event::NoteOn {
ch: 1,
note: 72,
vel: 100,
}); // overlapping: slide
assert_eq!(e.active_voices(), 1);
let f0 = e.first_freq().unwrap();
for _ in 0..24000 {
e.render(&p);
}
let f1 = e.first_freq().unwrap();
assert!(
f0 < 300.0 && (f1 - 523.25).abs() < 0.5,
"glided {f0} -> {f1}"
);
// the old note's off is ignored, the new note's off releases
e.apply(Event::NoteOff { ch: 1, note: 60 });
e.render(&p);
assert_eq!(e.active_voices(), 1);
e.apply(Event::NoteOff { ch: 1, note: 72 });
e.render(&p);
assert_eq!(e.active_voices(), 0);
// without the flag the same channel is polyphonic
let p2 = compile(&grid_from("1\nA\n!"));
let mut e = Engine::new(4, 48000.0);
e.apply(Event::NoteOn {
ch: 1,
note: 60,
vel: 100,
});
e.render(&p2);
e.apply(Event::NoteOn {
ch: 1,
note: 72,
vel: 100,
});
e.render(&p2);
assert_eq!(e.active_voices(), 2);
}
#[test]
fn master_chain_runs_on_the_mix() {
// post gain x2 should double the output of a plain saw
let plain = compile(&grid_from(".A\n.!"));
let gained = compile(&grid_from(".A\n.!\n9G"));
let mut a = Engine::new(1, 48000.0);
let mut b = Engine::new(1, 48000.0);
a.apply(Event::NoteOn {
ch: 0,
note: 60,
vel: 64,
});
b.apply(Event::NoteOn {
ch: 0,
note: 60,
vel: 64,
});
let ya = a.render(&plain)[0].atanh();
let yb = b.render(&gained)[0].atanh();
assert!((yb - 2.0 * ya).abs() < 1e-4);
}
}
+67 -3
View File
@@ -18,7 +18,11 @@ pub struct Grid {
impl Grid {
pub fn new() -> Self {
Grid { w: DEFAULT_W, h: DEFAULT_H, cells: vec!['.'; SLOTS] }
Grid {
w: DEFAULT_W,
h: DEFAULT_H,
cells: vec!['.'; SLOTS],
}
}
#[inline]
@@ -41,15 +45,58 @@ impl Grid {
self.h = nh.clamp(1, MAX_H);
self.w = nw.clamp(1, MAX_W);
}
/// Plain-text patch format: one line per row, `.` for an empty cell.
pub fn to_text(&self) -> String {
let mut s = String::with_capacity((self.w + 1) * self.h);
for row in 0..self.h {
for col in 0..self.w {
s.push(self.get(col, row));
}
s.push('\n');
}
s
}
/// Parse the text format; the grid takes the text's size (clamped).
pub fn from_text(text: &str) -> Self {
let lines: Vec<&str> = text.lines().filter(|l| !l.starts_with('#')).collect();
let h = lines.len().clamp(1, MAX_H);
let w = lines
.iter()
.map(|l| l.chars().count())
.max()
.unwrap_or(1)
.clamp(1, MAX_W);
let mut g = Grid {
w,
h,
cells: vec!['.'; SLOTS],
};
for (r, line) in lines.iter().enumerate().take(h) {
for (c, ch) in line.chars().enumerate().take(w) {
g.set(c, r, if ch == ' ' { '.' } else { ch });
}
}
g
}
}
/// orca-c's ruler snap: resize by one ruler division, snapped to a
/// ruler-aligned size (1, 9, 17, ...) rather than a plain +/-1.
pub fn ruler_snap(size: usize, delta_rulers: isize) -> usize {
if size == 0 {
return if delta_rulers > 0 { RULER * delta_rulers as usize } else { 1 };
return if delta_rulers > 0 {
RULER * delta_rulers as usize
} else {
1
};
}
let size = if delta_rulers < 0 { size + RULER - 1 } else { size };
let size = if delta_rulers < 0 {
size + RULER - 1
} else {
size
};
let n = ((size as isize - 1) / RULER as isize + delta_rulers).max(0);
(RULER as isize * n + 1) as usize
}
@@ -82,6 +129,23 @@ mod tests {
assert_eq!(g.get(g.w, 0), '.'); // just past the right edge
}
#[test]
fn text_roundtrip() {
let mut g = Grid::new();
g.resize(3, 4);
g.set(1, 0, 'O');
g.set(1, 2, '!');
let t = g.to_text();
assert_eq!(t, ".O..\n....\n.!..\n");
let g2 = Grid::from_text(&t);
assert_eq!((g2.w, g2.h), (4, 3));
assert_eq!(g2.get(1, 2), '!');
// spaces read as empty, comment lines are skipped
let g3 = Grid::from_text("# patch\nA \n!.\n");
assert_eq!((g3.w, g3.h), (2, 2));
assert_eq!(g3.get(1, 0), '.');
}
#[test]
fn set_get_roundtrip_and_resize_preserves() {
let mut g = Grid::new();
+155 -31
View File
@@ -1,17 +1,21 @@
//! galdr - a terminal grid synthesizer.
//!
//! Signal flows downward through single-glyph unit generators; each MIDI note
//! voices the whole grid; the mix is rendered low-latency to PipeWire. Pairs
//! with orca (MIDI/OSC/UDP out) and spectrust (visualize the output).
//! voices the whole grid (or the columns tagged with its channel); the mix is
//! rendered low-latency to PipeWire. Pairs with orca (MIDI + UDP out) and
//! spectrust (visualize the output).
mod audio;
mod dsp;
mod engine;
mod grid;
mod midi;
mod patch;
mod tui;
mod udp;
use std::sync::atomic::AtomicU32;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU32};
use std::sync::Arc;
use arc_swap::ArcSwap;
@@ -22,32 +26,54 @@ use crate::grid::Grid;
const HELP: &str = "\
galdr - terminal grid synthesizer
Usage: galdr [options]
Usage: galdr [options] [patch.galdr]
Options:
--latency <frames> PipeWire quantum; lower = lower latency (default: 256)
--poly <n> Number of voices (default: 8)
--rate <hz> Sample rate (default: 48000)
--stems <n> Add n mono PipeWire nodes galdr.ch0..galdr.ch<n-1>, one per
channel tag, carrying that channel's dry sum (default: 0)
--udp <port> Listen for orca's `;` UDP strings (default: 49161;
0 disables)
--patches <dir> Patch directory for `#name` loads and dated saves
(default: ~/hometaping)
--midi-name <s> Connect to the input port matching <s> instead of
creating the virtual 'galdr' port
-h, --help Print this message
Grid glyphs are UPPERCASE operators; digits 0-9 are constants; lowercase a-z
read MIDI CC (a = CC 0). Signal flows down (input = the cell above); the left
and right neighbours are params.
sources O sine A saw U pulse V tri N noise (ratio/width <- left)
Grid glyphs are UPPERCASE operators. Signal flows down (input = the cell
above); the left and right neighbours are params: digit 0-9 constant, a-z a
register (MIDI CC a = 0, or UDP), @ velocity, ^ key tracking, ? per-note random.
A base-36 glyph above a column's top operator tags the column with a MIDI
channel (0-9 a-f = 1-16); untagged columns play every channel. A '>' right of
the tag makes the channel mono-legato: overlapping notes slide (60 ms).
sources O sine A saw U pulse V tri N noise X FM (mod <- left cell, index <- right)
filters L lowpass H highpass B bandpass (cutoff <- left, resonance <- right)
shapers S soft/tanh(drive<-left) C clip(<-left) F fold(<-left) D decimate(<-left)
amp/mod E envelope(atk<-left, rel<-right) G gain(<-left) R ringmod(ratio<-left)
control K decay envelope (decay<-left, floor<-right): put it LEFT of an operator
and it drives that operator's left param (2KL5 = 303 filter sweep)
routing M mix (adds the cell to its left) ! output to master
master operators BELOW a ! process the stereo master in series:
Z delay(time<-left, fb<-right) W chorus(rate<-left, depth<-right) + filters/shapers
UDP commands (one or more per datagram, split on whitespace/';'):
k7 set register k to 7/35 *3 bang column 3 (5 ms gate, no note)
@34O write O at column 3, row 4 #verse load <patches>/verse.galdr
With no patch file, galdr opens ~/hometaping/<date>.galdr if it exists and
Ctrl+S writes it (the same dated naming as the orca files kept there).
";
fn main() -> anyhow::Result<()> {
let mut latency: u32 = 256;
let mut poly: usize = 8;
let mut rate: u32 = 48000;
let channels: u8 = 2;
let mut stems: usize = 0;
let mut udp_port: u16 = 49161;
let mut patches: Option<PathBuf> = None;
let mut midi_name: Option<String> = None;
let mut file: Option<PathBuf> = None;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
@@ -55,20 +81,137 @@ fn main() -> anyhow::Result<()> {
"--latency" => latency = args.next().and_then(|v| v.parse().ok()).unwrap_or(latency),
"--poly" => poly = args.next().and_then(|v| v.parse().ok()).unwrap_or(poly),
"--rate" => rate = args.next().and_then(|v| v.parse().ok()).unwrap_or(rate),
"--stems" => stems = args.next().and_then(|v| v.parse().ok()).unwrap_or(stems),
"--udp" => udp_port = args.next().and_then(|v| v.parse().ok()).unwrap_or(udp_port),
"--patches" => patches = args.next().map(PathBuf::from),
"--midi-name" => midi_name = args.next(),
"-h" | "--help" => {
print!("{HELP}");
return Ok(());
}
other if !other.starts_with('-') && file.is_none() => file = Some(PathBuf::from(other)),
other => {
eprintln!("unknown option: {other}\n\n{HELP}");
std::process::exit(2);
}
}
}
let stems = stems.min(engine::CHANNELS);
let patches = patches.unwrap_or_else(patch::default_dir);
// A default patch so the first run makes sound: a plain sine->env voice and
// a filtered saw voice.
// No file: today's patch (~/hometaping/<date>.galdr) if it exists, so the
// session continues where it left off and Ctrl+S keeps writing it.
if file.is_none() {
let today = patch::dated(&patches, &patch::today());
if today.exists() {
file = Some(today);
}
}
let grid = match &file {
Some(p) if p.exists() => patch::load(p)?,
_ => default_grid(),
};
let prog = Arc::new(ArcSwap::from_pointee(compile(&grid)));
let voices = Arc::new(AtomicU32::new(0));
let connected = Arc::new(AtomicBool::new(false));
// The audio thread drains one lock-free ring. MIDI (a midir callback) and
// UDP (its own thread) are separate producers, so each gets its own ring
// and a small pump thread merges them; no callback ever blocks.
let (prod_ev, cons_ev) = rtrb::RingBuffer::new(1024);
let (prod_midi, cons_midi) = rtrb::RingBuffer::new(1024);
let (prod_udp, cons_udp) = rtrb::RingBuffer::new(1024);
let (ctrl_tx, ctrl_rx) = std::sync::mpsc::channel();
audio::spawn(
prog.clone(),
cons_ev,
voices.clone(),
connected.clone(),
audio::Config {
sr: rate,
latency,
poly,
stems,
},
)
.map_err(|e| anyhow::anyhow!("audio: {e}"))?;
spawn_pump(prod_ev, vec![cons_midi, cons_udp])?;
// Keep the MIDI connection alive for the program's lifetime.
let _midi = match midi::connect(prod_midi, midi_name) {
Ok(conn) => Some(conn),
Err(e) => {
eprintln!("warning: MIDI unavailable ({e}); running without note input");
None
}
};
let udp = if udp_port > 0 {
match udp::spawn(udp_port, prod_udp, ctrl_tx) {
Ok(()) => Some(udp_port),
Err(e) => {
eprintln!("warning: UDP unavailable ({e}); running without orca control");
None
}
}
} else {
None
};
tui::run(
tui::Session {
prog,
voices,
connected,
latency,
ctrl: ctrl_rx,
patches,
file,
udp_port: udp,
},
grid,
)?;
Ok(())
}
fn spawn_pump(
mut out: rtrb::Producer<engine::Event>,
mut inputs: Vec<rtrb::Consumer<engine::Event>>,
) -> std::io::Result<()> {
// GALDR_DEBUG=<file> appends every event that reaches the engine.
let mut log = std::env::var_os("GALDR_DEBUG").and_then(|p| {
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(p)
.ok()
});
std::thread::Builder::new()
.name("galdr-pump".into())
.spawn(move || loop {
let mut idle = true;
for c in inputs.iter_mut() {
while let Ok(ev) = c.pop() {
if let Some(f) = log.as_mut() {
use std::io::Write;
let _ = writeln!(f, "{ev:?}");
}
let _ = out.push(ev);
idle = false;
}
}
if idle {
std::thread::sleep(std::time::Duration::from_micros(500));
}
})?;
Ok(())
}
/// A default patch so the first run makes sound: a plain sine->env voice and
/// a filtered saw voice.
fn default_grid() -> Grid {
let mut grid = Grid::new();
grid.set(0, 0, 'O'); // sine
grid.set(0, 1, 'E'); // -> envelope
@@ -78,24 +221,5 @@ fn main() -> anyhow::Result<()> {
grid.set(2, 1, 'L'); // -> lowpass
grid.set(2, 2, 'E'); // -> envelope
grid.set(2, 3, '!'); // -> out
let prog = Arc::new(ArcSwap::from_pointee(compile(&grid)));
let voices = Arc::new(AtomicU32::new(0));
let (prod_ev, cons_ev) = rtrb::RingBuffer::new(1024);
audio::spawn(prog.clone(), cons_ev, voices.clone(), rate, channels, latency, poly)
.map_err(|e| anyhow::anyhow!("audio: {e}"))?;
// Keep the MIDI connection alive for the program's lifetime.
let _midi = match midi::connect(prod_ev, midi_name) {
Ok(conn) => Some(conn),
Err(e) => {
eprintln!("warning: MIDI unavailable ({e}); running without note input");
None
}
};
tui::run(prog, voices, grid, latency)?;
Ok(())
grid
}
+67 -15
View File
@@ -1,5 +1,6 @@
//! MIDI input. galdr owns a virtual input port named "galdr" (symmetric with
//! orca's virtual output), so orca or any client connects to it in the graph.
//! The channel nibble is kept: tagged columns voice only their channel.
use anyhow::{anyhow, Result};
use midir::os::unix::VirtualInput;
@@ -8,34 +9,85 @@ use rtrb::Producer;
use crate::engine::Event;
pub fn connect(prod: Producer<Event>, port_name: Option<String>) -> Result<MidiInputConnection<Producer<Event>>> {
/// Decode one MIDI message into an engine event.
pub fn decode(msg: &[u8]) -> Option<Event> {
if msg.len() < 3 {
return None;
}
let ch = msg[0] & 0x0F;
match msg[0] & 0xF0 {
0x90 if msg[2] > 0 => Some(Event::NoteOn {
ch,
note: msg[1],
vel: msg[2],
}),
0x80 | 0x90 => Some(Event::NoteOff { ch, note: msg[1] }), // note-on velocity 0
0xB0 => Some(Event::Cc {
idx: msg[1],
val: msg[2],
}),
_ => None,
}
}
pub fn connect(
prod: Producer<Event>,
port_name: Option<String>,
) -> Result<MidiInputConnection<Producer<Event>>> {
let mut input = MidiInput::new("galdr").map_err(|e| anyhow!("MIDI init: {e}"))?;
input.ignore(midir::Ignore::None);
let cb = move |_stamp: u64, msg: &[u8], prod: &mut Producer<Event>| {
if msg.len() < 2 {
return;
if let Some(ev) = decode(msg) {
let _ = prod.push(ev);
}
let status = msg[0] & 0xF0;
let ev = match status {
0x90 if msg[2] > 0 => Event::NoteOn { note: msg[1], vel: msg[2] },
0x80 => Event::NoteOff { note: msg[1] },
0x90 => Event::NoteOff { note: msg[1] }, // note-on velocity 0
0xB0 => Event::Cc { idx: msg[1], val: msg[2] },
_ => return,
};
let _ = prod.push(ev);
};
if let Some(name) = port_name {
let ports = input.ports();
let port = ports
.iter()
.find(|p| input.port_name(p).map(|n| n.contains(&name)).unwrap_or(false))
.find(|p| {
input
.port_name(p)
.map(|n| n.contains(&name))
.unwrap_or(false)
})
.cloned()
.ok_or_else(|| anyhow!("no MIDI input port matching {name:?}"))?;
input.connect(&port, "galdr-in", cb, prod).map_err(|e| anyhow!("MIDI connect: {e}"))
input
.connect(&port, "galdr-in", cb, prod)
.map_err(|e| anyhow!("MIDI connect: {e}"))
} else {
input.create_virtual("galdr", cb, prod).map_err(|e| anyhow!("create virtual MIDI port: {e}"))
input
.create_virtual("galdr", cb, prod)
.map_err(|e| anyhow!("create virtual MIDI port: {e}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decode_keeps_channel() {
assert_eq!(
decode(&[0x92, 60, 100]),
Some(Event::NoteOn {
ch: 2,
note: 60,
vel: 100
})
);
assert_eq!(
decode(&[0x8F, 60, 0]),
Some(Event::NoteOff { ch: 15, note: 60 })
);
assert_eq!(
decode(&[0x90, 60, 0]),
Some(Event::NoteOff { ch: 0, note: 60 })
);
assert_eq!(decode(&[0xB3, 7, 64]), Some(Event::Cc { idx: 7, val: 64 }));
assert_eq!(decode(&[0xF8]), None);
}
}
+361
View File
@@ -0,0 +1,361 @@
//! Patch files: the grid's text format on disk. `galdr <file>` opens one,
//! Ctrl+S writes it back, and `#name` over UDP loads `<patches dir>/name.galdr`.
//!
//! With no file given, galdr works on today's patch, `~/hometaping/<date>.galdr`
//! (`date -I` form), next to the orca files kept the same way: starting galdr
//! opens it if it exists and Ctrl+S writes it.
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use crate::grid::Grid;
pub const EXT: &str = "galdr";
/// The patches directory: `~/hometaping`, where the dated orca files live.
pub fn default_dir() -> PathBuf {
std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
.join("hometaping")
}
/// Today's date as `date -I` prints it (local time), with a UTC fallback.
pub fn today() -> String {
if let Ok(out) = std::process::Command::new("date").arg("-I").output() {
if out.status.success() {
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if s.len() == 10 {
return s;
}
}
}
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
civil_from_days((secs / 86400) as i64)
}
/// Howard Hinnant's days-since-epoch to YYYY-MM-DD.
fn civil_from_days(z: i64) -> String {
let z = z + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!("{y:04}-{m:02}-{d:02}")
}
/// `<dir>/<date>.galdr`: the patch Ctrl+S writes when no file was given.
pub fn dated(dir: &Path, date: &str) -> PathBuf {
dir.join(format!("{date}.{EXT}"))
}
/// Resolve a UDP patch name inside the patches directory (no path escapes:
/// the parser already restricts names to alphanumerics, `-` and `_`).
/// `.galdr` is the name; a legacy `.txt` is accepted if that is what exists.
pub fn named(dir: &Path, name: &str) -> PathBuf {
let p = dir.join(format!("{name}.{EXT}"));
if p.exists() {
return p;
}
let legacy = dir.join(format!("{name}.txt"));
if legacy.exists() {
legacy
} else {
p
}
}
pub fn load(path: &Path) -> Result<Grid> {
let text = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
Ok(Grid::from_text(&text))
}
pub fn save(path: &Path, grid: &Grid) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
std::fs::write(path, grid.to_text()).with_context(|| format!("write {}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dated_path_matches_orca_convention() {
let p = dated(Path::new("/home/x/hometaping"), "2026-10-08");
assert_eq!(p, PathBuf::from("/home/x/hometaping/2026-10-08.galdr"));
assert_eq!(civil_from_days(0), "1970-01-01");
assert_eq!(civil_from_days(20734), "2026-10-08");
assert_eq!(today().len(), 10);
}
#[test]
fn named_prefers_galdr_then_legacy_txt() {
let dir = std::env::temp_dir().join(format!("galdr-named-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
assert_eq!(named(&dir, "x"), dir.join("x.galdr"));
std::fs::write(dir.join("y.txt"), "A\n!\n").unwrap();
assert_eq!(named(&dir, "y"), dir.join("y.txt"));
std::fs::write(dir.join("y.galdr"), "A\n!\n").unwrap();
assert_eq!(named(&dir, "y"), dir.join("y.galdr"));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn save_and_load_roundtrip() {
let dir = std::env::temp_dir().join(format!("galdr-patch-test-{}", std::process::id()));
let path = named(&dir, "verse");
let mut g = Grid::new();
g.resize(2, 3);
g.set(0, 0, 'A');
g.set(0, 1, '!');
save(&path, &g).unwrap();
let g2 = load(&path).unwrap();
assert_eq!((g2.w, g2.h), (3, 2));
assert_eq!(g2.get(0, 1), '!');
let _ = std::fs::remove_dir_all(dir);
}
}
#[cfg(test)]
mod example_render {
use super::*;
use crate::dsp::compile;
use crate::engine::{Engine, Event};
#[test]
fn drums_example_each_channel_sounds() {
let g = load(std::path::Path::new("examples/drums.galdr")).unwrap();
let p = compile(&g);
for (ch, note, name) in [
(0u8, 36u8, "bass"),
(9, 36, "kick"),
(10, 50, "snare"),
(11, 65, "hats"),
] {
let mut e = Engine::new(8, 48000.0);
e.apply(Event::NoteOn { ch, note, vel: 110 });
let mut peak = 0f32;
let mut early = 0f32;
let mut late = 0f32;
let mut zc_early = 0;
let mut zc_late = 0;
let mut prev = 0f32;
for i in 0..24000 {
let y = e.render(&p)[0];
peak = peak.max(y.abs());
if i < 2400 {
early = early.max(y.abs());
if (y < 0.0) != (prev < 0.0) {
zc_early += 1;
}
}
if (12000..14400).contains(&i) {
late = late.max(y.abs());
if (y < 0.0) != (prev < 0.0) {
zc_late += 1;
}
}
prev = y;
if i == 4800 {
e.apply(Event::NoteOff { ch, note });
}
}
eprintln!("{name:5} peak {peak:.2} first50ms {early:.2} (zc {zc_early}) at250ms {late:.2} (zc {zc_late}) voices {}", e.active_voices());
assert!(peak > 0.05 && peak <= 1.0, "{name} silent or clipping");
let mut e2 = Engine::new(8, 48000.0);
e2.apply(Event::NoteOn {
ch: 5,
note,
vel: 110,
});
assert!(
(0..1000)
.map(|_| e2.render(&p)[0].abs())
.fold(0f32, f32::max)
== 0.0,
"{name} leaks onto channel 6"
);
}
}
}
#[cfg(test)]
mod orchestra_example {
use super::*;
use crate::dsp::compile;
use crate::engine::{Engine, Event};
#[test]
fn each_section_sounds_and_stays_on_its_channel() {
let g = load(std::path::Path::new("examples/orchestra.galdr")).unwrap();
let p = compile(&g);
assert_eq!(p.post.len(), 1, "one master chain (hall) expected");
assert_eq!(p.post[0].len(), 3, "highpass, chorus, delay");
let names = [
"strings",
"low strings",
"brass",
"horns",
"flute",
"reed",
"clarinet",
"timpani",
"harp",
"choir",
];
for (ch, name) in names.iter().enumerate() {
let note = if ch == 7 { 36 } else { 60 };
let mut e = Engine::new(8, 48000.0);
e.apply(Event::Reg {
idx: b'k' - b'a',
val: 0.5,
});
e.apply(Event::NoteOn {
ch: ch as u8,
note,
vel: 100,
});
let mut peak = 0f32;
for i in 0..48000 {
peak = peak.max(e.render(&p)[0].abs());
if i == 24000 {
e.apply(Event::NoteOff { ch: ch as u8, note });
}
}
eprintln!("{name:12} peak {peak:.2}");
assert!(peak > 0.03 && peak <= 1.0, "{name} silent or clipping");
let mut e2 = Engine::new(8, 48000.0);
e2.apply(Event::NoteOn {
ch: 12,
note,
vel: 100,
});
assert!(
(0..2000)
.map(|_| e2.render(&p)[0].abs())
.fold(0f32, f32::max)
== 0.0,
"{name} leaks onto channel 13"
);
}
}
}
#[cfg(test)]
mod acid_example {
use super::*;
use crate::dsp::compile;
use crate::engine::{Engine, Event};
#[test]
fn acid_line_sweeps_and_slides() {
let g = load(std::path::Path::new("examples/acid.galdr")).unwrap();
let p = compile(&g);
assert!(p.legato[0], "channel 1 slides");
let mut e = Engine::new(8, 48000.0);
e.apply(Event::NoteOn {
ch: 0,
note: 36,
vel: 127,
});
let hf =
|x: &[f32]| x.windows(2).map(|w| (w[1] - w[0]).abs()).sum::<f32>() / x.len() as f32;
let early: Vec<f32> = (0..2000).map(|_| e.render(&p)[0]).collect();
for _ in 0..12000 {
e.render(&p);
}
let late: Vec<f32> = (0..2000).map(|_| e.render(&p)[0]).collect();
assert!(
early.iter().fold(0f32, |a, b| a.max(b.abs())) > 0.1,
"audible"
);
assert!(
hf(&early) > 1.5 * hf(&late),
"filter sweeps down over the note"
);
e.apply(Event::NoteOn {
ch: 0,
note: 48,
vel: 127,
}); // overlapping -> slide, one voice
e.render(&p);
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");
}
}
}
+155 -44
View File
@@ -5,9 +5,11 @@
//! a detailed help overlay; run a spectrum analyzer on the side if you want one.
use std::io::{self, Write};
use std::sync::atomic::{AtomicU32, Ordering};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::mpsc::Receiver;
use std::sync::Arc;
use std::time::Duration;
use std::time::{Duration, Instant};
use arc_swap::ArcSwap;
use crossterm::cursor::{Hide, MoveTo, Show};
@@ -20,27 +22,37 @@ use crossterm::{execute, queue, terminal};
use crate::dsp::{compile, Program};
use crate::grid::{ruler_snap, Grid, RULER};
use crate::patch;
use crate::udp::Control;
const HELP: &[&str] = &[
"galdr — grid synthesizer",
"",
"Signal flows DOWN: a cell's input is the cell above it. Operators are",
"UPPERCASE. A digit 0-9 to the left/right is a constant param; a lowercase",
"a-z reads MIDI CC (a = CC 0). Every note plays the whole grid; each ! sums",
"into the master, so parallel columns stack voices/timbres.",
"UPPERCASE. Left/right neighbours are params: digit 0-9 constant, a-z a",
"register (MIDI CC a=0, or UDP), @ velocity, ^ key tracking, ? per-note random.",
"A base-36 glyph ABOVE a column's top operator tags it with a MIDI channel",
"(0-9 a-f = ch 1-16); untagged columns play every channel. Each ! sums into",
"the master; operators BELOW a ! form the master chain (stereo, serial).",
"",
"SOURCES O sine A saw U pulse V triangle N noise",
" left param = pitch ratio (O/A) or pulse width (U)",
"FILTERS L lowpass H highpass B bandpass (state-variable)",
" left = cutoff, right = resonance (higher glyph = more)",
"SHAPERS S soft clip/tanh (left=drive) C hard clip (left=threshold)",
" F wavefolder (left=amount) D decimate/bitcrush (left=rate)",
"AMP/MOD E envelope, note-gated (left=attack, right=release)",
" G gain (left=amount) R ring modulator (left=ratio)",
"ROUTING M mix — adds the cell to its LEFT ! output to master",
"SOURCES O sine A saw U pulse V triangle N noise X FM (mod <- LEFT",
" cell + above, index <- right) left = pitch ratio (O/A) / width (U)",
"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 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 to place · Backspace clears",
" ( ) width -/+ _ + height -/+ Ctrl+arrows resize by one",
"KEYS arrows move · type a glyph · Backspace clears · Ctrl+S save patch",
" ( ) width -/+ [ ] height -/+ Ctrl+arrows resize by one",
" ? toggle this help · Esc quit",
];
@@ -52,17 +64,32 @@ enum Cs {
fn glyph_color(c: char) -> Color {
match c {
'O' | 'A' | 'U' | 'V' | 'N' => Color::Cyan,
'O' | 'A' | 'U' | 'V' | 'N' | 'X' => Color::Cyan,
'L' | 'H' | 'B' => Color::Blue,
'S' | 'C' | 'F' | 'D' => Color::Yellow,
'E' | 'G' | 'R' | 'M' => 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,
'@' | '^' | '?' => Color::Red,
_ => Color::White,
}
}
/// Everything the editor needs besides the grid itself.
pub struct Session {
pub prog: Arc<ArcSwap<Program>>,
pub voices: Arc<AtomicU32>,
pub connected: Arc<AtomicBool>,
pub latency: u32,
pub ctrl: Receiver<Control>,
pub patches: PathBuf,
pub file: Option<PathBuf>,
pub udp_port: Option<u16>,
}
struct Renderer {
cells: Vec<(char, Cs)>,
status: String,
@@ -89,26 +116,70 @@ impl Renderer {
}
}
pub fn run(
prog: Arc<ArcSwap<Program>>,
voices: Arc<AtomicU32>,
mut grid: Grid,
latency: u32,
) -> io::Result<()> {
pub fn run(mut s: Session, mut grid: Grid) -> io::Result<()> {
enable_raw_mode()?;
let mut out = io::stdout();
execute!(out, EnterAlternateScreen, Hide)?;
let (mut cx, mut cy) = (0usize, 0usize);
let mut help = false;
let mut r = Renderer::new();
let mut note: Option<(String, Instant)> = None;
let set_note =
|note: &mut Option<(String, Instant)>, msg: String| *note = Some((msg, Instant::now()));
let res = (|| -> io::Result<()> {
loop {
draw(&mut out, &grid, cx, cy, voices.load(Ordering::Relaxed), latency, help, &mut r)?;
if note
.as_ref()
.is_some_and(|(_, t)| t.elapsed() > Duration::from_secs(4))
{
note = None;
}
let status = status_line(
&s,
&grid,
s.voices.load(Ordering::Relaxed),
note.as_ref().map(|(m, _)| m.as_str()),
);
draw(&mut out, &grid, cx, cy, &status, help, &mut r)?;
// Edits arriving over UDP (orca patching the grid, patch loads).
let mut remote = false;
while let Ok(c) = s.ctrl.try_recv() {
match c {
Control::Set { col, row, glyph } => {
if col < grid.w && row < grid.h {
grid.set(col, row, if glyph == ' ' { '.' } else { glyph });
remote = true;
}
}
Control::Load(name) => {
let path = patch::named(&s.patches, &name);
match patch::load(&path) {
Ok(g) => {
grid = g;
s.file = Some(path);
remote = true;
set_note(&mut note, format!("loaded {name}"));
}
Err(e) => set_note(&mut note, format!("load {name}: {e}")),
}
}
}
}
if remote {
cx = cx.min(grid.w - 1);
cy = cy.min(grid.h - 1);
s.prog.store(Arc::new(compile(&grid)));
r.init = false; // size may have changed: repaint fully
}
if !event::poll(Duration::from_millis(33))? {
continue;
}
let Event::Key(k) = event::read()? else { continue };
let Event::Key(k) = event::read()? else {
continue;
};
if k.kind != KeyEventKind::Press {
continue;
}
@@ -124,6 +195,20 @@ pub fn run(
}
}
KeyCode::Char('c') if ctrl => return Ok(()),
KeyCode::Char('s') if ctrl => {
// No file given: today's patch next to the orca files.
let path = s
.file
.clone()
.unwrap_or_else(|| patch::dated(&s.patches, &patch::today()));
match patch::save(&path, &grid) {
Ok(()) => {
set_note(&mut note, format!("saved {}", path.display()));
s.file = Some(path);
}
Err(e) => set_note(&mut note, format!("save failed: {e}")),
}
}
KeyCode::Char('?') => help = !help,
_ if help => {}
KeyCode::Char('(') => {
@@ -134,11 +219,11 @@ pub fn run(
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;
}
@@ -177,7 +262,7 @@ pub fn run(
cy = cy.min(grid.h - 1);
}
if resized || edited {
prog.store(Arc::new(compile(&grid)));
s.prog.store(Arc::new(compile(&grid)));
}
}
})();
@@ -187,25 +272,45 @@ pub fn run(
res
}
#[allow(clippy::too_many_arguments)]
fn status_line(s: &Session, grid: &Grid, voices: u32, note: Option<&str>) -> String {
let file = s
.file
.as_ref()
.and_then(|p| p.file_name())
.map(|f| f.to_string_lossy().into_owned())
.unwrap_or_else(|| "(unsaved)".into());
let udp = s
.udp_port
.map(|p| format!(" udp {p}"))
.unwrap_or_default();
if !s.connected.load(Ordering::Relaxed) {
return format!(
"PIPEWIRE DOWN — reconnecting… {}x{} {file}",
grid.w, grid.h
);
}
match note {
Some(n) => format!("{n} · voices {voices} {}x{} {file}", grid.w, grid.h),
None => format!(
"voices {voices} {}x{} latency {}f{udp} {file} ? help Esc quit",
grid.w, grid.h, s.latency
),
}
}
fn draw(
out: &mut io::Stdout,
grid: &Grid,
cx: usize,
cy: usize,
voices: u32,
latency: u32,
status: &str,
help: bool,
r: &mut Renderer,
) -> io::Result<()> {
let (tw, th) = terminal::size()?;
let full = !r.init
|| tw != r.tw
|| th != r.th
|| help != r.help
|| grid.w != r.gw
|| grid.h != r.gh;
let full =
!r.init || tw != r.tw || th != r.th || help != r.help || grid.w != r.gw || grid.h != r.gh;
if full {
queue!(out, Clear(ClearType::All))?;
r.cells = vec![('\0', Cs::Fg(Color::Reset)); grid.w * grid.h];
@@ -221,7 +326,7 @@ fn draw(
if help {
if full {
let oy = (th as usize).saturating_sub(HELP.len()) / 2;
let ox = (tw as usize).saturating_sub(74) / 2;
let ox = (tw as usize).saturating_sub(84) / 2;
for (i, line) in HELP.iter().enumerate() {
queue!(
out,
@@ -242,7 +347,11 @@ fn draw(
let raw = grid.get(col, row);
// ruler mark on empty intersections, for orientation while sizing
let disp = if raw == '.' {
if row % RULER == 0 && col % RULER == 0 { '+' } else { '.' }
if row % RULER == 0 && col % RULER == 0 {
'+'
} else {
'.'
}
} else {
raw
};
@@ -258,7 +367,11 @@ fn draw(
continue;
}
r.cells[idx] = (disp, cs);
queue!(out, MoveTo((off_x + col) as u16, (off_y + row) as u16), SetAttribute(Attribute::Reset))?;
queue!(
out,
MoveTo((off_x + col) as u16, (off_y + row) as u16),
SetAttribute(Attribute::Reset)
)?;
match cs {
Cs::Cursor => queue!(out, SetAttribute(Attribute::Reverse))?,
Cs::Fg(color) => queue!(out, SetForegroundColor(color))?,
@@ -267,10 +380,8 @@ fn draw(
}
}
let status =
format!("voices {voices} {}x{} latency {latency}f ? help Esc quit", grid.w, grid.h);
if status != r.status {
r.status = status.clone();
r.status = status.to_string();
let pad: String = format!("{status:<width$}", width = tw as usize);
queue!(
out,
+170
View File
@@ -0,0 +1,170 @@
//! UDP control from orca (`;` sends a bare string to a port). One datagram
//! carries one or more commands separated by whitespace or `;`:
//!
//! * `k7` set register k (a-z) to base-36 7 of 35 -> audio thread
//! * `*3` bang column 3 (base-36) -> audio thread
//! * `@34O` write glyph `O` at column 3, row 4 (base-36 each) -> editor
//! * `@34.` clear that cell
//! * `#verse` load the patch file `verse` from the patches directory -> editor
//!
//! Register and bang commands go straight into the lock-free event ring the
//! MIDI callback uses; editor commands go over a channel to the TUI thread,
//! which owns the grid and recompiles.
use std::net::UdpSocket;
use std::sync::mpsc::Sender;
use std::thread;
use anyhow::{Context, Result};
use rtrb::Producer;
use crate::dsp::b36;
use crate::engine::Event;
/// Commands that change the grid; handled by the editor thread.
#[derive(Clone, Debug, PartialEq)]
pub enum Control {
Set { col: usize, row: usize, glyph: char },
Load(String),
}
#[derive(Clone, Debug, PartialEq)]
pub enum Cmd {
Audio(Event),
Edit(Control),
}
pub fn parse(text: &str) -> Vec<Cmd> {
let mut out = Vec::new();
for tok in text.split(|c: char| c.is_whitespace() || c == ';') {
let mut it = tok.chars();
let Some(head) = it.next() else { continue };
let rest: String = it.collect();
match head {
'a'..='z' => {
if let Some(v) = rest.chars().next().and_then(b36) {
let idx = head as u8 - b'a';
out.push(Cmd::Audio(Event::Reg {
idx,
val: v as f32 / 35.0,
}));
}
}
'*' => {
if let Some(col) = rest.chars().next().and_then(b36) {
out.push(Cmd::Audio(Event::Bang { col }));
}
}
'@' => {
let mut r = rest.chars();
let (Some(c), Some(w), Some(g)) = (r.next(), r.next(), r.next()) else {
continue;
};
if let (Some(col), Some(row)) = (b36(c), b36(w)) {
out.push(Cmd::Edit(Control::Set {
col: col as usize,
row: row as usize,
glyph: g,
}));
}
}
'#' => {
let name: String = rest
.chars()
.filter(|c| c.is_alphanumeric() || matches!(c, '-' | '_'))
.collect();
if !name.is_empty() {
out.push(Cmd::Edit(Control::Load(name)));
}
}
_ => {}
}
}
out
}
/// Bind the socket and serve it from a background thread for the program's
/// lifetime.
pub fn spawn(port: u16, mut audio: Producer<Event>, editor: Sender<Control>) -> Result<()> {
let sock =
UdpSocket::bind(("0.0.0.0", port)).with_context(|| format!("bind UDP port {port}"))?;
thread::Builder::new()
.name("galdr-udp".into())
.spawn(move || {
let mut buf = [0u8; 2048];
while let Ok((n, _)) = sock.recv_from(&mut buf) {
let text = String::from_utf8_lossy(&buf[..n]);
for cmd in parse(&text) {
match cmd {
Cmd::Audio(ev) => {
let _ = audio.push(ev);
}
Cmd::Edit(c) => {
if editor.send(c).is_err() {
return;
}
}
}
}
}
})
.context("spawn UDP thread")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_register_set() {
assert_eq!(
parse("k7"),
vec![Cmd::Audio(Event::Reg {
idx: 10,
val: 7.0 / 35.0
})]
);
assert_eq!(
parse("az"),
vec![Cmd::Audio(Event::Reg { idx: 0, val: 1.0 })]
);
}
#[test]
fn parses_bang_and_edit_and_load() {
assert_eq!(parse("*3"), vec![Cmd::Audio(Event::Bang { col: 3 })]);
assert_eq!(
parse("@34O"),
vec![Cmd::Edit(Control::Set {
col: 3,
row: 4,
glyph: 'O'
})]
);
assert_eq!(
parse("@a0."),
vec![Cmd::Edit(Control::Set {
col: 10,
row: 0,
glyph: '.'
})]
);
assert_eq!(
parse("#verse"),
vec![Cmd::Edit(Control::Load("verse".into()))]
);
}
#[test]
fn multiple_commands_per_datagram() {
let cmds = parse("k7 *2;#drop\n");
assert_eq!(cmds.len(), 3);
}
#[test]
fn garbage_is_ignored() {
assert!(parse("").is_empty());
assert!(parse("K7 @3 *").is_empty());
}
}