Compare commits
4
Commits
392ceceeff
...
v0.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e836ee0e1d | ||
|
|
4bf13a2bf0 | ||
|
|
b53fdd5a6d | ||
|
|
f9f06568bf |
Generated
+1
-1
@@ -320,7 +320,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "galdr"
|
||||
version = "0.2.3"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "galdr"
|
||||
version = "0.2.3"
|
||||
version = "0.3.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"
|
||||
@@ -11,7 +11,7 @@ codegen-units = 1
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
pipewire = "0.10"
|
||||
pipewire = { version = "0.10", features = ["v0_3_49"] } # Buffer::requested
|
||||
midir = "0.10"
|
||||
arc-swap = "1.7"
|
||||
rtrb = "0.3"
|
||||
|
||||
@@ -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,179 @@ 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 |
|
||||
| `!` | 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.
|
||||
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:
|
||||
|
||||
@@ -47,22 +215,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
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Bendik Aagaard Lynghaug <bendik.lynghaug@gmail.com>
|
||||
pkgname=galdr
|
||||
pkgver=0.2.3
|
||||
pkgver=0.3.0
|
||||
pkgrel=1
|
||||
pkgdesc="Terminal grid synthesizer: signal flows through single-glyph unit generators, MIDI in, low-latency PipeWire out"
|
||||
arch=('x86_64' 'aarch64')
|
||||
|
||||
@@ -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.
|
||||
...!.
|
||||
@@ -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...!.
|
||||
.!.......!.....
|
||||
@@ -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.....................................
|
||||
+281
-72
@@ -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,64 +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(())
|
||||
.process(move |stream, _| {
|
||||
let Some(mut buffer) = stream.dequeue_buffer() else { return };
|
||||
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);
|
||||
.state_changed(move |_, _, _old, new| {
|
||||
if let pw::stream::StreamState::Error(e) = new {
|
||||
eprintln!("\r\n[galdr] stream error: {e}\r");
|
||||
ml.quit();
|
||||
}
|
||||
|
||||
let mut n_frames = 0usize;
|
||||
})
|
||||
.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 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 = 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,
|
||||
@@ -134,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(())
|
||||
}
|
||||
|
||||
+731
-123
File diff suppressed because it is too large
Load Diff
+355
-47
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+293
@@ -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
-41
@@ -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,26 +22,32 @@ 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)",
|
||||
"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 envelope (left=decay, right=floor); LEFT of an operator it drives",
|
||||
" that operator's left param: 2KL5 = filter sweep. tag> = slide (legato channel)",
|
||||
"ROUTING M mix — adds the cell to its LEFT ! output to master",
|
||||
"MASTER Z delay (left=time, right=feedback) W chorus (left=rate, right=depth)",
|
||||
" + 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",
|
||||
" ? toggle this help · Esc quit",
|
||||
];
|
||||
@@ -52,17 +60,30 @@ 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' | 'K' => Color::Green,
|
||||
'!' => 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 +110,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 +189,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('(') => {
|
||||
@@ -169,7 +248,6 @@ pub fn run(
|
||||
KeyCode::Char(c) => {
|
||||
grid.set(cx, cy, if c == ' ' { '.' } else { c });
|
||||
edited = true;
|
||||
cx = (cx + 1).min(grid.w - 1);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -178,7 +256,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)));
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -188,25 +266,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];
|
||||
@@ -222,7 +320,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,
|
||||
@@ -243,7 +341,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
|
||||
};
|
||||
@@ -259,7 +361,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))?,
|
||||
@@ -268,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 {
|
||||
r.status = status.clone();
|
||||
r.status = status.to_string();
|
||||
let pad: String = format!("{status:<width$}", width = tw as usize);
|
||||
queue!(
|
||||
out,
|
||||
|
||||
+170
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user