diff --git a/README.md b/README.md index 6e3d366..7074e89 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,14 @@ # galdr A terminal **grid synthesizer**. Signal flows downward through a field of -single-glyph unit generators; every incoming MIDI note voices the whole grid; -the mix is rendered low-latency to PipeWire. It is orca's voice — the same -esoteric, spatial approach to programming, but for making sound instead of -sending events. +single-glyph unit generators; every incoming MIDI note voices the grid (or the +columns tagged with its channel); the mix is rendered low-latency to PipeWire. +It is orca's voice — the same esoteric, spatial approach to programming, but +for making sound instead of sending events. ``` -orca-rs ──MIDI/OSC/UDP──▶ galdr ──PipeWire──▶ speakers - compose the pattern voice it (spectrust -p visualizes it) +orca-rs ──MIDI + UDP──▶ galdr ──PipeWire──▶ speakers / stems + compose the pattern voice it (spectrust -p visualizes it) ``` ## The grid @@ -32,11 +32,179 @@ a constant parameter; a lowercase letter `a`–`z` reads MIDI CC (`a` = CC 0). | `D` | decimate / bitcrush | rate | | `G` | gain | amount | | `R` | ring modulator | ratio | +| `X` | FM: sine at the note pitch, phase pushed by the cell to its **left** (and above) | — / index | | `M` | mix — adds the cell to its **left** | — | +| `K` | control envelope: 1 at note-on, decays to a floor; no audio input | decay / floor | | `!` | output to master | — | +`K` is the one operator whose output is a *param*: placed to the **left** of +another operator it becomes that operator's left param, scaled 0–9. `2KL5` is +a lowpass whose cutoff starts open and decays (2) to the floor, with +resonance 5. That's the 303 filter envelope. + Each `!` sums into the master, so parallel columns stack timbres or voices. -A note plays every column at once. +An untagged column plays on every MIDI channel. + +### Params + +A param cell is a digit `0`–`9` (constant), a lowercase letter `a`–`z` (a +register: MIDI CC `a` = CC 0, or set over UDP), or one of three per-note +values: `@` velocity, `^` key tracking (note number), `?` a random value rolled +at note-on. All read on the same 0–9 scale, so `@L` is velocity-to-cutoff and +`?O` detunes every hit a little differently. + +### Channel tags + +A base-36 glyph directly **above** a column's top operator is that column's +MIDI channel: `0`–`9`, `a`–`f` for channels 1–16 (orca's numbering). The +column then only voices notes on that channel. A `>` right of the tag makes +the channel **mono-legato**: a note that overlaps the previous one slides the +running voice to the new pitch over 60 ms without retriggering the envelopes, +the 303 slide. From orca, make the step's note length longer than the gap. + +### Acid + +`examples/acid.galdr`, a 303 line on channel 1: + +``` +...0> +...A. saw (U for the square) +.2KL@ K sweeps the cutoff (decay 2, floor open-to-closed); accent = velocity -> resonance +...E1 short amp decay +..3S. overdrive +...!. +``` + +``` +1....2.... column 0 is channel 1 (a filtered saw), +A....O.... column 5 is channel 2 (a plain sine) +L....E.... +E....!.... +!......... +``` + +### The master chain + +Operators placed **below** a column's `!` no longer run per voice: they +process the summed stereo master, in series, in column order. Any filter or +shaper works there, plus two master-only effects: + +| glyph | effect | left / right param | +|---|---|---| +| `Z` | delay | time (0 = 100 ms … 9 = 1 s) / feedback | +| `W` | stereo chorus | rate / depth | + +``` +A +! +5L lowpass on the whole mix +3Z6 then a 400 ms delay with lots of feedback +.W then a slow chorus for width +``` + +Editing the grid recompiles and hot-swaps the running program without dropping +audio. There is no embedded scope — run a spectrum analyzer (e.g. `spectrust`) +on the side if you want one. + +## A drum kit (one drum per MIDI channel) + +A note voices every column tagged with its channel, so give each drum its own +channel and let orca pick the channel per hit. `examples/drums.galdr`: + +``` +.0..9....a...b. +.A..O9...N...N. +@L5.EX9.6B5.8H. +.E2..E1..E1..E0 +2S...!..3S...!. +.!.......!..... +``` + +- **bass** (tag `0`, MIDI ch 1): saw → lowpass with the cutoff from velocity + (`@`) and resonance 5 → envelope, release 2 → soft clip 2 → out. +- **kick** (tag `9`, ch 10): the left column is a sine into an envelope with + no `!`, it exists only as a modulator. The `X` next to it is a sine at the + note pitch, phase-modulated by that decaying signal with index 9: bright at + the hit, a clean low sine a moment later. Play it a low note (C1–C2). +- **snare** (tag `a`, ch 11): noise → bandpass 6, resonance 5 → short + envelope → soft clip 3. A mid note sets the bandpass body. +- **hats** (tag `b`, ch 12): noise → highpass 8 → envelope with release 0. + Note length from orca is the open/closed control. + +From orca, `:` with the channel glyph per drum, e.g. `:93C` for the kick, +`:a3D` snare, `:b5F` hats, `:02C` bass. With `--stems 12` each drum also has +its own `galdr.ch*` node to record. + +## An orchestra (the big example) + +`examples/orchestra.galdr` is ten sections on ten MIDI channels, each in a +four-column block `[left param | chain | right param | gap]`, so every digit +is a knob. It uses most of the vocabulary: velocity (`@`) into a filter and +into an FM index, key tracking (`^`), per-note random (`?`), two modulator +columns feeding `X`, registers set from orca (`k` and `z`), and a hall on the +master chain (highpass, chorus, delay with feedback from `z`). + +``` +.0...1...2...3...44..5...6...7...8...9.. +.A...A...A2.2U...NO.1U..5U...O7..A...V.. +@L2.3L3.1EX@4L1.9HE36B6.6L1..EX3^L?.kL2. +4E5.3E4..1E23E3.1E!.2E2.2E2..0E40E3.7E7. +.!..2S...2S.1S..2G..1F...!....!..!..9G.. +1H...!....!..!...!...!...............!.. +2W3..................................... +4Zz..................................... +``` + +| ch | tag | section | how | +|---|---|---|---| +| 1 | `0` | strings | saw, lowpass cutoff from velocity, slow envelope; the hall hangs under its `!` | +| 2 | `1` | low strings | saw, lowpass 3/3, soft clip | +| 3 | `2` | brass | a saw-envelope modulator column drives `X`, FM index from velocity | +| 4 | `3` | horns | narrow pulse, lowpass, soft clip | +| 5 | `4` | flute | breath noise (highpass, short env, gain) beside a sine | +| 6 | `5` | reed | pulse width 1, resonant bandpass, fold | +| 7 | `6` | clarinet | square, lowpass | +| 8 | `7` | timpani | sine-envelope modulator into `X`, long release, play C1 to C2 | +| 9 | `8` | harp | lowpass cutoff key-tracked, resonance random per pluck, pluck envelope | +| 10 | `9` | choir | triangle, lowpass cutoff from register `k`, very slow envelope | + +Load it with `galdr examples/orchestra.galdr`, or over UDP with `#orchestra` +once it sits in the patches directory. `;k5` and `;z3` from orca set the choir +brightness and the hall feedback live. + +## Stems + +`--stems n` adds one mono PipeWire node per channel tag, `galdr.ch0` … +`galdr.ch` (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/.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 +`/name.galdr` (`--patches` overrides the directory). Example — a filtered saw with an envelope: @@ -47,22 +215,24 @@ E ...enveloped by the note gate ! ...to master ``` -Editing the grid recompiles and hot-swaps the running program without dropping -audio. There is no embedded scope — run a spectrum analyzer (e.g. `spectrust`) -on the side if you want one. - ## Usage ``` -galdr [--latency ] [--poly ] [--rate ] [--midi-name ] +galdr [--latency ] [--poly ] [--rate ] [--stems ] + [--udp ] [--patches ] [--midi-name ] [patch.galdr] ``` galdr creates a virtual MIDI input port named **galdr**; connect orca or any sequencer/DAW to it. `--midi-name` connects to an existing port instead. -Keys: cursor keys move; type a glyph to place it; Backspace clears; `?` toggles -help; Esc quits. Resize the grid with `(` / `)` (width) and `_` / `+` (height) +Keys: cursor keys move; type a glyph to place it; Backspace clears; `Ctrl+S` +saves the patch; `?` toggles help; Esc quits. Resize the grid with `(` / `)` (width) and `_` / `+` (height) by one ruler division, or Ctrl+arrows by one cell (orca's bindings). +galdr survives a PipeWire restart: the status line shows `PIPEWIRE DOWN — +reconnecting…` and the stream comes back on its own, voices and master-chain +state intact. + Runtime deps: PipeWire and ALSA (MIDI). Built in Rust; reuses the native -PipeWire path from spectrust. +PipeWire path from spectrust. `GALDR_DEBUG=` appends every event that +reaches the engine, handy when orca and galdr disagree. diff --git a/examples/acid.galdr b/examples/acid.galdr new file mode 100644 index 0000000..2536df2 --- /dev/null +++ b/examples/acid.galdr @@ -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. +...!. diff --git a/examples/drums.galdr b/examples/drums.galdr new file mode 100644 index 0000000..06814ef --- /dev/null +++ b/examples/drums.galdr @@ -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...!. +.!.......!..... diff --git a/examples/orchestra.galdr b/examples/orchestra.galdr new file mode 100644 index 0000000..3d57e66 --- /dev/null +++ b/examples/orchestra.galdr @@ -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..................................... diff --git a/src/audio.rs b/src/audio.rs index 334e084..cc3391d 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -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`, 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>; -#[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, voices: Arc, - sr: u32, - channels: u8, - latency: u32, - poly: usize, + connected: Arc, + cfg: Config, ) -> Result<(), String> { let (ready_tx, ready_rx) = std::sync::mpsc::channel::>(); 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, prog: Shared, - mut events: Consumer, voices: Arc, - sr: u32, - channels: u8, - latency: u32, - poly: usize, - ready: Sender>, + 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, 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>, + cfg: &Config, + connected: &Arc, + ready: &mut Option>>, ) -> 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 = 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(()) } diff --git a/src/dsp.rs b/src/dsp.rs index f35e21d..7082483 100644 --- a/src/dsp.rs +++ b/src/dsp.rs @@ -1,21 +1,36 @@ -//! Grid -> compiled signal program, and the per-sample voice evaluator. +//! Grid -> compiled signal program, and the per-sample evaluators. //! -//! 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`. +//! The grid is the editor surface only. On every edit it compiles to flat node +//! lists (topological by construction: signal flows downward, so a cell's +//! input - the cell above - is always evaluated first; `M`/`X` read the cell to +//! the LEFT, whose column was compiled earlier). The audio thread never sees the +//! grid, only the compiled `Program`. //! //! Glyph convention: UPPERCASE letters are operators, digits 0-9 are constant -//! params, lowercase a-z read MIDI CC (a = CC 0). A neighbour to the left/right -//! supplies a parameter; `M` instead reads the left cell as a second signal. +//! params, lowercase a-z read registers (MIDI CC or UDP; a = CC 0). `@` `^` `?` +//! are per-note params (velocity, key tracking, random). A neighbour to the +//! left/right supplies a parameter; `M` and `X` instead read the left cell as a +//! second signal. +//! +//! Two spatial rules extend the grid beyond a single voice program: +//! * a base-36 glyph directly ABOVE a column's top operator is that column's +//! MIDI channel tag (0-9, a-f = channel 1-16); untagged columns are omni. +//! * operators BELOW a column's `!` form the master chain: they process the +//! summed stereo output (in column order, serially) instead of each voice. use crate::grid::{Grid, MAX_W, SLOTS}; pub const MAX_STATE: usize = 4 * SLOTS; pub const TAU: f32 = std::f32::consts::TAU; const PI: f32 = std::f32::consts::PI; +/// Master-chain delay buffers available per program (Z / W nodes beyond this +/// pass audio through unchanged). +pub const MAX_BUFS: usize = 4; +/// Length of one delay buffer in seconds. +pub const BUF_SECONDS: f32 = 1.0; +pub const NO_BUF: usize = usize::MAX; -#[derive(Clone, Copy)] +#[derive(Clone, Copy, PartialEq, Debug)] pub enum Op { Sine, Saw, @@ -34,27 +49,62 @@ pub enum Op { Ring, Mix, Out, + /// Phase modulation: a sine at the note pitch whose phase is pushed by the + /// left cell's signal (and the cell above, if any). Right param = index. + Fm, + /// Master-chain delay: left = time, right = feedback. + Delay, + /// Master-chain stereo chorus: left = rate, right = depth. + Chorus, + /// `K`: control envelope, no audio input. Outputs a level that starts at + /// 1 on note-on and decays (left = decay time) down to a floor (right). + /// Placed LEFT of an operator it becomes that operator's left param: + /// `2KL5` sweeps the lowpass cutoff from open down to the floor. + Ctrl, } -#[derive(Clone, Copy)] +#[derive(Clone, Copy, PartialEq, Debug)] pub enum Param { Const(f32), Reg(usize), + /// `@` note velocity + Vel, + /// `^` key tracking (note number) + Key, + /// `?` a random value rolled at note-on + Rand, + /// A control signal from the `K` cell to the left (its slot in `s`). + Sig(usize), None, } +/// Per-note values a `Param` may read. The master chain has no note, so it +/// passes `None` and the note params read as absent. +#[derive(Clone, Copy)] +pub struct NoteCtx { + pub vel: f32, + pub note: u8, + pub rand: f32, +} + impl Param { + /// All params are on the digit scale 0..=9 so operators treat a constant, + /// a register and a note value alike. #[inline] - fn get(self, regs: &[f32]) -> Option { + fn get(self, regs: &[f32], note: Option, sig: &[f32]) -> Option { match self { Param::Const(c) => Some(c), - Param::Reg(i) => Some(regs[i]), + Param::Reg(i) => Some(regs[i] * 9.0), + Param::Vel => note.map(|n| n.vel * 9.0), + Param::Key => note.map(|n| n.note as f32 / 127.0 * 9.0), + Param::Rand => note.map(|n| n.rand * 9.0), + Param::Sig(i) => Some((sig[i] * 9.0).clamp(0.0, 9.0)), Param::None => None, } } } -#[derive(Clone, Copy)] +#[derive(Clone, Copy, Debug)] pub struct Node { pub op: Op, pub out: usize, @@ -62,15 +112,27 @@ pub struct Node { pub lsig: Option, pub left: Param, pub right: Param, + /// First persistent state slot; `ss` slots per channel. pub st: usize, + pub ss: usize, + /// MIDI channel tag of the column (None = omni). + pub ch: Option, + pub col: u8, + /// Delay buffer slot for Z / W, or NO_BUF. + pub buf: usize, } pub struct Program { + /// Voice nodes, column-major (topological). pub nodes: Vec, + /// Master chains: one serial chain per column that has operators below `!`. + pub post: Vec>, pub has_env: bool, + /// Channels tagged `>`: mono-legato, overlapping notes slide. + pub legato: [bool; 16], } -/// (operator, persistent state slots per voice) +/// (operator, persistent state slots per channel) fn op_of(c: char) -> Option<(Op, usize)> { Some(match c { 'O' => (Op::Sine, 1), @@ -90,14 +152,25 @@ fn op_of(c: char) -> Option<(Op, usize)> { 'R' => (Op::Ring, 1), 'M' => (Op::Mix, 0), '!' => (Op::Out, 0), + 'X' => (Op::Fm, 1), + 'Z' => (Op::Delay, 1), + 'W' => (Op::Chorus, 1), + 'K' => (Op::Ctrl, 0), _ => return None, }) } +pub fn is_operator(c: char) -> bool { + op_of(c).is_some() +} + 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::Vel, + '^' => Param::Key, + '?' => Param::Rand, _ => Param::None, } } @@ -106,13 +179,100 @@ fn occupied(c: char) -> bool { !matches!(c, ' ' | '.' | '\0') } +/// orca's base-36 digit: 0-9, a-z (A-Z fold to the same values). +pub fn b36(c: char) -> Option { + match c { + '0'..='9' => Some(c as u8 - b'0'), + 'a'..='z' => Some(c as u8 - b'a' + 10), + 'A'..='Z' => Some(c as u8 - b'A' + 10), + _ => None, + } +} + +/// The channel tag of a column: the glyph directly above its top operator, +/// if it is a base-36 digit below 16. The bool is the slide flag: a `>` +/// right of the tag. +pub fn column_tag(grid: &Grid, col: usize) -> Option<(u8, bool)> { + let top = (0..grid.h).find(|&r| is_operator(grid.get(col, r)))?; + if top == 0 { + return None; + } + let tag = b36(grid.get(col, top - 1)).filter(|&v| v < 16)?; + Some((tag, grid.get(col + 1, top - 1) == '>')) +} + +/// A `K` to the left of an operator feeds that operator's left param. +fn left_param(grid: &Grid, col: usize, row: usize) -> Param { + if col == 0 { + return Param::None; + } + let c = grid.get(col - 1, row); + if matches!(op_of(c), Some((Op::Ctrl, _))) { + Param::Sig(row * MAX_W + col - 1) + } else { + param_of(c) + } +} + pub fn compile(grid: &Grid) -> Program { let mut nodes = Vec::new(); + let mut post: Vec> = Vec::new(); let mut st = 0usize; let mut has_env = false; - for row in 0..grid.h { - for col in 0..grid.w { - let Some((op, ssize)) = op_of(grid.get(col, row)) else { continue }; + let mut bufs = 0usize; + let mut legato = [false; 16]; + for col in 0..grid.w { + let tagged = column_tag(grid, col); + let tag = tagged.map(|(t, _)| t); + if let Some((t, true)) = tagged { + legato[t as usize] = true; + } + let mut below_out = false; + let mut chain: Vec = Vec::new(); + for row in 0..grid.h { + let Some((op, ssize)) = op_of(grid.get(col, row)) else { + continue; + }; + let lsig = if col > 0 { + Some(row * MAX_W + col - 1) + } else { + None + }; + let left = left_param(grid, col, row); + let right = if col + 1 < grid.w { + param_of(grid.get(col + 1, row)) + } else { + Param::None + }; + let out = row * MAX_W + col; + if below_out { + // Master chain: input is the previous post node in this column + // (None = the master bus itself). Stereo => two state sets. + let input = chain.last().map(|n: &Node| n.out); + let buf = if matches!(op, Op::Delay | Op::Chorus) { + let b = if bufs < MAX_BUFS { bufs } else { NO_BUF }; + bufs += 1; + b + } else { + NO_BUF + }; + let this_st = st; + st = (st + ssize * 2).min(MAX_STATE - 8); + chain.push(Node { + op, + out, + input, + lsig: None, + left, + right, + st: this_st, + ss: ssize, + ch: None, + col: col as u8, + buf, + }); + continue; + } if matches!(op, Op::Env) { has_env = true; } @@ -121,23 +281,55 @@ pub fn compile(grid: &Grid) -> Program { } else { None }; - let lsig = if col > 0 { Some(row * MAX_W + col - 1) } else { None }; - let left = if col > 0 { param_of(grid.get(col - 1, row)) } else { Param::None }; - let right = if col + 1 < grid.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 * MAX_W + col, input, lsig, left, right, st: this_st }); + st = (st + ssize).min(MAX_STATE - 8); + nodes.push(Node { + op, + out, + input, + lsig, + left, + right, + st: this_st, + ss: ssize, + ch: tag, + col: col as u8, + buf: NO_BUF, + }); + if matches!(op, Op::Out) { + below_out = true; + } + } + if !chain.is_empty() { + post.push(chain); } } - Program { nodes, has_env } + Program { + nodes, + post, + has_env, + legato, + } } pub struct Voice { pub active: bool, pub note: u8, + pub ch: u8, pub freq: f32, pub gate: f32, pub vel: f32, + pub rand: f32, + /// A bang voice plays only this column. + pub only_col: Option, + /// Frames until a bang voice's gate closes. + pub trig_left: u32, + /// Bang voice: take the channel from the column's tag at next render. + pub resolve_ch: bool, + /// Frames since note-on (drives `K`). + pub t: u32, + /// Slide target; `freq` glides towards it. + pub target: f32, pub env_level: f32, pub age: u64, pub rng: u32, @@ -149,21 +341,35 @@ impl Voice { Voice { active: false, note: 0, + ch: 0, freq: 0.0, gate: 0.0, vel: 0.0, + rand: 0.0, + only_col: None, + trig_left: 0, + resolve_ch: false, + t: 0, + target: 0.0, env_level: 0.0, age: 0, rng: 0x1234_5678, state: vec![0.0; MAX_STATE], } } + + #[inline] + pub fn next_rand(&mut self) -> f32 { + self.rng ^= self.rng << 13; + self.rng ^= self.rng >> 17; + self.rng ^= self.rng << 5; + self.rng as f32 / u32::MAX as f32 + } } #[inline] fn cutoff_hz(v: f32) -> f32 { - let n = if v > 1.0 { v / 9.0 } else { v }; - 40.0 * 400f32.powf(n.clamp(0.0, 1.0)) + 40.0 * 400f32.powf((v / 9.0).clamp(0.0, 1.0)) } #[inline] @@ -197,16 +403,86 @@ fn res_k(right: Option) -> f32 { (1.5 - right.unwrap_or(3.0) * 0.15).clamp(0.15, 1.5) } +/// The note-independent processors, shared by voices and the master chain. +/// `state` is the node's own slice. Returns None for ops this does not handle. #[inline] +fn fx(op: Op, inp: f32, l: Option, r: Option, state: &mut [f32], sr: f32) -> Option { + Some(match op { + Op::Low | Op::High | Op::Band => { + let fc = cutoff_hz(l.unwrap_or(9.0)); + let k = res_k(r); + let (a, b) = state.split_at_mut(1); + let (lo, ba, hi) = svf(inp, fc, k, sr, &mut a[0], &mut b[0]); + match op { + Op::High => hi, + Op::Band => ba, + _ => lo, + } + } + Op::Soft => { + let drive = l.filter(|&x| x > 0.0).unwrap_or(1.0); + (inp * drive).tanh() + } + Op::Clip => { + let t = l.map(|x| (x / 9.0).clamp(0.05, 1.0)).unwrap_or(0.5); + inp.clamp(-t, t) + } + Op::Fold => { + let amt = 1.0 + l.unwrap_or(1.0); + (inp * amt * PI * 0.5).sin() + } + Op::Decimate => { + let factor = 1.0 + l.unwrap_or(4.0) * 3.0; + let held_val = state[0]; + state[1] += 1.0; + if state[1] >= factor { + state[1] = 0.0; + state[0] = inp; + inp + } else { + held_val + } + } + Op::Gain => { + let g = l + .map(|x| if x > 1.0 { x / 4.5 } else { x * 2.0 }) + .unwrap_or(1.0); + inp * g + } + _ => return None, + }) +} + 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; + let note = Some(NoteCtx { + vel: v.vel, + note: v.note, + rand: v.rand, + }); for n in &p.nodes { + // A bang voice addresses one column directly and ignores channel tags. + match v.only_col { + Some(c) if c != n.col => continue, + Some(_) => {} + None => { + if n.ch.is_some_and(|c| c != v.ch) { + continue; + } + } + } 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 l = n.left.get(regs, note, &s); + let r = n.right.get(regs, note, &s); let y = match n.op { + Op::Ctrl => { + // decay 0..9 -> 20 ms .. 1.6 s; floor 0..9 -> 0..1 + let decay = 0.02 + l.unwrap_or(3.0).powi(2) * 0.02; + let floor = r.map(|x| x / 9.0).unwrap_or(0.0); + floor + (1.0 - floor) * (-(v.t as f32) / (decay * sr)).exp() + } Op::Sine => { let ratio = l.filter(|&x| x > 0.0).unwrap_or(1.0); (phasor(&mut v.state[n.st], v.freq * ratio / sr) * TAU).sin() @@ -217,29 +493,14 @@ pub fn eval_voice(v: &mut Voice, p: &Program, regs: &[f32], sr: f32) -> f32 { } Op::Pulse => { let w = l.map(|x| (x / 9.0).clamp(0.05, 0.95)).unwrap_or(0.5); - if phasor(&mut v.state[n.st], v.freq / sr) < w { 1.0 } else { -1.0 } - } - Op::Tri => 4.0 * (phasor(&mut v.state[n.st], v.freq / sr) - 0.5).abs() - 1.0, - Op::Noise => { - 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::Low | Op::High | Op::Band => { - let fc = cutoff_hz(l.unwrap_or(9.0)); - let k = res_k(r); - let (ic1, ic2) = { - let (a, b) = v.state.split_at_mut(n.st + 1); - (&mut a[n.st], &mut b[0]) - }; - let (lo, ba, hi) = svf(inp, fc, k, sr, ic1, ic2); - match n.op { - Op::High => hi, - Op::Band => ba, - _ => lo, + if phasor(&mut v.state[n.st], v.freq / sr) < w { + 1.0 + } else { + -1.0 } } + Op::Tri => 4.0 * (phasor(&mut v.state[n.st], v.freq / sr) - 0.5).abs() - 1.0, + Op::Noise => v.next_rand() * 2.0 - 1.0, Op::Env => { 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); @@ -252,46 +513,29 @@ pub fn eval_voice(v: &mut Voice, p: &Program, regs: &[f32], sr: f32) -> f32 { } inp * *level * v.vel } - Op::Soft => { - let drive = l.filter(|&x| x > 0.0).unwrap_or(1.0); - (inp * drive).tanh() - } - Op::Clip => { - let t = l.map(|x| (x / 9.0).clamp(0.05, 1.0)).unwrap_or(0.5); - inp.clamp(-t, t) - } - Op::Fold => { - let amt = 1.0 + l.unwrap_or(1.0); - (inp * amt * PI * 0.5).sin() - } - Op::Decimate => { - let factor = 1.0 + l.unwrap_or(4.0) * 3.0; - let held = &mut v.state[n.st]; - let held_val = *held; - let ph = &mut v.state[n.st + 1]; - *ph += 1.0; - if *ph >= factor { - *ph = 0.0; - v.state[n.st] = inp; - inp - } else { - held_val - } - } - Op::Gain => { - let g = l.map(|x| if x > 1.0 { x / 4.5 } else { x * 2.0 }).unwrap_or(1.0); - inp * g - } Op::Ring => { let ratio = l.filter(|&x| x > 0.0).unwrap_or(1.0); let m = (phasor(&mut v.state[n.st], v.freq * ratio / sr) * TAU).sin(); inp * m } + Op::Fm => { + // modulator = left cell (+ the cell above); index in cycles + let index = r.map(|x| x * 0.25).unwrap_or(0.5); + let m = n.lsig.map(|i| s[i]).unwrap_or(0.0) + inp; + let ph = phasor(&mut v.state[n.st], v.freq / sr); + ((ph + m * index) * TAU).sin() + } Op::Mix => inp + n.lsig.map(|i| s[i]).unwrap_or(0.0), Op::Out => { out += inp; inp } + // delay/chorus only make sense on the master chain; pass through + Op::Delay | Op::Chorus => inp, + op => { + let end = (n.st + n.ss).min(v.state.len()); + fx(op, inp, l, r, &mut v.state[n.st..end], sr).unwrap_or(inp) + } }; s[n.out] = y; } @@ -300,13 +544,106 @@ pub fn eval_voice(v: &mut Voice, p: &Program, regs: &[f32], sr: f32) -> f32 { env_max = v.gate; } v.env_level = env_max; + v.t = v.t.saturating_add(1); out } +/// Run the master chains over one stereo frame. `state` is the engine's +/// post-chain state (two sets per node), `bufs` the delay buffers +/// (`2 * buf_len` floats each: left half, right half). +#[allow(clippy::too_many_arguments)] +pub fn eval_post( + p: &Program, + mut m: [f32; 2], + regs: &[f32], + state: &mut [f32], + bufs: &mut [Vec], + buf_len: usize, + pos: &mut usize, + sr: f32, +) -> [f32; 2] { + if p.post.is_empty() { + return m; + } + let w = *pos; + for chain in &p.post { + let mut s = [[0f32; 2]; SLOTS]; + let mut last = m; + for n in chain { + let l = n.left.get(regs, None, &[]); + let r = n.right.get(regs, None, &[]); + let inp = n.input.map(|i| s[i]).unwrap_or(m); + let mut y = inp; + for c in 0..2 { + let st = n.st + c * n.ss; + y[c] = match n.op { + Op::Delay => { + if n.buf == NO_BUF { + inp[c] + } else { + let time = + ((l.unwrap_or(4.0) + 1.0) / 10.0 * BUF_SECONDS * sr) as usize; + let fb = r.map(|x| x / 10.0).unwrap_or(0.3); + let buf = &mut bufs[n.buf][c * buf_len..(c + 1) * buf_len]; + let rd = (w + buf_len - time.clamp(1, buf_len - 1)) % buf_len; + let d = buf[rd]; + buf[w] = inp[c] + d * fb; + inp[c] + d * 0.6 + } + } + Op::Chorus => { + if n.buf == NO_BUF { + inp[c] + } else { + let rate = 0.1 + l.unwrap_or(2.0) * 0.4; + let depth = (1.0 + r.unwrap_or(4.0)) * 0.001 * sr; + let centre = 0.02 * sr; + // one LFO per node, right channel a half cycle behind + let ph = &mut state[n.st]; + if c == 0 { + phasor(ph, rate / sr); + } + let lfo = ((*ph + if c == 1 { 0.5 } else { 0.0 }) * TAU).sin(); + let dly = centre + lfo * depth; + let buf = &mut bufs[n.buf][c * buf_len..(c + 1) * buf_len]; + let rpos = (w as f32 + buf_len as f32 - dly) % buf_len as f32; + let i0 = rpos as usize % buf_len; + let i1 = (i0 + 1) % buf_len; + let fr = rpos - rpos.floor(); + let d = buf[i0] * (1.0 - fr) + buf[i1] * fr; + buf[w] = inp[c]; + (inp[c] + d) * 0.7 + } + } + // voice-only ops have no meaning here: pass through + Op::Sine + | Op::Saw + | Op::Pulse + | Op::Tri + | Op::Noise + | Op::Env + | Op::Ring + | Op::Fm + | Op::Mix + | Op::Out => inp[c], + op => { + let end = (st + n.ss).min(state.len()); + fx(op, inp[c], l, r, &mut state[st..end], sr).unwrap_or(inp[c]) + } + }; + } + s[n.out] = y; + last = y; + } + m = last; + } + *pos = (w + 1) % buf_len; + m +} + #[cfg(test)] mod tests { use super::*; - use crate::grid::Grid; fn grid_from(src: &str) -> Grid { let mut g = Grid::new(); @@ -328,110 +665,381 @@ mod tests { } fn render(v: &mut Voice, p: &Program, n: usize) -> Vec { - let regs = [0.0f32; 128]; + let regs = vec![0.0; 128]; (0..n).map(|_| eval_voice(v, p, ®s, 48000.0)).collect() } #[test] fn compile_ignores_non_operators() { - // digits and dots are params/empty, not nodes - let p = compile(&grid_from("O5.\n!..")); - assert_eq!(p.nodes.len(), 2); // O and ! + let p = compile(&grid_from("5.x\n...\n")); + assert!(p.nodes.is_empty()); } #[test] fn compile_wires_input_from_above() { - // O over ! -> the ! node's input is the O's slot - let p = compile(&grid_from("O\n!")); - let out_node = p.nodes.iter().find(|n| matches!(n.op, Op::Out)).unwrap(); - assert!(out_node.input.is_some()); + let p = compile(&grid_from("A\nL\n!")); + assert_eq!(p.nodes.len(), 3); + assert_eq!(p.nodes[0].input, None); + assert_eq!(p.nodes[1].input, Some(0)); + assert_eq!(p.nodes[2].input, Some(MAX_W)); } #[test] fn compile_reads_left_param() { - // '5' left of L becomes a constant param - let p = compile(&grid_from("5L")); - let lp = p.nodes.iter().find(|n| matches!(n.op, Op::Low)).unwrap(); - assert!(matches!(lp.left, Param::Const(x) if (x - 5.0).abs() < 1e-6)); + let p = compile(&grid_from(".A\n5L")); + assert!(matches!(p.nodes[1].left, Param::Const(x) if x == 5.0)); } #[test] fn compile_lowercase_left_is_cc_reg() { - let p = compile(&grid_from("aG")); - let g = p.nodes.iter().find(|n| matches!(n.op, Op::Gain)).unwrap(); - assert!(matches!(g.left, Param::Reg(0))); + let p = compile(&grid_from("cO")); + assert!(matches!(p.nodes[0].left, Param::Reg(2))); + } + + #[test] + fn compile_punctuation_params() { + let p = compile(&grid_from("@L?\n^G.")); + assert_eq!(p.nodes[0].left, Param::Vel); + assert_eq!(p.nodes[0].right, Param::Rand); + assert_eq!(p.nodes[1].left, Param::Key); } #[test] fn compile_detects_envelope() { - assert!(compile(&grid_from("O\nE\n!")).has_env); - assert!(!compile(&grid_from("O\n!")).has_env); + assert!(compile(&grid_from("A\nE")).has_env); + assert!(!compile(&grid_from("A\n!")).has_env); + } + + #[test] + fn channel_tag_above_top_operator() { + // column 0 tagged channel 3, column 2 tagged 'a' = 10, column 4 untagged + let g = grid_from("3.a..\nA.O.U\n!.!.!"); + assert_eq!(column_tag(&g, 0), Some((3, false))); + assert_eq!(column_tag(&g, 2), Some((10, false))); + assert_eq!(column_tag(&g, 4), None); + let p = compile(&g); + let tags: Vec<_> = p.nodes.iter().map(|n| (n.col, n.ch)).collect(); + assert_eq!( + tags, + vec![ + (0, Some(3)), + (0, Some(3)), + (2, Some(10)), + (2, Some(10)), + (4, None), + (4, None) + ] + ); + } + + #[test] + fn k_left_of_an_operator_is_its_param() { + let p = compile(&grid_from(".A.\n2KL5\n.!.")); + // nodes column-major: col1 K? no: K at col1 row1, A col2 row0... check L's left + let l = p.nodes.iter().find(|n| n.op == Op::Low).unwrap(); + assert_eq!(l.left, Param::Sig(MAX_W + 1)); + assert!(matches!(l.right, Param::Const(x) if x == 5.0)); + let k = p.nodes.iter().find(|n| n.op == Op::Ctrl).unwrap(); + assert!(matches!(k.left, Param::Const(x) if x == 2.0)); + } + + #[test] + fn k_decays_from_one_to_its_floor() { + // K decay 0 (20 ms), floor 3 (= 1/3): output is the K cell itself via Out below it + let p = compile(&grid_from("0K3\n.!.")); + let mut v = voice(440.0, 1.0, 1.0); + let regs = vec![0.0; 128]; + let first = eval_voice(&mut v, &p, ®s, 48000.0); + for _ in 0..4800 { + eval_voice(&mut v, &p, ®s, 48000.0); + } + let late = eval_voice(&mut v, &p, ®s, 48000.0); + assert!((first - 1.0).abs() < 1e-3, "starts at 1, got {first}"); + assert!( + (late - 1.0 / 3.0).abs() < 1e-2, + "settles at the floor, got {late}" + ); + } + + #[test] + fn k_sweeps_a_lowpass() { + // saw -> lowpass whose cutoff is the K cell: bright first, dull later + let p = compile(&grid_from(".A.\n1KL2\n.!.")); + let mut v = voice(110.0, 1.0, 1.0); + let regs = vec![0.0; 128]; + let hf = + |x: &[f32]| x.windows(2).map(|w| (w[1] - w[0]).abs()).sum::() / x.len() as f32; + let early: Vec = (0..2000) + .map(|_| eval_voice(&mut v, &p, ®s, 48000.0)) + .collect(); + for _ in 0..20000 { + eval_voice(&mut v, &p, ®s, 48000.0); + } + let late: Vec = (0..2000) + .map(|_| eval_voice(&mut v, &p, ®s, 48000.0)) + .collect(); + assert!( + hf(&early) > 2.0 * hf(&late), + "cutoff should have swept down" + ); + } + + #[test] + fn slide_flag_right_of_tag() { + let g = grid_from("3>..\nA...\n!..."); + assert_eq!(column_tag(&g, 0), Some((3, true))); + assert!(compile(&g).legato[3]); + assert!(!compile(&grid_from("3\nA\n!")).legato[3]); + } + + #[test] + fn tag_out_of_range_is_omni() { + let g = grid_from("z\nA\n!"); + assert_eq!(column_tag(&g, 0), None); + } + + #[test] + fn tagged_column_is_silent_for_other_channels() { + let p = compile(&grid_from("2\nA\n!")); + let mut v = voice(440.0, 1.0, 1.0); + v.ch = 2; + assert!(render(&mut v, &p, 50).iter().any(|x| x.abs() > 0.1)); + let mut v = voice(440.0, 1.0, 1.0); + v.ch = 5; + assert!(render(&mut v, &p, 50).iter().all(|x| x.abs() < 1e-9)); + } + + #[test] + fn bang_voice_ignores_channel_tag() { + let p = compile(&grid_from("3\nA\n!")); + let mut v = voice(440.0, 1.0, 1.0); + v.ch = 0; + v.only_col = Some(0); + assert!(render(&mut v, &p, 10).iter().any(|x| x.abs() > 0.1)); + } + + #[test] + fn bang_voice_plays_only_its_column() { + let p = compile(&grid_from("A.A\n!.!")); + let mut v = voice(440.0, 1.0, 1.0); + v.only_col = Some(2); + let both = render(&mut voice(440.0, 1.0, 1.0), &p, 10); + let one = render(&mut v, &p, 10); + // two identical saws summed vs one + assert!((both[5] - 2.0 * one[5]).abs() < 1e-5); } #[test] fn empty_grid_is_silent() { - let p = compile(&grid_from("...\n...")); - let mut v = voice(440.0, 1.0, 1.0); - assert!(render(&mut v, &p, 64).iter().all(|&y| y == 0.0)); + let p = compile(&Grid::new()); + let out = render(&mut voice(440.0, 1.0, 1.0), &p, 100); + assert!(out.iter().all(|&x| x == 0.0)); } #[test] fn sine_is_bounded_and_audible() { let p = compile(&grid_from("O\n!")); - let mut v = voice(440.0, 1.0, 1.0); - let out = render(&mut v, &p, 2048); - assert!(out.iter().all(|&y| y.abs() <= 1.0 + 1e-6)); - assert!(out.iter().cloned().fold(0.0, f32::max) > 0.5); // reaches amplitude + let out = render(&mut voice(440.0, 1.0, 1.0), &p, 480); + assert!(out.iter().all(|x| x.abs() <= 1.0 + 1e-6)); + assert!(out.iter().any(|x| x.abs() > 0.5)); } #[test] fn saw_first_sample_is_minus_one() { - // phase starts at 0 -> saw = 2*0-1 = -1 let p = compile(&grid_from("A\n!")); - let mut v = voice(440.0, 1.0, 1.0); - assert!((render(&mut v, &p, 1)[0] - (-1.0)).abs() < 1e-6); + let out = render(&mut voice(440.0, 1.0, 1.0), &p, 1); + assert!((out[0] + 1.0).abs() < 1e-6); } #[test] fn outputs_sum_across_columns() { - // two saw->out columns: first sample -1 + -1 = -2 let p = compile(&grid_from("A.A\n!.!")); - let mut v = voice(440.0, 1.0, 1.0); - assert!((render(&mut v, &p, 1)[0] - (-2.0)).abs() < 1e-6); + let out = render(&mut voice(440.0, 1.0, 1.0), &p, 1); + assert!((out[0] + 2.0).abs() < 1e-6); } #[test] fn no_envelope_is_gated_by_note() { - let p = compile(&grid_from("A\n!")); - let mut open = voice(440.0, 1.0, 1.0); - let mut shut = voice(440.0, 0.0, 1.0); - assert!(render(&mut open, &p, 1)[0].abs() > 0.5); - assert_eq!(render(&mut shut, &p, 1)[0], 0.0); // gate 0 -> silent + let p = compile(&grid_from("O\n!")); + let out = render(&mut voice(440.0, 0.0, 1.0), &p, 10); + assert!(out.iter().all(|&x| x == 0.0)); } #[test] fn envelope_rises_then_releases() { let p = compile(&grid_from("O\nE\n!")); let mut v = voice(440.0, 1.0, 1.0); - render(&mut v, &p, 8000); // ~sustain reached - let peak = v.env_level; - assert!(peak > 0.9, "env should rise to sustain, got {peak}"); + render(&mut v, &p, 4800); + assert!(v.env_level > 0.9); v.gate = 0.0; - render(&mut v, &p, 24000); // release (~0.5s >> 0.15s release) - assert!(v.env_level < 0.1, "env should decay after note off, got {}", v.env_level); + render(&mut v, &p, 96000); + assert!(v.env_level < 1e-4); } #[test] fn mix_adds_left_cell() { - // (1,0)=A saw(-1); (1,1)=M reads above(empty=0) + left cell A(-1) = -1 - let p = compile(&grid_from("A.\nAM\n.!")); + let p = compile(&grid_from("AA\nAM\n.!")); + let out = render(&mut voice(440.0, 1.0, 1.0), &p, 1); + assert!((out[0] + 2.0).abs() < 1e-6); + } + + #[test] + fn fm_without_modulator_is_a_sine() { + let fm = compile(&grid_from("X\n!")); + let sine = compile(&grid_from("O\n!")); + let a = render(&mut voice(440.0, 1.0, 1.0), &fm, 200); + let b = render(&mut voice(440.0, 1.0, 1.0), &sine, 200); + assert!(a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 1e-5)); + } + + #[test] + fn fm_with_left_modulator_differs_from_sine() { + let fm = compile(&grid_from("OX9\n.!.")); + let sine = compile(&grid_from(".O\n.!")); + let a = render(&mut voice(440.0, 1.0, 1.0), &fm, 200); + let b = render(&mut voice(440.0, 1.0, 1.0), &sine, 200); + assert!(a.iter().all(|x| x.abs() <= 1.0 + 1e-6)); + assert!(a.iter().zip(&b).any(|(x, y)| (x - y).abs() > 0.1)); + } + + #[test] + fn register_params_use_digit_scale() { + let p = compile(&grid_from(".A\naG\n.!")); + let mut regs = vec![0.0; 128]; + regs[0] = 1.0; // full CC -> gain param 9 -> x2 let mut v = voice(440.0, 1.0, 1.0); - assert!((render(&mut v, &p, 1)[0] - (-1.0)).abs() < 1e-6); + let out: Vec = (0..1) + .map(|_| eval_voice(&mut v, &p, ®s, 48000.0)) + .collect(); + assert!((out[0] + 2.0).abs() < 1e-5); // saw first sample -1 * gain 2 } #[test] fn cutoff_hz_is_monotonic() { - assert!(cutoff_hz(0.0) < cutoff_hz(5.0)); - assert!(cutoff_hz(5.0) < cutoff_hz(9.0)); + let mut last = 0.0; + for i in 0..=9 { + let f = cutoff_hz(i as f32); + assert!(f > last); + last = f; + } + assert!((cutoff_hz(0.0) - 40.0).abs() < 1e-3); + assert!((cutoff_hz(9.0) - 16000.0).abs() < 1.0); + } + + // ---- master chain ---- + + fn post_env(sr: f32) -> (Vec, Vec>, usize) { + let len = (sr * BUF_SECONDS) as usize; + ( + vec![0.0; MAX_STATE], + (0..MAX_BUFS).map(|_| vec![0.0; 2 * len]).collect(), + len, + ) + } + + #[test] + fn operators_below_out_form_master_chain() { + let p = compile(&grid_from(".A\n.!\n5L\n.Z")); + assert_eq!(p.nodes.len(), 2); + assert_eq!(p.post.len(), 1); + assert_eq!(p.post[0].len(), 2); + assert_eq!(p.post[0][0].op, Op::Low); + assert_eq!(p.post[0][0].input, None); + assert_eq!(p.post[0][1].op, Op::Delay); + assert_eq!(p.post[0][1].input, Some(p.post[0][0].out)); + assert_eq!(p.post[0][1].buf, 0); + } + + #[test] + fn master_chain_without_nodes_is_identity() { + let p = compile(&grid_from("A\n!")); + let (mut st, mut bufs, len) = post_env(48000.0); + let mut pos = 0; + let y = eval_post( + &p, + [0.25, -0.5], + &[0.0; 128], + &mut st, + &mut bufs, + len, + &mut pos, + 48000.0, + ); + assert_eq!(y, [0.25, -0.5]); + } + + #[test] + fn delay_echoes_after_its_time() { + // Z with time digit 0 => 100 ms = 4800 frames + let p = compile(&grid_from(".A\n.!\n0Z")); + let (mut st, mut bufs, len) = post_env(48000.0); + let mut pos = 0; + let regs = [0.0; 128]; + let mut out = Vec::new(); + for i in 0..6000 { + let x = if i == 0 { 1.0 } else { 0.0 }; + out.push( + eval_post( + &p, + [x, x], + ®s, + &mut st, + &mut bufs, + len, + &mut pos, + 48000.0, + )[0], + ); + } + assert!((out[0] - 1.0).abs() < 1e-6); + assert!(out[1..4800].iter().all(|x| x.abs() < 1e-6)); + assert!((out[4800] - 0.6).abs() < 1e-6); + } + + #[test] + fn chorus_is_stereo_and_bounded() { + let p = compile(&grid_from(".A\n.!\n5W9")); + let (mut st, mut bufs, len) = post_env(48000.0); + let mut pos = 0; + let regs = [0.0; 128]; + let mut diff = 0.0f32; + let mut ph = 0.0f32; + for _ in 0..9600 { + let x = (ph * TAU).sin(); + ph = (ph + 220.0 / 48000.0) % 1.0; + let y = eval_post( + &p, + [x, x], + ®s, + &mut st, + &mut bufs, + len, + &mut pos, + 48000.0, + ); + assert!(y[0].abs() <= 1.5 && y[1].abs() <= 1.5); + diff = diff.max((y[0] - y[1]).abs()); + } + assert!(diff > 0.05, "chorus should decorrelate L/R"); + } + + #[test] + fn two_post_columns_apply_in_series() { + // column 1: gain x2 (9 -> 9/4.5 = 2), column 3: gain x2 again + let p = compile(&grid_from(".A.A\n.!.!\n9G9G")); + assert_eq!(p.post.len(), 2); + let (mut st, mut bufs, len) = post_env(48000.0); + let mut pos = 0; + let y = eval_post( + &p, + [0.1, 0.1], + &[0.0; 128], + &mut st, + &mut bufs, + len, + &mut pos, + 48000.0, + ); + assert!((y[0] - 0.4).abs() < 1e-6); } } diff --git a/src/engine.rs b/src/engine.rs index cecd611..25a8327 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -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, 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, + bufs: Vec>, + 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); } } diff --git a/src/grid.rs b/src/grid.rs index b26938e..1c60521 100644 --- a/src/grid.rs +++ b/src/grid.rs @@ -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(); diff --git a/src/main.rs b/src/main.rs index 523cc01..26e793a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 PipeWire quantum; lower = lower latency (default: 256) --poly Number of voices (default: 8) --rate Sample rate (default: 48000) + --stems Add n mono PipeWire nodes galdr.ch0..galdr.ch, one per + channel tag, carrying that channel's dry sum (default: 0) + --udp Listen for orca's `;` UDP strings (default: 49161; + 0 disables) + --patches Patch directory for `#name` loads and dated saves + (default: ~/hometaping) --midi-name Connect to the input port matching 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 /verse.galdr + +With no patch file, galdr opens ~/hometaping/.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 = None; let mut midi_name: Option = None; + let mut file: Option = 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/.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, + mut inputs: Vec>, +) -> std::io::Result<()> { + // GALDR_DEBUG= 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 } diff --git a/src/midi.rs b/src/midi.rs index ec20dd1..592c10f 100644 --- a/src/midi.rs +++ b/src/midi.rs @@ -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, port_name: Option) -> Result>> { +/// Decode one MIDI message into an engine event. +pub fn decode(msg: &[u8]) -> Option { + 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, + port_name: Option, +) -> Result>> { 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| { - 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); } } diff --git a/src/patch.rs b/src/patch.rs new file mode 100644 index 0000000..50f56b6 --- /dev/null +++ b/src/patch.rs @@ -0,0 +1,293 @@ +//! Patch files: the grid's text format on disk. `galdr ` opens one, +//! Ctrl+S writes it back, and `#name` over UDP loads `/name.galdr`. +//! +//! With no file given, galdr works on today's patch, `~/hometaping/.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}") +} + +/// `/.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 { + 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::() / x.len() as f32; + let early: Vec = (0..2000).map(|_| e.render(&p)[0]).collect(); + for _ in 0..12000 { + e.render(&p); + } + let late: Vec = (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); + } +} diff --git a/src/tui.rs b/src/tui.rs index aa761c9..a3d718e 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -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>, + pub voices: Arc, + pub connected: Arc, + pub latency: u32, + pub ctrl: Receiver, + pub patches: PathBuf, + pub file: Option, + pub udp_port: Option, +} + struct Renderer { cells: Vec<(char, Cs)>, status: String, @@ -89,26 +110,70 @@ impl Renderer { } } -pub fn run( - prog: Arc>, - voices: Arc, - 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: 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 { + 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, editor: Sender) -> 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()); + } +}