2 Commits
Author SHA1 Message Date
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
16 changed files with 2487 additions and 354 deletions
Generated
+1 -1
View File
@@ -320,7 +320,7 @@ dependencies = [
[[package]] [[package]]
name = "galdr" name = "galdr"
version = "0.2.4" version = "0.3.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arc-swap", "arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "galdr" name = "galdr"
version = "0.2.4" version = "0.3.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"
+184 -14
View File
@@ -1,13 +1,13 @@
# galdr # galdr
A terminal **grid synthesizer**. Signal flows downward through a field of A terminal **grid synthesizer**. Signal flows downward through a field of
single-glyph unit generators; every incoming MIDI note voices the whole grid; single-glyph unit generators; every incoming MIDI note voices the grid (or the
the mix is rendered low-latency to PipeWire. It is orca's voice — the same columns tagged with its channel); the mix is rendered low-latency to PipeWire.
esoteric, spatial approach to programming, but for making sound instead of It is orca's voice — the same esoteric, spatial approach to programming, but
sending events. for making sound instead of sending events.
``` ```
orca-rs ──MIDI/OSC/UDP──▶ galdr ──PipeWire──▶ speakers orca-rs ──MIDI + UDP──▶ galdr ──PipeWire──▶ speakers / stems
compose the pattern voice it (spectrust -p visualizes it) compose the pattern voice it (spectrust -p visualizes it)
``` ```
@@ -32,11 +32,179 @@ a constant parameter; a lowercase letter `a`–`z` reads MIDI CC (`a` = CC 0).
| `D` | decimate / bitcrush | rate | | `D` | decimate / bitcrush | rate |
| `G` | gain | amount | | `G` | gain | amount |
| `R` | ring modulator | ratio | | `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** | — | | `M` | mix — adds the cell to its **left** | — |
| `K` | control envelope: 1 at note-on, decays to a floor; no audio input | decay / floor |
| `!` | output to master | — | | `!` | output to master | — |
`K` is the one operator whose output is a *param*: placed to the **left** of
another operator it becomes that operator's left param, scaled 0–9. `2KL5` is
a lowpass whose cutoff starts open and decays (2) to the floor, with
resonance 5. That's the 303 filter envelope.
Each `!` sums into the master, so parallel columns stack timbres or voices. 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 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 |
```
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: Example — a filtered saw with an envelope:
@@ -47,22 +215,24 @@ E ...enveloped by the note gate
! ...to master ! ...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 ## 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 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. 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 Keys: cursor keys move; type a glyph to place it; Backspace clears; `Ctrl+S`
help; Esc quits. Resize the grid with `(` / `)` (width) and `_` / `+` (height) 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). 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 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> # Maintainer: Bendik Aagaard Lynghaug <bendik.lynghaug@gmail.com>
pkgname=galdr pkgname=galdr
pkgver=0.2.4 pkgver=0.3.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')
+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...!.
.!.......!.....
+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, //! Low-latency PipeWire playback. The RT process callbacks drain input events,
//! renders the voice mixer, and fills the output buffer. No allocation or locks //! render the voice mixer and master chain, and fill the output buffers. No
//! on this path. //! 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::mpsc::Sender;
use std::sync::Arc; use std::sync::Arc;
use std::thread; use std::thread;
use std::time::Duration;
use arc_swap::ArcSwap; use arc_swap::ArcSwap;
use pipewire as pw; use pipewire as pw;
@@ -15,50 +32,180 @@ use rtrb::Consumer;
use spa::param::audio::{AudioFormat, AudioInfoRaw}; use spa::param::audio::{AudioFormat, AudioInfoRaw};
use crate::dsp::Program; use crate::dsp::Program;
use crate::engine::{Engine, Event}; use crate::engine::{Engine, Event, CHANNELS};
pub type Shared = Arc<ArcSwap<Program>>; 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( pub fn spawn(
prog: Shared, prog: Shared,
events: Consumer<Event>, events: Consumer<Event>,
voices: Arc<AtomicU32>, voices: Arc<AtomicU32>,
sr: u32, connected: Arc<AtomicBool>,
channels: u8, cfg: Config,
latency: u32,
poly: usize,
) -> Result<(), String> { ) -> Result<(), String> {
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<(), String>>(); let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<(), String>>();
thread::Builder::new() thread::Builder::new()
.name("galdr-audio".into()) .name("galdr-audio".into())
.spawn(move || { .spawn(move || {
if let Err(e) = let render = Rc::new(RefCell::new(Render {
run(prog, events, voices, sr, channels, latency, poly, ready_tx.clone()) engine: Engine::new(cfg.poly, cfg.sr as f32),
{ events,
let _ = ready_tx.send(Err(e)); 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}"))?; .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)] /// One graph cycle's worth of rendered audio, shared by all stream callbacks.
fn run( struct Render {
engine: Engine,
events: Consumer<Event>,
prog: Shared, prog: Shared,
mut events: Consumer<Event>,
voices: Arc<AtomicU32>, voices: Arc<AtomicU32>,
sr: u32, latency: usize,
channels: u8, /// Cycle counter: bumped each time a new cycle is rendered.
latency: u32, cycle: u64,
poly: usize, frames: usize,
ready: Sender<Result<(), String>>, 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> { ) -> Result<(), String> {
pw::init(); pw::init();
let mainloop = pw::main_loop::MainLoopRc::new(None).map_err(|e| format!("mainloop: {e}"))?; 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 context =
let core = context.connect_rc(None).map_err(|e| format!("connect PipeWire: {e}"))?; 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! { let props = properties! {
*pw::keys::MEDIA_TYPE => "Audio", *pw::keys::MEDIA_TYPE => "Audio",
*pw::keys::MEDIA_CATEGORY => "Playback", *pw::keys::MEDIA_CATEGORY => "Playback",
@@ -66,71 +213,56 @@ fn run(
*pw::keys::NODE_NAME => "galdr", *pw::keys::NODE_NAME => "galdr",
*pw::keys::NODE_LATENCY => format!("{latency}/{sr}"), *pw::keys::NODE_LATENCY => format!("{latency}/{sr}"),
}; };
let stream = pw::stream::StreamBox::new(&core, "galdr", props).map_err(|e| format!("stream: {e}"))?; let stream =
pw::stream::StreamBox::new(&core, "galdr", props).map_err(|e| format!("stream: {e}"))?;
let ch = channels.max(1) as usize; let r = render.clone();
let mut engine = Engine::new(poly, sr as f32); let mut seen: u64 = render.borrow().cycle;
let ml = mainloop.clone();
let _listener = stream let listener = stream
.add_local_listener_with_user_data(()) .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, _| { .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 requested = buffer.requested() as usize;
let datas = buffer.datas_mut(); let datas = buffer.datas_mut();
let Some(data) = datas.first_mut() else { return }; let Some(data) = datas.first_mut() else {
let stride = ch * 4; return;
let program = prog.load(); };
let stride = 2 * 4;
// Drain all pending input events once per buffer. let mut r = r.borrow_mut();
while let Ok(ev) = events.pop() { r.ensure(seen, requested);
engine.apply(ev); seen = r.cycle;
} let mut n = 0usize;
// 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;
if let Some(slice) = data.data() { if let Some(slice) = data.data() {
n_frames = want.min(slice.len() / stride); n = r.frames.min(slice.len() / stride);
for f in 0..n_frames { for (f, m) in r.master[..n].iter().enumerate() {
let smp = engine.render(&program); let o = f * stride;
let bytes = smp.to_le_bytes(); slice[o..o + 4].copy_from_slice(&m[0].to_le_bytes());
for c in 0..ch { slice[o + 4..o + 8].copy_from_slice(&m[1].to_le_bytes());
let o = f * stride + c * 4;
slice[o..o + 4].copy_from_slice(&bytes);
}
} }
} }
let chunk = data.chunk_mut(); let chunk = data.chunk_mut();
*chunk.offset_mut() = 0; *chunk.offset_mut() = 0;
*chunk.stride_mut() = stride as _; *chunk.stride_mut() = stride as _;
*chunk.size_mut() = (stride * n_frames) as _; *chunk.size_mut() = (stride * n) as _;
voices.store(engine.active_voices() as u32, Ordering::Relaxed);
}) })
.register() .register()
.map_err(|e| format!("listener: {e}"))?; .map_err(|e| format!("listener: {e}"))?;
let values = format_pod(
let mut audio_info = AudioInfoRaw::new(); sr,
audio_info.set_format(AudioFormat::F32LE); &[
audio_info.set_rate(sr); spa::sys::SPA_AUDIO_CHANNEL_FL,
audio_info.set_channels(channels as u32); spa::sys::SPA_AUDIO_CHANNEL_FR,
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 mut params = [Pod::from_bytes(&values).ok_or("bad pod")?]; let mut params = [Pod::from_bytes(&values).ok_or("bad pod")?];
stream stream
.connect( .connect(
spa::utils::Direction::Output, spa::utils::Direction::Output,
@@ -141,8 +273,78 @@ fn run(
&mut params, &mut params,
) )
.map_err(|e| format!("connect stream: {e}"))?; .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(); mainloop.run();
Ok(()) Ok(())
} }
+731 -123
View File
File diff suppressed because it is too large Load Diff
+338 -30
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 { pub enum Event {
NoteOn { note: u8, vel: u8 }, NoteOn {
NoteOff { note: u8 }, ch: u8,
Cc { idx: u8, val: 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 { pub struct Engine {
@@ -14,15 +38,35 @@ pub struct Engine {
regs: Vec<f32>, regs: Vec<f32>,
sr: f32, sr: f32,
clock: u64, 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 { impl Engine {
pub fn new(poly: usize, sr: f32) -> Self { pub fn new(poly: usize, sr: f32) -> Self {
let buf_len = (sr * BUF_SECONDS) as usize;
Engine { Engine {
voices: (0..poly).map(|_| Voice::new()).collect(), voices: (0..poly).map(|_| Voice::new()).collect(),
regs: vec![0.0; 128], regs: vec![0.0; 128],
sr, sr,
clock: 0, 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,13 +74,10 @@ impl Engine {
self.voices.iter().filter(|v| v.active).count() self.voices.iter().filter(|v| v.active).count()
} }
pub fn apply(&mut self, ev: Event) { /// A free voice, else the oldest one (stolen).
match ev { fn allocate(&mut self) -> usize {
Event::NoteOn { note, vel } => {
self.clock += 1; self.clock += 1;
// Prefer a free voice, else steal the oldest. self.voices
let idx = self
.voices
.iter() .iter()
.position(|v| !v.active) .position(|v| !v.active)
.unwrap_or_else(|| { .unwrap_or_else(|| {
@@ -47,48 +88,144 @@ impl Engine {
} }
} }
best 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]; let v = &mut self.voices[idx];
v.active = true; v.active = true;
v.ch = ch;
v.note = note; v.note = note;
v.freq = 440.0 * 2f32.powf((note as f32 - 69.0) / 12.0); v.freq = freq;
v.gate = 1.0; v.gate = 1.0;
v.vel = vel as f32 / 127.0; v.vel = vel;
v.age = self.clock; 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 // fresh phases/env so a stolen voice doesn't click from old state
for x in v.state.iter_mut() { for x in v.state.iter_mut() {
*x = 0.0; *x = 0.0;
} }
v.env_level = 0.0; v.env_level = 0.0;
} }
Event::NoteOff { note } => {
pub fn apply(&mut self, ev: Event) {
match ev {
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;
}
}
let idx = self.allocate();
self.start(idx, ch, note, freq, vel as f32 / 127.0);
}
Event::NoteOff { ch, note } => {
let ch = ch % CHANNELS as u8;
for v in self.voices.iter_mut() { 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; v.gate = 0.0;
} }
} }
} }
Event::Cc { idx, val } => { 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] #[inline]
pub fn render(&mut self, prog: &Program) -> f32 { pub fn render(&mut self, prog: &Program) -> [f32; 2] {
let mut mix = 0.0f32; let mut mix = 0.0f32;
self.stems = [0.0; CHANNELS];
self.legato = prog.legato;
for v in self.voices.iter_mut() { for v in self.voices.iter_mut() {
if !v.active { if !v.active {
continue; 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. // Reap voices whose gate is closed and envelope has decayed.
if v.gate < 0.5 && v.env_level < 1e-4 { if v.gate < 0.5 && v.env_level < 1e-4 {
v.active = false; 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. // Gentle headroom limiter.
(mix * 0.3).tanh() [y[0].tanh(), y[1].tanh()]
} }
} }
@@ -121,7 +258,11 @@ mod tests {
#[test] #[test]
fn noteon_maps_a4_to_440() { fn noteon_maps_a4_to_440() {
let mut e = Engine::new(4, 48000.0); 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!((e.first_freq().unwrap() - 440.0).abs() < 0.01);
assert_eq!(e.active_voices(), 1); assert_eq!(e.active_voices(), 1);
} }
@@ -130,10 +271,31 @@ mod tests {
fn noteoff_reaps_gated_voice() { fn noteoff_reaps_gated_voice() {
let p = compile(&grid_from("A\n!")); // no envelope -> gate reaps immediately let p = compile(&grid_from("A\n!")); // no envelope -> gate reaps immediately
let mut e = Engine::new(4, 48000.0); 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); e.render(&p);
assert_eq!(e.active_voices(), 1); 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); e.render(&p);
assert_eq!(e.active_voices(), 0); assert_eq!(e.active_voices(), 0);
} }
@@ -141,17 +303,27 @@ mod tests {
#[test] #[test]
fn monophonic_steals_the_oldest_voice() { fn monophonic_steals_the_oldest_voice() {
let mut e = Engine::new(1, 48000.0); let mut e = Engine::new(1, 48000.0);
e.apply(Event::NoteOn { note: 60, vel: 100 }); e.apply(Event::NoteOn {
e.apply(Event::NoteOn { note: 72, vel: 100 }); ch: 0,
note: 60,
vel: 100,
});
e.apply(Event::NoteOn {
ch: 0,
note: 72,
vel: 100,
});
assert_eq!(e.active_voices(), 1); assert_eq!(e.active_voices(), 1);
assert!((e.first_freq().unwrap() - 440.0 * 2f32.powf((72.0 - 69.0) / 12.0)).abs() < 0.01); assert!((e.first_freq().unwrap() - 440.0 * 2f32.powf((72.0 - 69.0) / 12.0)).abs() < 0.01);
} }
#[test] #[test]
fn cc_writes_register() { fn cc_and_udp_write_registers() {
let mut e = Engine::new(2, 48000.0); let mut e = Engine::new(2, 48000.0);
e.apply(Event::Cc { idx: 0, val: 127 }); e.apply(Event::Cc { idx: 0, val: 127 });
assert!((e.reg(0) - 1.0).abs() < 1e-6); 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] #[test]
@@ -159,7 +331,143 @@ mod tests {
// many stacked saws would exceed 1.0 without the limiter // many stacked saws would exceed 1.0 without the limiter
let p = compile(&grid_from("A.A.A.A\n!.!.!.!")); let p = compile(&grid_from("A.A.A.A\n!.!.!.!"));
let mut e = Engine::new(4, 48000.0); let mut e = Engine::new(4, 48000.0);
e.apply(Event::NoteOn { note: 60, vel: 127 }); e.apply(Event::NoteOn {
assert!(e.render(&p).abs() <= 1.0); 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 { impl Grid {
pub fn new() -> Self { 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] #[inline]
@@ -41,15 +45,58 @@ impl Grid {
self.h = nh.clamp(1, MAX_H); self.h = nh.clamp(1, MAX_H);
self.w = nw.clamp(1, MAX_W); 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 /// orca-c's ruler snap: resize by one ruler division, snapped to a
/// ruler-aligned size (1, 9, 17, ...) rather than a plain +/-1. /// ruler-aligned size (1, 9, 17, ...) rather than a plain +/-1.
pub fn ruler_snap(size: usize, delta_rulers: isize) -> usize { pub fn ruler_snap(size: usize, delta_rulers: isize) -> usize {
if size == 0 { 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); let n = ((size as isize - 1) / RULER as isize + delta_rulers).max(0);
(RULER as isize * n + 1) as usize (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 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] #[test]
fn set_get_roundtrip_and_resize_preserves() { fn set_get_roundtrip_and_resize_preserves() {
let mut g = Grid::new(); let mut g = Grid::new();
+155 -31
View File
@@ -1,17 +1,21 @@
//! galdr - a terminal grid synthesizer. //! galdr - a terminal grid synthesizer.
//! //!
//! Signal flows downward through single-glyph unit generators; each MIDI note //! Signal flows downward through single-glyph unit generators; each MIDI note
//! voices the whole grid; the mix is rendered low-latency to PipeWire. Pairs //! voices the whole grid (or the columns tagged with its channel); the mix is
//! with orca (MIDI/OSC/UDP out) and spectrust (visualize the output). //! rendered low-latency to PipeWire. Pairs with orca (MIDI + UDP out) and
//! spectrust (visualize the output).
mod audio; mod audio;
mod dsp; mod dsp;
mod engine; mod engine;
mod grid; mod grid;
mod midi; mod midi;
mod patch;
mod tui; mod tui;
mod udp;
use std::sync::atomic::AtomicU32; use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU32};
use std::sync::Arc; use std::sync::Arc;
use arc_swap::ArcSwap; use arc_swap::ArcSwap;
@@ -22,32 +26,54 @@ use crate::grid::Grid;
const HELP: &str = "\ const HELP: &str = "\
galdr - terminal grid synthesizer galdr - terminal grid synthesizer
Usage: galdr [options] Usage: galdr [options] [patch.galdr]
Options: Options:
--latency <frames> PipeWire quantum; lower = lower latency (default: 256) --latency <frames> PipeWire quantum; lower = lower latency (default: 256)
--poly <n> Number of voices (default: 8) --poly <n> Number of voices (default: 8)
--rate <hz> Sample rate (default: 48000) --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 --midi-name <s> Connect to the input port matching <s> instead of
creating the virtual 'galdr' port creating the virtual 'galdr' port
-h, --help Print this message -h, --help Print this message
Grid glyphs are UPPERCASE operators; digits 0-9 are constants; lowercase a-z Grid glyphs are UPPERCASE operators. Signal flows down (input = the cell
read MIDI CC (a = CC 0). Signal flows down (input = the cell above); the left above); the left and right neighbours are params: digit 0-9 constant, a-z a
and right neighbours are params. register (MIDI CC a = 0, or UDP), @ velocity, ^ key tracking, ? per-note random.
sources O sine A saw U pulse V tri N noise (ratio/width <- left) 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) 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) 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) 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 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<()> { fn main() -> anyhow::Result<()> {
let mut latency: u32 = 256; let mut latency: u32 = 256;
let mut poly: usize = 8; let mut poly: usize = 8;
let mut rate: u32 = 48000; 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 midi_name: Option<String> = None;
let mut file: Option<PathBuf> = None;
let mut args = std::env::args().skip(1); let mut args = std::env::args().skip(1);
while let Some(a) = args.next() { 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), "--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), "--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), "--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(), "--midi-name" => midi_name = args.next(),
"-h" | "--help" => { "-h" | "--help" => {
print!("{HELP}"); print!("{HELP}");
return Ok(()); return Ok(());
} }
other if !other.starts_with('-') && file.is_none() => file = Some(PathBuf::from(other)),
other => { other => {
eprintln!("unknown option: {other}\n\n{HELP}"); eprintln!("unknown option: {other}\n\n{HELP}");
std::process::exit(2); 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 // No file: today's patch (~/hometaping/<date>.galdr) if it exists, so the
// a filtered saw voice. // 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(); let mut grid = Grid::new();
grid.set(0, 0, 'O'); // sine grid.set(0, 0, 'O'); // sine
grid.set(0, 1, 'E'); // -> envelope grid.set(0, 1, 'E'); // -> envelope
@@ -78,24 +221,5 @@ fn main() -> anyhow::Result<()> {
grid.set(2, 1, 'L'); // -> lowpass grid.set(2, 1, 'L'); // -> lowpass
grid.set(2, 2, 'E'); // -> envelope grid.set(2, 2, 'E'); // -> envelope
grid.set(2, 3, '!'); // -> out grid.set(2, 3, '!'); // -> out
grid
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(())
} }
+67 -15
View File
@@ -1,5 +1,6 @@
//! MIDI input. galdr owns a virtual input port named "galdr" (symmetric with //! 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. //! 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 anyhow::{anyhow, Result};
use midir::os::unix::VirtualInput; use midir::os::unix::VirtualInput;
@@ -8,34 +9,85 @@ use rtrb::Producer;
use crate::engine::Event; 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}"))?; let mut input = MidiInput::new("galdr").map_err(|e| anyhow!("MIDI init: {e}"))?;
input.ignore(midir::Ignore::None); input.ignore(midir::Ignore::None);
let cb = move |_stamp: u64, msg: &[u8], prod: &mut Producer<Event>| { let cb = move |_stamp: u64, msg: &[u8], prod: &mut Producer<Event>| {
if msg.len() < 2 { if let Some(ev) = decode(msg) {
return;
}
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); let _ = prod.push(ev);
}
}; };
if let Some(name) = port_name { if let Some(name) = port_name {
let ports = input.ports(); let ports = input.ports();
let port = ports let port = ports
.iter() .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() .cloned()
.ok_or_else(|| anyhow!("no MIDI input port matching {name:?}"))?; .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 { } 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);
} }
} }
+293
View File
@@ -0,0 +1,293 @@
//! 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);
}
}
+145 -40
View File
@@ -5,9 +5,11 @@
//! a detailed help overlay; run a spectrum analyzer on the side if you want one. //! a detailed help overlay; run a spectrum analyzer on the side if you want one.
use std::io::{self, Write}; 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::sync::Arc;
use std::time::Duration; use std::time::{Duration, Instant};
use arc_swap::ArcSwap; use arc_swap::ArcSwap;
use crossterm::cursor::{Hide, MoveTo, Show}; use crossterm::cursor::{Hide, MoveTo, Show};
@@ -20,26 +22,32 @@ use crossterm::{execute, queue, terminal};
use crate::dsp::{compile, Program}; use crate::dsp::{compile, Program};
use crate::grid::{ruler_snap, Grid, RULER}; use crate::grid::{ruler_snap, Grid, RULER};
use crate::patch;
use crate::udp::Control;
const HELP: &[&str] = &[ const HELP: &[&str] = &[
"galdr — grid synthesizer", "galdr — grid synthesizer",
"", "",
"Signal flows DOWN: a cell's input is the cell above it. Operators are", "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", "UPPERCASE. Left/right neighbours are params: digit 0-9 constant, a-z a",
"a-z reads MIDI CC (a = CC 0). Every note plays the whole grid; each ! sums", "register (MIDI CC a=0, or UDP), @ velocity, ^ key tracking, ? per-note random.",
"into the master, so parallel columns stack voices/timbres.", "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", "SOURCES O sine A saw U pulse V triangle N noise X FM (mod <- LEFT",
" left param = pitch ratio (O/A) or pulse width (U)", " cell + above, index <- right) left = pitch ratio (O/A) / width (U)",
"FILTERS L lowpass H highpass B bandpass (state-variable)", "FILTERS L lowpass H highpass B bandpass left = cutoff, right = resonance",
" left = cutoff, right = resonance (higher glyph = more)", "SHAPERS S soft clip (left=drive) C clip (left) F fold (left) D decimate (left)",
"SHAPERS S soft clip/tanh (left=drive) C hard clip (left=threshold)", "AMP/MOD E envelope (left=attack, right=release) G gain R ring mod (ratio)",
" F wavefolder (left=amount) D decimate/bitcrush (left=rate)", "CONTROL K decay envelope (left=decay, right=floor); LEFT of an operator it drives",
"AMP/MOD E envelope, note-gated (left=attack, right=release)", " that operator's left param: 2KL5 = filter sweep. tag> = slide (legato channel)",
" G gain (left=amount) R ring modulator (left=ratio)",
"ROUTING M mix — adds the cell to its LEFT ! output to master", "ROUTING M mix — adds the cell to its LEFT ! output to master",
"MASTER Z delay (left=time, right=feedback) W chorus (left=rate, right=depth)",
" + 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", "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",
]; ];
@@ -52,17 +60,30 @@ enum Cs {
fn glyph_color(c: char) -> Color { fn glyph_color(c: char) -> Color {
match c { match c {
'O' | 'A' | 'U' | 'V' | 'N' => 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' => Color::Green, 'E' | 'G' | 'R' | 'M' | 'Z' | 'W' | 'K' => Color::Green,
'!' => Color::Magenta, '!' => Color::Magenta,
'0'..='9' => Color::Grey, '0'..='9' => Color::Grey,
'a'..='z' => Color::DarkYellow, 'a'..='z' => Color::DarkYellow,
'@' | '^' | '?' => Color::Red,
_ => Color::White, _ => 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 { struct Renderer {
cells: Vec<(char, Cs)>, cells: Vec<(char, Cs)>,
status: String, status: String,
@@ -89,26 +110,70 @@ impl Renderer {
} }
} }
pub fn run( pub fn run(mut s: Session, mut grid: Grid) -> io::Result<()> {
prog: Arc<ArcSwap<Program>>,
voices: Arc<AtomicU32>,
mut grid: Grid,
latency: u32,
) -> io::Result<()> {
enable_raw_mode()?; enable_raw_mode()?;
let mut out = io::stdout(); let mut out = io::stdout();
execute!(out, EnterAlternateScreen, Hide)?; execute!(out, EnterAlternateScreen, Hide)?;
let (mut cx, mut cy) = (0usize, 0usize); let (mut cx, mut cy) = (0usize, 0usize);
let mut help = false; let mut help = false;
let mut r = Renderer::new(); 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<()> { let res = (|| -> io::Result<()> {
loop { 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))? { if !event::poll(Duration::from_millis(33))? {
continue; continue;
} }
let Event::Key(k) = event::read()? else { continue }; let Event::Key(k) = event::read()? else {
continue;
};
if k.kind != KeyEventKind::Press { if k.kind != KeyEventKind::Press {
continue; continue;
} }
@@ -124,6 +189,20 @@ pub fn run(
} }
} }
KeyCode::Char('c') if ctrl => return Ok(()), 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, KeyCode::Char('?') => help = !help,
_ if help => {} _ if help => {}
KeyCode::Char('(') => { KeyCode::Char('(') => {
@@ -177,7 +256,7 @@ pub fn run(
cy = cy.min(grid.h - 1); cy = cy.min(grid.h - 1);
} }
if resized || edited { if resized || edited {
prog.store(Arc::new(compile(&grid))); s.prog.store(Arc::new(compile(&grid)));
} }
} }
})(); })();
@@ -187,25 +266,45 @@ pub fn run(
res 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( fn draw(
out: &mut io::Stdout, out: &mut io::Stdout,
grid: &Grid, grid: &Grid,
cx: usize, cx: usize,
cy: usize, cy: usize,
voices: u32, status: &str,
latency: u32,
help: bool, help: bool,
r: &mut Renderer, r: &mut Renderer,
) -> io::Result<()> { ) -> io::Result<()> {
let (tw, th) = terminal::size()?; let (tw, th) = terminal::size()?;
let full = !r.init let full =
|| tw != r.tw !r.init || tw != r.tw || th != r.th || help != r.help || grid.w != r.gw || grid.h != r.gh;
|| th != r.th
|| help != r.help
|| grid.w != r.gw
|| grid.h != r.gh;
if full { if full {
queue!(out, Clear(ClearType::All))?; queue!(out, Clear(ClearType::All))?;
r.cells = vec![('\0', Cs::Fg(Color::Reset)); grid.w * grid.h]; r.cells = vec![('\0', Cs::Fg(Color::Reset)); grid.w * grid.h];
@@ -221,7 +320,7 @@ fn draw(
if help { if help {
if full { if full {
let oy = (th as usize).saturating_sub(HELP.len()) / 2; 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() { for (i, line) in HELP.iter().enumerate() {
queue!( queue!(
out, out,
@@ -242,7 +341,11 @@ fn draw(
let raw = grid.get(col, row); let raw = grid.get(col, row);
// ruler mark on empty intersections, for orientation while sizing // ruler mark on empty intersections, for orientation while sizing
let disp = if raw == '.' { let disp = if raw == '.' {
if row % RULER == 0 && col % RULER == 0 { '+' } else { '.' } if row % RULER == 0 && col % RULER == 0 {
'+'
} else {
'.'
}
} else { } else {
raw raw
}; };
@@ -258,7 +361,11 @@ fn draw(
continue; continue;
} }
r.cells[idx] = (disp, cs); 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 { match cs {
Cs::Cursor => queue!(out, SetAttribute(Attribute::Reverse))?, Cs::Cursor => queue!(out, SetAttribute(Attribute::Reverse))?,
Cs::Fg(color) => queue!(out, SetForegroundColor(color))?, Cs::Fg(color) => queue!(out, SetForegroundColor(color))?,
@@ -267,10 +374,8 @@ fn draw(
} }
} }
let status =
format!("voices {voices} {}x{} latency {latency}f ? help Esc quit", grid.w, grid.h);
if status != r.status { if status != r.status {
r.status = status.clone(); r.status = status.to_string();
let pad: String = format!("{status:<width$}", width = tw as usize); let pad: String = format!("{status:<width$}", width = tw as usize);
queue!( queue!(
out, 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());
}
}