galdr 0.1.0: terminal grid synthesizer (MIDI in, PipeWire out)
Release / build (x86_64, ubuntu-latest, /sccache, x86_64-unknown-linux-gnu) (push) Successful in 2m3s
Release / build (aarch64, aarch64, /var/lib/gitea-runner/sccache, aarch64-unknown-linux-gnu) (push) Successful in 2m46s
Release / update-aur (push) Successful in 23s

Signal flows downward through single-glyph unit generators; each MIDI note
voices the whole grid; the mix renders low-latency to PipeWire (reusing the
native path from spectrust). Grid edits recompile and hot-swap without
dropping audio. Pairs with orca (note/CC source) and spectrust (visualizer).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GLUwWE2KmFPzhKaf67tWbx
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-13 13:24:18 +02:00
co-authored by Claude Opus 4.8
commit d2778e169a
15 changed files with 2467 additions and 0 deletions
+174
View File
@@ -0,0 +1,174 @@
name: Release
on:
push:
tags:
- 'v*'
env:
CARGO_TERM_COLOR: always
jobs:
build:
strategy:
matrix:
include:
- arch: x86_64
target: x86_64-unknown-linux-gnu
runs-on: ubuntu-latest
sccache_dir: /sccache
- arch: aarch64
target: aarch64-unknown-linux-gnu
runs-on: aarch64
sccache_dir: /var/lib/gitea-runner/sccache
runs-on: ${{ matrix.runs-on }}
steps:
- uses: actions/checkout@v4
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
# Compiled-crate cache via sccache. x86_64 uses /sccache (a persistent
# host dir bind-mounted into the job container by the runner config);
# aarch64 runs on the klokka host and uses a runner-owned dir.
- name: Set up sccache
run: |
echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV"
echo "SCCACHE_DIR=${{ matrix.sccache_dir }}" >> "$GITHUB_ENV"
mkdir -p "${{ matrix.sccache_dir }}"
if ! command -v sccache >/dev/null 2>&1; then
V=0.8.2
curl -sSL "https://github.com/mozilla/sccache/releases/download/v${V}/sccache-v${V}-x86_64-unknown-linux-musl.tar.gz" | tar -xz
sudo install -m0755 "sccache-v${V}-x86_64-unknown-linux-musl/sccache" /usr/local/bin/sccache
fi
sccache --version
- name: Ensure build deps (libpipewire for output, alsa for MIDI, clang for bindgen)
run: |
if command -v apt-get >/dev/null; then
# Ephemeral container runner: safe to install into it.
SUDO=""; command -v sudo >/dev/null && SUDO=sudo
$SUDO apt-get update
$SUDO apt-get install -y libpipewire-0.3-dev libspa-0.2-dev libasound2-dev clang libclang-dev pkg-config
else
# Bare/host runner: never mutate it - require deps preinstalled.
missing=""
pkg-config --exists libpipewire-0.3 2>/dev/null || missing="$missing libpipewire(+dev headers)"
pkg-config --exists alsa 2>/dev/null || missing="$missing alsa-lib(+dev headers)"
command -v clang >/dev/null || missing="$missing clang"
if [ -n "$missing" ]; then
echo "::error::bare runner missing build deps:$missing — install once on the host; CI does not modify bare runners"
exit 1
fi
echo "bare runner already has libpipewire + alsa + clang"
fi
- name: Build
run: cargo build --release --locked --target ${{ matrix.target }}
- name: Package
run: |
TAG=${{ gitea.ref_name }}
TARBALL="galdr-${TAG}-${{ matrix.arch }}.tar.gz"
mkdir pkg
cp target/${{ matrix.target }}/release/galdr pkg/
cp LICENSE pkg/
tar -czf "${TARBALL}" -C pkg .
sha256sum "${TARBALL}" > "${TARBALL}.sha256"
echo "TARBALL=${TARBALL}" >> $GITHUB_ENV
- name: Create release
run: |
curl -sX POST \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/json" \
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases" \
-d "{\"tag_name\":\"${{ gitea.ref_name }}\",\"name\":\"${{ gitea.ref_name }}\"}" \
--fail-with-body || true
- name: Upload assets
run: |
RELEASE_ID=$(curl -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/tags/${{ gitea.ref_name }}" \
| jq -r '.id')
for FILE in "${{ env.TARBALL }}" "${{ env.TARBALL }}.sha256"; do
EXISTING=$(curl -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/${RELEASE_ID}/assets" \
| jq -r ".[] | select(.name == \"${FILE}\") | .id")
for AID in $EXISTING; do
curl -sX DELETE \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/${RELEASE_ID}/assets/${AID}"
done
curl -sX POST \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/octet-stream" \
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/${RELEASE_ID}/assets?name=${FILE}" \
--data-binary "@${FILE}" --fail-with-body
done
update-aur:
needs: build
runs-on: aarch64
steps:
- uses: actions/checkout@v4
- name: Compute checksums and update PKGBUILD
run: |
TAG=${{ gitea.ref_name }}
BASE="${{ gitea.server_url }}/${{ gitea.repository }}/releases/download/${TAG}"
SUM_X86=$(curl -sL "${BASE}/galdr-${TAG}-x86_64.tar.gz" | sha256sum | cut -d' ' -f1)
SUM_AARCH=$(curl -sL "${BASE}/galdr-${TAG}-aarch64.tar.gz" | sha256sum | cut -d' ' -f1)
sed -i "s/sha256sums_x86_64=('.*')/sha256sums_x86_64=('${SUM_X86}')/" aur/PKGBUILD
sed -i "s/sha256sums_aarch64=('.*')/sha256sums_aarch64=('${SUM_AARCH}')/" aur/PKGBUILD
# Best-effort mirror to the instance Arch registry via a write:package
# REGISTRY_TOKEN secret; skipped + non-fatal when unset.
- name: Publish to the Arch package registry
continue-on-error: true
run: |
set -euo pipefail
if [ -z "${{ secrets.REGISTRY_TOKEN }}" ]; then
echo "::warning::REGISTRY_TOKEN not set — skipping Arch registry publish"
exit 0
fi
cd aur
for carch in aarch64 x86_64; do
pkgfile=$(CARCH="$carch" makepkg --packagelist | tail -1)
CARCH="$carch" makepkg -f --nodeps --noconfirm --skipinteg
curl --fail-with-body --user "bl:${{ secrets.REGISTRY_TOKEN }}" \
--upload-file "$pkgfile" \
"${{ gitea.server_url }}/api/packages/${{ gitea.repository_owner }}/arch/uhhm"
done
- name: Push to AUR
env:
AUR_SSH_KEY: ${{ secrets.AUR_SSH_KEY }}
run: |
install -dm700 ~/.ssh
echo "$AUR_SSH_KEY" > ~/.ssh/aur
chmod 600 ~/.ssh/aur
echo "Host aur.archlinux.org" >> ~/.ssh/config
echo " IdentityFile ~/.ssh/aur" >> ~/.ssh/config
echo " User aur" >> ~/.ssh/config
ssh-keyscan aur.archlinux.org >> ~/.ssh/known_hosts 2>/dev/null
rm -rf /tmp/aur-galdr
git clone ssh://aur@aur.archlinux.org/galdr.git /tmp/aur-galdr
cp aur/PKGBUILD /tmp/aur-galdr/
cd /tmp/aur-galdr
makepkg --printsrcinfo > .SRCINFO
git config user.name "Bendik Aagaard Lynghaug"
git config user.email "bendik.lynghaug@gmail.com"
git add PKGBUILD .SRCINFO
git commit -m "Update to ${{ gitea.ref_name }}"
git push origin master
+1
View File
@@ -0,0 +1 @@
/target
Generated
+1358
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "galdr"
version = "0.1.0"
edition = "2021"
description = "A terminal grid synthesizer: signal flows spatially through single-glyph unit generators, voiced by MIDI, rendered low-latency to PipeWire"
license = "MIT"
[profile.release]
lto = true
codegen-units = 1
[dependencies]
anyhow = "1"
pipewire = "0.10"
midir = "0.10"
rustfft = "6.2"
arc-swap = "1.7"
rtrb = "0.3"
ratatui = "0.29"
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Bendik Aagaard Lynghaug
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+58
View File
@@ -0,0 +1,58 @@
# galdr
A terminal **grid synthesizer**. Signal flows downward through a field of
single-glyph unit generators; every incoming MIDI note voices the whole grid;
the mix is rendered low-latency to PipeWire. It is orca's voice — the same
esoteric, spatial approach to programming, but for making sound instead of
sending events.
```
orca-rs ──MIDI/OSC/UDP──▶ galdr ──PipeWire──▶ speakers
compose the pattern voice it (spectrust -p visualizes it)
```
## The grid
Signal flows **down**: each cell takes the sample from the cell above as its
input. The left and right neighbour glyphs are parameters — a digit `0`–`9` is
a constant, a letter `a`–`z` reads MIDI CC (`a` = CC 0).
| glyph | unit generator |
|---|---|
| `~` | sine (ratio ← left) |
| `^` | saw (ratio ← left) |
| `_` | pulse (width ← left) |
| `v` | triangle |
| `*` | noise |
| `f` | lowpass (cutoff ← left) |
| `e` | envelope, attack/release ← left/right, gated by the note |
| `s` | shaper / drive ← left |
| `!` | output to master |
Each `!` sums into the master, so parallel columns stack timbres or voices.
A note plays every column at once.
Example — a filtered saw with an envelope:
```
^ saw at the note pitch
f lowpass, cutoff from the digit to its left...
e ...enveloped by the note gate
! ...to master
```
Editing the grid recompiles and hot-swaps the running program without dropping
audio.
## Usage
```
galdr [--latency <frames>] [--poly <n>] [--rate <hz>] [--midi-name <s>]
```
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.
Cursor keys move; type a glyph to place it; Backspace clears; Esc quits.
Runtime deps: PipeWire and ALSA (MIDI). Built in Rust; reuses the native
PipeWire path from spectrust.
+21
View File
@@ -0,0 +1,21 @@
# Maintainer: Bendik Aagaard Lynghaug <bendik.lynghaug@gmail.com>
pkgname=galdr
pkgver=0.1.0
pkgrel=1
pkgdesc="Terminal grid synthesizer: signal flows through single-glyph unit generators, MIDI in, low-latency PipeWire out"
arch=('x86_64' 'aarch64')
options=('!strip')
url="https://project.uhhm.no/bl/galdr"
license=('MIT')
depends=('libpipewire' 'alsa-lib')
provides=('galdr')
conflicts=('galdr-git' 'galdr-bin')
source_x86_64=("galdr-v${pkgver}-x86_64.tar.gz::https://project.uhhm.no/bl/galdr/releases/download/v${pkgver}/galdr-v${pkgver}-x86_64.tar.gz")
source_aarch64=("galdr-v${pkgver}-aarch64.tar.gz::https://project.uhhm.no/bl/galdr/releases/download/v${pkgver}/galdr-v${pkgver}-aarch64.tar.gz")
sha256sums_x86_64=('SKIP')
sha256sums_aarch64=('SKIP')
package() {
install -Dm755 galdr "$pkgdir/usr/bin/galdr"
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
}
+12
View File
@@ -0,0 +1,12 @@
publish = false
allow-branch = ["main"]
[[pre-release-replacements]]
file = "aur/PKGBUILD"
search = "pkgver=.*"
replace = "pkgver={{version}}"
[[pre-release-replacements]]
file = "aur/PKGBUILD"
search = "pkgrel=.*"
replace = "pkgrel=1"
+144
View File
@@ -0,0 +1,144 @@
//! Low-latency PipeWire playback. The RT process callback drains input events,
//! renders the voice mixer, fills the output buffer, and taps the master into a
//! scope ring for the TUI. No allocation or locks on this path.
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::mpsc::Sender;
use std::sync::Arc;
use std::thread;
use arc_swap::ArcSwap;
use pipewire as pw;
use pw::spa;
use pw::{properties::properties, spa::pod::Pod};
use rtrb::{Consumer, Producer};
use spa::param::audio::{AudioFormat, AudioInfoRaw};
use crate::dsp::Program;
use crate::engine::{Engine, Event};
pub type Shared = Arc<ArcSwap<Program>>;
#[allow(clippy::too_many_arguments)]
pub fn spawn(
prog: Shared,
events: Consumer<Event>,
scope: Producer<f32>,
voices: Arc<AtomicU32>,
sr: u32,
channels: u8,
latency: u32,
poly: usize,
) -> Result<(), String> {
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<(), String>>();
thread::Builder::new()
.name("galdr-audio".into())
.spawn(move || {
if let Err(e) =
run(prog, events, scope, voices, sr, channels, latency, poly, ready_tx.clone())
{
let _ = ready_tx.send(Err(e));
}
})
.map_err(|e| format!("spawn audio thread: {e}"))?;
ready_rx.recv().map_err(|_| "audio thread exited before start".to_string())?
}
#[allow(clippy::too_many_arguments)]
fn run(
prog: Shared,
mut events: Consumer<Event>,
mut scope: Producer<f32>,
voices: Arc<AtomicU32>,
sr: u32,
channels: u8,
latency: u32,
poly: usize,
ready: Sender<Result<(), String>>,
) -> Result<(), String> {
pw::init();
let mainloop = pw::main_loop::MainLoopRc::new(None).map_err(|e| format!("mainloop: {e}"))?;
let context = pw::context::ContextRc::new(&mainloop, None).map_err(|e| format!("context: {e}"))?;
let core = context.connect_rc(None).map_err(|e| format!("connect PipeWire: {e}"))?;
let props = properties! {
*pw::keys::MEDIA_TYPE => "Audio",
*pw::keys::MEDIA_CATEGORY => "Playback",
*pw::keys::MEDIA_ROLE => "Music",
*pw::keys::NODE_NAME => "galdr",
*pw::keys::NODE_LATENCY => format!("{latency}/{sr}"),
};
let stream = pw::stream::StreamBox::new(&core, "galdr", props).map_err(|e| format!("stream: {e}"))?;
let ch = channels.max(1) as usize;
let mut engine = Engine::new(poly, sr as f32);
let _listener = stream
.add_local_listener_with_user_data(())
.process(move |stream, _| {
let Some(mut buffer) = stream.dequeue_buffer() else { return };
let datas = buffer.datas_mut();
let Some(data) = datas.first_mut() else { return };
let stride = ch * 4;
let program = prog.load();
// Drain all pending input events once per buffer.
while let Ok(ev) = events.pop() {
engine.apply(ev);
}
let mut n_frames = 0usize;
if let Some(slice) = data.data() {
n_frames = slice.len() / stride;
for f in 0..n_frames {
let smp = engine.render(&program);
let _ = scope.push(smp);
let bytes = smp.to_le_bytes();
for c in 0..ch {
let o = f * stride + c * 4;
slice[o..o + 4].copy_from_slice(&bytes);
}
}
}
let chunk = data.chunk_mut();
*chunk.offset_mut() = 0;
*chunk.stride_mut() = stride as _;
*chunk.size_mut() = (stride * n_frames) as _;
voices.store(engine.active_voices() as u32, Ordering::Relaxed);
})
.register()
.map_err(|e| format!("listener: {e}"))?;
let mut audio_info = AudioInfoRaw::new();
audio_info.set_format(AudioFormat::F32LE);
audio_info.set_rate(sr);
audio_info.set_channels(channels as u32);
let obj = spa::pod::Object {
type_: spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
id: spa::param::ParamType::EnumFormat.as_raw(),
properties: audio_info.into(),
};
let values: Vec<u8> = spa::pod::serialize::PodSerializer::serialize(
std::io::Cursor::new(Vec::new()),
&spa::pod::Value::Object(obj),
)
.map_err(|e| format!("pod: {e}"))?
.0
.into_inner();
let mut params = [Pod::from_bytes(&values).ok_or("bad pod")?];
stream
.connect(
spa::utils::Direction::Output,
None,
pw::stream::StreamFlags::AUTOCONNECT
| pw::stream::StreamFlags::MAP_BUFFERS
| pw::stream::StreamFlags::RT_PROCESS,
&mut params,
)
.map_err(|e| format!("connect stream: {e}"))?;
let _ = ready.send(Ok(()));
mainloop.run();
Ok(())
}
+247
View File
@@ -0,0 +1,247 @@
//! Grid -> compiled signal program, and the per-sample voice evaluator.
//!
//! The grid is the editor surface only. On every edit it compiles to a flat,
//! row-ordered list of nodes (topological by construction: signal flows
//! downward, so a cell's input - the cell above - is always evaluated first).
//! The audio thread never sees the grid, only the compiled `Program`.
use crate::grid::{Grid, H, SLOTS, W};
pub const MAX_STATE: usize = 4 * SLOTS;
pub const TAU: f32 = std::f32::consts::TAU;
#[derive(Clone, Copy)]
pub enum Op {
Sine,
Saw,
Pulse,
Tri,
Noise,
Lpf,
Env,
Shape,
Out,
}
/// A parameter read from a horizontal neighbour glyph.
#[derive(Clone, Copy)]
pub enum Param {
Const(f32),
Reg(usize),
None,
}
impl Param {
#[inline]
fn get(self, regs: &[f32]) -> Option<f32> {
match self {
Param::Const(c) => Some(c),
Param::Reg(i) => Some(regs[i]),
Param::None => None,
}
}
}
#[derive(Clone, Copy)]
pub struct Node {
pub op: Op,
pub out: usize,
pub input: Option<usize>,
pub left: Param,
pub right: Param,
pub st: usize,
}
pub struct Program {
pub nodes: Vec<Node>,
pub has_env: bool,
}
fn op_of(c: char) -> Option<(Op, usize)> {
// (operator, number of persistent state slots it needs per voice)
Some(match c {
'~' => (Op::Sine, 1),
'^' => (Op::Saw, 1),
'_' => (Op::Pulse, 1),
'v' => (Op::Tri, 1),
'*' => (Op::Noise, 0),
'f' => (Op::Lpf, 2),
'e' => (Op::Env, 2),
's' => (Op::Shape, 0),
'!' => (Op::Out, 0),
_ => return None,
})
}
fn param_of(c: char) -> Param {
match c {
'0'..='9' => Param::Const((c as u8 - b'0') as f32),
'a'..='z' => Param::Reg((c as u8 - b'a') as usize),
_ => Param::None,
}
}
fn occupied(c: char) -> bool {
!matches!(c, ' ' | '.' | '\0')
}
pub fn compile(grid: &Grid) -> Program {
let mut nodes = Vec::new();
let mut st = 0usize;
let mut has_env = false;
for row in 0..H {
for col in 0..W {
let Some((op, ssize)) = op_of(grid.get(col, row)) else { continue };
if matches!(op, Op::Env) {
has_env = true;
}
let input = if row > 0 && occupied(grid.get(col, row - 1)) {
Some((row - 1) * W + col)
} else {
None
};
let left = if col > 0 { param_of(grid.get(col - 1, row)) } else { Param::None };
let right = if col + 1 < W { param_of(grid.get(col + 1, row)) } else { Param::None };
let this_st = st;
st = (st + ssize).min(MAX_STATE - 4);
nodes.push(Node { op, out: row * W + col, input, left, right, st: this_st });
}
}
Program { nodes, has_env }
}
/// Per-voice mutable state consumed by the evaluator.
pub struct Voice {
pub active: bool,
pub note: u8,
pub freq: f32,
pub gate: f32,
pub vel: f32,
pub env_level: f32,
pub age: u64,
pub rng: u32,
pub state: Vec<f32>,
}
impl Voice {
pub fn new() -> Self {
Voice {
active: false,
note: 0,
freq: 0.0,
gate: 0.0,
vel: 0.0,
env_level: 0.0,
age: 0,
rng: 0x1234_5678,
state: vec![0.0; MAX_STATE],
}
}
}
#[inline]
fn cutoff_hz(v: f32) -> f32 {
// 0..=9 or a 0..1 CC value -> ~40 Hz .. ~16 kHz, exponential.
let n = if v > 1.0 { v / 9.0 } else { v };
40.0 * 400f32.powf(n.clamp(0.0, 1.0))
}
/// Evaluate one output sample for one voice. Returns the summed `!` outputs.
#[inline]
pub fn eval_voice(v: &mut Voice, p: &Program, regs: &[f32], sr: f32) -> f32 {
let mut s = [0f32; SLOTS];
let mut out = 0.0f32;
let mut env_max = 0.0f32;
for n in &p.nodes {
let inp = n.input.map(|i| s[i]).unwrap_or(0.0);
let l = n.left.get(regs);
let r = n.right.get(regs);
let y = match n.op {
Op::Sine => {
let ratio = l.filter(|&x| x > 0.0).unwrap_or(1.0);
let ph = &mut v.state[n.st];
let y = (*ph * TAU).sin();
*ph += v.freq * ratio / sr;
if *ph >= 1.0 {
*ph -= 1.0;
}
y
}
Op::Saw => {
let ratio = l.filter(|&x| x > 0.0).unwrap_or(1.0);
let ph = &mut v.state[n.st];
let y = 2.0 * *ph - 1.0;
*ph += v.freq * ratio / sr;
if *ph >= 1.0 {
*ph -= 1.0;
}
y
}
Op::Pulse => {
let width = l.map(|x| (x / 9.0).clamp(0.05, 0.95)).unwrap_or(0.5);
let ph = &mut v.state[n.st];
let y = if *ph < width { 1.0 } else { -1.0 };
*ph += v.freq / sr;
if *ph >= 1.0 {
*ph -= 1.0;
}
y
}
Op::Tri => {
let ph = &mut v.state[n.st];
let y = 4.0 * (*ph - 0.5).abs() - 1.0;
*ph += v.freq / sr;
if *ph >= 1.0 {
*ph -= 1.0;
}
y
}
Op::Noise => {
// xorshift32
v.rng ^= v.rng << 13;
v.rng ^= v.rng >> 17;
v.rng ^= v.rng << 5;
(v.rng as f32 / u32::MAX as f32) * 2.0 - 1.0
}
Op::Lpf => {
let fc = cutoff_hz(l.unwrap_or(9.0));
let a = (1.0 - (-TAU * fc / sr).exp()).clamp(0.0, 1.0);
let z = &mut v.state[n.st];
*z += a * (inp - *z);
let _res = r; // reserved for resonance later
*z
}
Op::Env => {
// Attack/Release, sustain = 1. l = attack, r = release (0..9).
let atk = l.map(|x| 0.002 + x * 0.05).unwrap_or(0.01);
let rel = r.map(|x| 0.01 + x * 0.15).unwrap_or(0.15);
let level = &mut v.state[n.st];
let target = if v.gate > 0.5 { 1.0 } else { 0.0 };
let rate = if v.gate > 0.5 { atk } else { rel };
let step = (1.0 / (rate * sr)).min(1.0);
*level += (target - *level) * step;
if *level > env_max {
env_max = *level;
}
inp * *level * v.vel
}
Op::Shape => {
let drive = l.filter(|&x| x > 0.0).unwrap_or(1.0);
(inp * drive).tanh()
}
Op::Out => {
out += inp;
inp
}
};
s[n.out] = y;
}
// With no envelope, gate directly gates the voice so notes don't sustain
// forever.
if !p.has_env {
out *= v.gate * v.vel;
env_max = v.gate;
}
v.env_level = env_max;
out
}
+93
View File
@@ -0,0 +1,93 @@
//! Voice allocation and the polyphonic mixer. Owned by the audio thread.
use crate::dsp::{eval_voice, Program, Voice};
#[derive(Clone, Copy)]
pub enum Event {
NoteOn { note: u8, vel: u8 },
NoteOff { note: u8 },
Cc { idx: u8, val: u8 },
}
pub struct Engine {
voices: Vec<Voice>,
regs: Vec<f32>,
sr: f32,
clock: u64,
}
impl Engine {
pub fn new(poly: usize, sr: f32) -> Self {
Engine {
voices: (0..poly).map(|_| Voice::new()).collect(),
regs: vec![0.0; 128],
sr,
clock: 0,
}
}
pub fn active_voices(&self) -> usize {
self.voices.iter().filter(|v| v.active).count()
}
pub fn apply(&mut self, ev: Event) {
match ev {
Event::NoteOn { note, vel } => {
self.clock += 1;
// Prefer a free voice, else steal the oldest.
let idx = self
.voices
.iter()
.position(|v| !v.active)
.unwrap_or_else(|| {
let mut best = 0;
for i in 1..self.voices.len() {
if self.voices[i].age < self.voices[best].age {
best = i;
}
}
best
});
let v = &mut self.voices[idx];
v.active = true;
v.note = note;
v.freq = 440.0 * 2f32.powf((note as f32 - 69.0) / 12.0);
v.gate = 1.0;
v.vel = vel as f32 / 127.0;
v.age = self.clock;
// fresh phases/env so a stolen voice doesn't click from old state
for x in v.state.iter_mut() {
*x = 0.0;
}
v.env_level = 0.0;
}
Event::NoteOff { note } => {
for v in self.voices.iter_mut() {
if v.active && v.note == note {
v.gate = 0.0;
}
}
}
Event::Cc { idx, val } => {
self.regs[idx as usize] = val as f32 / 127.0;
}
}
}
#[inline]
pub fn render(&mut self, prog: &Program) -> f32 {
let mut mix = 0.0f32;
for v in self.voices.iter_mut() {
if !v.active {
continue;
}
mix += eval_voice(v, prog, &self.regs, self.sr);
// Reap voices whose gate is closed and envelope has decayed.
if v.gate < 0.5 && v.env_level < 1e-4 {
v.active = false;
}
}
// Gentle headroom limiter.
(mix * 0.3).tanh()
}
}
+31
View File
@@ -0,0 +1,31 @@
//! The fixed-size character grid that is the editor surface.
pub const W: usize = 24;
pub const H: usize = 16;
pub const SLOTS: usize = W * H;
#[derive(Clone)]
pub struct Grid {
cells: Vec<char>,
}
impl Grid {
pub fn new() -> Self {
Grid { cells: vec!['.'; SLOTS] }
}
#[inline]
pub fn get(&self, col: usize, row: usize) -> char {
if col < W && row < H {
self.cells[row * W + col]
} else {
'.'
}
}
pub fn set(&mut self, col: usize, row: usize, c: char) {
if col < W && row < H {
self.cells[row * W + col] = c;
}
}
}
+98
View File
@@ -0,0 +1,98 @@
//! galdr - a terminal grid synthesizer.
//!
//! Signal flows downward through single-glyph unit generators; each MIDI note
//! voices the whole grid; the mix is rendered low-latency to PipeWire. Pairs
//! with orca (MIDI/OSC/UDP out) and spectrust (visualize the output).
mod audio;
mod dsp;
mod engine;
mod grid;
mod midi;
mod tui;
use std::sync::atomic::AtomicU32;
use std::sync::Arc;
use arc_swap::ArcSwap;
use crate::dsp::compile;
use crate::grid::Grid;
const HELP: &str = "\
galdr - terminal grid synthesizer
Usage: galdr [options]
Options:
--latency <frames> PipeWire quantum; lower = lower latency (default: 256)
--poly <n> Number of voices (default: 8)
--rate <hz> Sample rate (default: 48000)
--midi-name <s> Connect to the input port matching <s> instead of
creating the virtual 'galdr' port
-h, --help Print this message
Grid glyphs (signal flows down; left/right neighbours are params 0-9 or CC a-z):
~ sine ^ saw _ pulse v tri * noise
f lowpass(cutoff<-left) e envelope(atk<-left,rel<-right) s shaper(drive<-left)
! output to master
";
fn main() -> anyhow::Result<()> {
let mut latency: u32 = 256;
let mut poly: usize = 8;
let mut rate: u32 = 48000;
let channels: u8 = 2;
let mut midi_name: Option<String> = None;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--latency" => latency = args.next().and_then(|v| v.parse().ok()).unwrap_or(latency),
"--poly" => poly = args.next().and_then(|v| v.parse().ok()).unwrap_or(poly),
"--rate" => rate = args.next().and_then(|v| v.parse().ok()).unwrap_or(rate),
"--midi-name" => midi_name = args.next(),
"-h" | "--help" => {
print!("{HELP}");
return Ok(());
}
other => {
eprintln!("unknown option: {other}\n\n{HELP}");
std::process::exit(2);
}
}
}
// A default patch so the first run makes sound: a plain sine->env voice and
// a filtered saw voice.
let mut grid = Grid::new();
grid.set(0, 0, '~');
grid.set(0, 1, 'e');
grid.set(0, 2, '!');
grid.set(2, 0, '^');
grid.set(1, 1, '5');
grid.set(2, 1, 'f');
grid.set(2, 2, 'e');
grid.set(2, 3, '!');
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);
let (prod_sc, cons_sc) = rtrb::RingBuffer::new(16384);
audio::spawn(prog.clone(), cons_ev, prod_sc, 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, cons_sc, voices, grid, latency)?;
Ok(())
}
+41
View File
@@ -0,0 +1,41 @@
//! 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.
use anyhow::{anyhow, Result};
use midir::os::unix::VirtualInput;
use midir::{MidiInput, MidiInputConnection};
use rtrb::Producer;
use crate::engine::Event;
pub fn connect(prod: Producer<Event>, port_name: Option<String>) -> Result<MidiInputConnection<Producer<Event>>> {
let mut input = MidiInput::new("galdr").map_err(|e| anyhow!("MIDI init: {e}"))?;
input.ignore(midir::Ignore::None);
let cb = move |_stamp: u64, msg: &[u8], prod: &mut Producer<Event>| {
if msg.len() < 2 {
return;
}
let status = msg[0] & 0xF0;
let ev = match status {
0x90 if msg[2] > 0 => Event::NoteOn { note: msg[1], vel: msg[2] },
0x80 => Event::NoteOff { note: msg[1] },
0x90 => Event::NoteOff { note: msg[1] }, // note-on velocity 0
0xB0 => Event::Cc { idx: msg[1], val: msg[2] },
_ => return,
};
let _ = prod.push(ev);
};
if let Some(name) = port_name {
let ports = input.ports();
let port = ports
.iter()
.find(|p| input.port_name(p).map(|n| n.contains(&name)).unwrap_or(false))
.cloned()
.ok_or_else(|| anyhow!("no MIDI input port matching {name:?}"))?;
input.connect(&port, "galdr-in", cb, prod).map_err(|e| anyhow!("MIDI connect: {e}"))
} else {
input.create_virtual("galdr", cb, prod).map_err(|e| anyhow!("create virtual MIDI port: {e}"))
}
}
+149
View File
@@ -0,0 +1,149 @@
//! Terminal editor: the grid on the left, a live spectrum of the master on the
//! right, a status line below. Editing recompiles and hot-swaps the program.
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use arc_swap::ArcSwap;
use ratatui::crossterm::event::{self, Event as CEvent, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Paragraph};
use rtrb::Consumer;
use rustfft::{num_complex::Complex, FftPlanner};
use crate::dsp::{compile, Program};
use crate::grid::{Grid, H, W};
const FFT: usize = 1024;
pub fn run(
prog: Arc<ArcSwap<Program>>,
mut scope: Consumer<f32>,
voices: Arc<AtomicU32>,
mut grid: Grid,
latency: u32,
) -> std::io::Result<()> {
let mut terminal = ratatui::init();
let mut samples = vec![0.0f32; FFT];
let mut planner = FftPlanner::new();
let fft = planner.plan_fft_forward(FFT);
let hann: Vec<f32> = (0..FFT)
.map(|i| 0.5 * (1.0 - (std::f32::consts::TAU * i as f32 / FFT as f32).cos()))
.collect();
let (mut cx, mut cy) = (0usize, 0usize);
loop {
// Pull whatever the audio thread produced into the rolling window.
while let Ok(s) = scope.pop() {
samples.rotate_left(1);
samples[FFT - 1] = s;
}
let mut buf: Vec<Complex<f32>> =
samples.iter().zip(&hann).map(|(s, w)| Complex { re: s * w, im: 0.0 }).collect();
fft.process(&mut buf);
let mags: Vec<f32> = buf[..FFT / 2].iter().map(|c| c.norm()).collect();
let nv = voices.load(Ordering::Relaxed);
terminal.draw(|f| ui(f, &grid, cx, cy, &mags, nv, latency))?;
if event::poll(std::time::Duration::from_millis(33))? {
if let CEvent::Key(k) = event::read()? {
if k.kind != KeyEventKind::Press {
continue;
}
if k.code == KeyCode::Esc
|| (k.code == KeyCode::Char('c') && k.modifiers.contains(KeyModifiers::CONTROL))
{
break;
}
let mut changed = false;
match k.code {
KeyCode::Left => cx = cx.saturating_sub(1),
KeyCode::Right => cx = (cx + 1).min(W - 1),
KeyCode::Up => cy = cy.saturating_sub(1),
KeyCode::Down => cy = (cy + 1).min(H - 1),
KeyCode::Backspace | KeyCode::Delete => {
grid.set(cx, cy, '.');
changed = true;
}
KeyCode::Char(c) => {
grid.set(cx, cy, if c == ' ' { '.' } else { c });
changed = true;
}
_ => {}
}
if changed {
prog.store(Arc::new(compile(&grid)));
}
}
}
}
ratatui::restore();
Ok(())
}
fn ui(f: &mut Frame, grid: &Grid, cx: usize, cy: usize, mags: &[f32], voices: u32, latency: u32) {
let root = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(f.area());
let cols = Layout::horizontal([Constraint::Length((W + 3) as u16), Constraint::Min(10)]).split(root[0]);
// Grid pane.
let mut lines = Vec::with_capacity(H);
for row in 0..H {
let mut spans = Vec::with_capacity(W);
for col in 0..W {
let ch = grid.get(col, row);
let mut st = match ch {
'~' | '^' | '_' | 'v' | '*' => Style::default().fg(Color::Cyan),
'f' | 's' => Style::default().fg(Color::Yellow),
'e' => Style::default().fg(Color::Green),
'!' => Style::default().fg(Color::Magenta),
'.' => Style::default().fg(Color::DarkGray),
_ => Style::default().fg(Color::White),
};
if col == cx && row == cy {
st = st.add_modifier(Modifier::REVERSED);
}
spans.push(Span::styled(ch.to_string(), st));
}
lines.push(Line::from(spans));
}
f.render_widget(
Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(" galdr ")),
cols[0],
);
// Spectrum pane.
let area = cols[1];
let inner_w = area.width.saturating_sub(2) as usize;
let inner_h = area.height.saturating_sub(2) as usize;
let mut srows = vec![String::new(); inner_h];
if inner_w > 0 && inner_h > 0 {
let maxm = mags.iter().cloned().fold(1e-6f32, f32::max);
for x in 0..inner_w {
// log-ish bin mapping into the lower spectrum
let frac = (x as f32 / inner_w as f32).powf(1.6);
let bin = ((frac * mags.len() as f32) as usize).min(mags.len() - 1);
let mag = (mags[bin] / maxm).sqrt();
let filled = (mag * inner_h as f32) as usize;
for y in 0..inner_h {
srows[inner_h - 1 - y].push(if y < filled { '█' } else { ' ' });
}
}
}
let scope_lines: Vec<Line> = srows.into_iter().map(|s| Line::from(Span::styled(s, Style::default().fg(Color::Green)))).collect();
f.render_widget(
Paragraph::new(scope_lines).block(Block::default().borders(Borders::ALL).title(" spectrum ")),
area,
);
// Status line.
let status = format!(
" voices {voices} · latency {latency}f · glyphs ~^_v* f e s ! · Esc quit "
);
f.render_widget(
Paragraph::new(status).style(Style::default().fg(Color::Black).bg(Color::Cyan)),
root[1],
);
}