Compare commits
2
Commits
b53fdd5a6d
...
e836ee0e1d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e836ee0e1d | ||
|
|
4bf13a2bf0 |
Generated
+1
-1
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
# 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)
|
||||||
```
|
```
|
||||||
|
|
||||||
## The grid
|
## The grid
|
||||||
@@ -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
@@ -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')
|
||||||
|
|||||||
@@ -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.....................................
|
||||||
+279
-77
@@ -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
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 {
|
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,65 +74,158 @@ impl Engine {
|
|||||||
self.voices.iter().filter(|v| v.active).count()
|
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) {
|
pub fn apply(&mut self, ev: Event) {
|
||||||
match ev {
|
match ev {
|
||||||
Event::NoteOn { note, vel } => {
|
Event::NoteOn { ch, note, vel } => {
|
||||||
self.clock += 1;
|
let ch = ch % CHANNELS as u8;
|
||||||
// Prefer a free voice, else steal the oldest.
|
let freq = 440.0 * 2f32.powf((note as f32 - 69.0) / 12.0);
|
||||||
let idx = self
|
self.last_freq[ch as usize] = freq;
|
||||||
.voices
|
// Slide: on a legato channel an overlapping note retunes the
|
||||||
.iter()
|
// held voice instead of starting one (envelopes keep running).
|
||||||
.position(|v| !v.active)
|
if self.legato[ch as usize] {
|
||||||
.unwrap_or_else(|| {
|
if let Some(v) = self
|
||||||
let mut best = 0;
|
.voices
|
||||||
for i in 1..self.voices.len() {
|
.iter_mut()
|
||||||
if self.voices[i].age < self.voices[best].age {
|
.find(|v| v.active && v.ch == ch && v.gate > 0.5 && v.only_col.is_none())
|
||||||
best = i;
|
{
|
||||||
}
|
v.target = freq;
|
||||||
}
|
v.note = note;
|
||||||
best
|
return;
|
||||||
});
|
}
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
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() {
|
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
@@ -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
@@ -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
@@ -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 _ = 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 {
|
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
@@ -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
@@ -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
@@ -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