Channel tags, stems, master chain, FM, K envelope, slide, UDP control, reconnect

Grid grammar:
- a base-36 glyph above a column's top operator tags it with a MIDI channel;
  '>' right of the tag makes the channel mono-legato (60 ms slide)
- operators below a '!' form the stereo master chain: Z delay, W chorus,
  plus any filter/shaper
- X: phase modulation by the cell to the left (and above), index from the right
- K: control envelope; left of an operator it drives that operator's left
  param (2KL5 = 303 filter sweep)
- per-note params: @ velocity, ^ key tracking, ? random; registers now read
  on the digit scale, so MIDI CC and UDP registers have full range

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

Examples with render tests: drums, orchestra, acid.

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