Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
071d1b6d63 | ||
|
|
fded9796fa |
Generated
+1
-1
@@ -320,7 +320,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "galdr"
|
name = "galdr"
|
||||||
version = "0.2.0"
|
version = "0.2.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "galdr"
|
name = "galdr"
|
||||||
version = "0.2.0"
|
version = "0.2.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "A terminal grid synthesizer: signal flows spatially through single-glyph unit generators, voiced by MIDI, rendered low-latency to PipeWire"
|
description = "A terminal grid synthesizer: signal flows spatially through single-glyph unit generators, voiced by MIDI, rendered low-latency to PipeWire"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# Maintainer: Bendik Aagaard Lynghaug <bendik.lynghaug@gmail.com>
|
# Maintainer: Bendik Aagaard Lynghaug <bendik.lynghaug@gmail.com>
|
||||||
pkgname=galdr
|
pkgname=galdr
|
||||||
pkgver=0.2.0
|
pkgver=0.2.1
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Terminal grid synthesizer: signal flows through single-glyph unit generators, MIDI in, low-latency PipeWire out"
|
pkgdesc="Terminal grid synthesizer: signal flows through single-glyph unit generators, MIDI in, low-latency PipeWire out"
|
||||||
arch=('x86_64' 'aarch64')
|
arch=('x86_64' 'aarch64')
|
||||||
|
|||||||
+154
-84
@@ -1,6 +1,8 @@
|
|||||||
//! Grid editor. crossterm renderer adapted from orca-rs's grid view (same
|
//! Grid editor. crossterm renderer adapted from orca-rs's grid view: a centered
|
||||||
//! centered-canvas + per-glyph colour approach); no embedded scope - run a
|
//! canvas with per-glyph colour and a change-diff so only cells that actually
|
||||||
//! spectrum analyzer (e.g. spectrust) on the side if you want one.
|
//! change get repainted (a full clear+redraw each frame flickers over SSH on
|
||||||
|
//! terminals that ignore synchronized-update, e.g. macOS Terminal). `?` toggles
|
||||||
|
//! a detailed help overlay; run a spectrum analyzer on the side if you want one.
|
||||||
|
|
||||||
use std::io::{self, Write};
|
use std::io::{self, Write};
|
||||||
use std::sync::atomic::{AtomicU32, Ordering};
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
@@ -12,30 +14,70 @@ use crossterm::cursor::{Hide, MoveTo, Show};
|
|||||||
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
|
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
|
||||||
use crossterm::style::{Attribute, Color, Print, SetAttribute, SetForegroundColor};
|
use crossterm::style::{Attribute, Color, Print, SetAttribute, SetForegroundColor};
|
||||||
use crossterm::terminal::{
|
use crossterm::terminal::{
|
||||||
disable_raw_mode, enable_raw_mode, BeginSynchronizedUpdate, Clear, ClearType,
|
disable_raw_mode, enable_raw_mode, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen,
|
||||||
EndSynchronizedUpdate, EnterAlternateScreen, LeaveAlternateScreen,
|
|
||||||
};
|
};
|
||||||
use crossterm::{execute, queue, terminal};
|
use crossterm::{execute, queue, terminal};
|
||||||
|
|
||||||
use crate::dsp::{compile, Program};
|
use crate::dsp::{compile, Program};
|
||||||
use crate::grid::{Grid, H, W};
|
use crate::grid::{Grid, H, W};
|
||||||
|
|
||||||
// Foreground colour by glyph category. (orca-rs uses one Normal colour; galdr
|
const HELP: &[&str] = &[
|
||||||
// tints by role so a patch reads at a glance.)
|
"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.",
|
||||||
|
"",
|
||||||
|
"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)",
|
||||||
|
"ROUTING M mix — adds the cell to its LEFT ! output to master",
|
||||||
|
"",
|
||||||
|
"KEYS arrows move · type a glyph to place · Backspace clears",
|
||||||
|
" ? toggle this help · Esc quit",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
|
enum Cs {
|
||||||
|
Fg(Color),
|
||||||
|
Cursor,
|
||||||
|
}
|
||||||
|
|
||||||
fn glyph_color(c: char) -> Color {
|
fn glyph_color(c: char) -> Color {
|
||||||
match c {
|
match c {
|
||||||
'O' | 'A' | 'U' | 'V' | 'N' => Color::Cyan, // sources
|
'O' | 'A' | 'U' | 'V' | 'N' => Color::Cyan,
|
||||||
'L' | 'H' | 'B' => Color::Blue, // filters
|
'L' | 'H' | 'B' => Color::Blue,
|
||||||
'S' | 'C' | 'F' | 'D' => Color::Yellow, // shapers
|
'S' | 'C' | 'F' | 'D' => Color::Yellow,
|
||||||
'E' | 'G' | 'R' | 'M' => Color::Green, // amp / mod
|
'E' | 'G' | 'R' | 'M' => Color::Green,
|
||||||
'!' => Color::Magenta, // output
|
'!' => Color::Magenta,
|
||||||
'0'..='9' => Color::Grey, // constants
|
'0'..='9' => Color::Grey,
|
||||||
'a'..='z' => Color::DarkYellow, // CC registers
|
'a'..='z' => Color::DarkYellow,
|
||||||
'.' => Color::DarkGrey, // empty
|
'.' => Color::DarkGrey,
|
||||||
_ => Color::White,
|
_ => Color::White,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct Renderer {
|
||||||
|
cells: Vec<(char, Cs)>,
|
||||||
|
status: String,
|
||||||
|
tw: u16,
|
||||||
|
th: u16,
|
||||||
|
help: bool,
|
||||||
|
init: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Renderer {
|
||||||
|
fn new() -> Self {
|
||||||
|
Renderer { cells: Vec::new(), status: String::new(), tw: 0, th: 0, help: false, init: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn run(
|
pub fn run(
|
||||||
prog: Arc<ArcSwap<Program>>,
|
prog: Arc<ArcSwap<Program>>,
|
||||||
voices: Arc<AtomicU32>,
|
voices: Arc<AtomicU32>,
|
||||||
@@ -46,61 +88,55 @@ pub fn run(
|
|||||||
let mut out = io::stdout();
|
let mut out = io::stdout();
|
||||||
execute!(out, EnterAlternateScreen, Hide)?;
|
execute!(out, EnterAlternateScreen, Hide)?;
|
||||||
let (mut cx, mut cy) = (0usize, 0usize);
|
let (mut cx, mut cy) = (0usize, 0usize);
|
||||||
|
let mut help = false;
|
||||||
|
let mut r = Renderer::new();
|
||||||
|
|
||||||
let res = main_loop(&mut out, &prog, &voices, &mut grid, latency, &mut cx, &mut cy);
|
let res = (|| -> io::Result<()> {
|
||||||
|
loop {
|
||||||
|
draw(&mut out, &grid, cx, cy, voices.load(Ordering::Relaxed), latency, help, &mut r)?;
|
||||||
|
if !event::poll(Duration::from_millis(33))? {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Event::Key(k) = event::read()? else { continue };
|
||||||
|
if k.kind != KeyEventKind::Press {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let ctrl = k.modifiers.contains(KeyModifiers::CONTROL);
|
||||||
|
match k.code {
|
||||||
|
KeyCode::Esc => {
|
||||||
|
if help {
|
||||||
|
help = false;
|
||||||
|
} else {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('c') if ctrl => return Ok(()),
|
||||||
|
KeyCode::Char('?') => help = !help,
|
||||||
|
_ if help => {} // any other key: ignore while help is open
|
||||||
|
KeyCode::Left => cx = cx.saturating_sub(1),
|
||||||
|
KeyCode::Right => cx = (cx + 1).min(W - 1),
|
||||||
|
KeyCode::Up => cy = cy.saturating_sub(1),
|
||||||
|
KeyCode::Down => cy = (cy + 1).min(H - 1),
|
||||||
|
KeyCode::Backspace | KeyCode::Delete => {
|
||||||
|
grid.set(cx, cy, '.');
|
||||||
|
prog.store(Arc::new(compile(&grid)));
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
grid.set(cx, cy, if c == ' ' { '.' } else { c });
|
||||||
|
prog.store(Arc::new(compile(&grid)));
|
||||||
|
cx = (cx + 1).min(W - 1);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
execute!(out, Show, LeaveAlternateScreen)?;
|
execute!(out, Show, LeaveAlternateScreen)?;
|
||||||
disable_raw_mode()?;
|
disable_raw_mode()?;
|
||||||
res
|
res
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main_loop(
|
#[allow(clippy::too_many_arguments)]
|
||||||
out: &mut io::Stdout,
|
|
||||||
prog: &Arc<ArcSwap<Program>>,
|
|
||||||
voices: &Arc<AtomicU32>,
|
|
||||||
grid: &mut Grid,
|
|
||||||
latency: u32,
|
|
||||||
cx: &mut usize,
|
|
||||||
cy: &mut usize,
|
|
||||||
) -> io::Result<()> {
|
|
||||||
loop {
|
|
||||||
draw(out, grid, *cx, *cy, voices.load(Ordering::Relaxed), latency)?;
|
|
||||||
|
|
||||||
if event::poll(Duration::from_millis(33))? {
|
|
||||||
if let Event::Key(k) = event::read()? {
|
|
||||||
if k.kind != KeyEventKind::Press {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if k.code == KeyCode::Esc
|
|
||||||
|| (k.code == KeyCode::Char('c') && k.modifiers.contains(KeyModifiers::CONTROL))
|
|
||||||
{
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let mut changed = false;
|
|
||||||
match k.code {
|
|
||||||
KeyCode::Left => *cx = cx.saturating_sub(1),
|
|
||||||
KeyCode::Right => *cx = (*cx + 1).min(W - 1),
|
|
||||||
KeyCode::Up => *cy = cy.saturating_sub(1),
|
|
||||||
KeyCode::Down => *cy = (*cy + 1).min(H - 1),
|
|
||||||
KeyCode::Backspace | KeyCode::Delete => {
|
|
||||||
grid.set(*cx, *cy, '.');
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
KeyCode::Char(c) => {
|
|
||||||
grid.set(*cx, *cy, if c == ' ' { '.' } else { c });
|
|
||||||
changed = true;
|
|
||||||
*cx = (*cx + 1).min(W - 1);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
if changed {
|
|
||||||
prog.store(Arc::new(compile(grid)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw(
|
fn draw(
|
||||||
out: &mut io::Stdout,
|
out: &mut io::Stdout,
|
||||||
grid: &Grid,
|
grid: &Grid,
|
||||||
@@ -108,38 +144,72 @@ fn draw(
|
|||||||
cy: usize,
|
cy: usize,
|
||||||
voices: u32,
|
voices: u32,
|
||||||
latency: u32,
|
latency: u32,
|
||||||
|
help: bool,
|
||||||
|
r: &mut Renderer,
|
||||||
) -> io::Result<()> {
|
) -> io::Result<()> {
|
||||||
let (tw, th) = terminal::size()?;
|
let (tw, th) = terminal::size()?;
|
||||||
|
|
||||||
|
// Full repaint only on first frame, resize, or a help toggle - never every
|
||||||
|
// frame (that is the flicker).
|
||||||
|
let full = !r.init || tw != r.tw || th != r.th || help != r.help;
|
||||||
|
if full {
|
||||||
|
queue!(out, Clear(ClearType::All))?;
|
||||||
|
r.cells = vec![('\0', Cs::Fg(Color::Reset)); W * H];
|
||||||
|
r.status.clear();
|
||||||
|
r.tw = tw;
|
||||||
|
r.th = th;
|
||||||
|
r.help = help;
|
||||||
|
r.init = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if help {
|
||||||
|
if full {
|
||||||
|
let oy = (th as usize).saturating_sub(HELP.len()) / 2;
|
||||||
|
let ox = (tw as usize).saturating_sub(74) / 2;
|
||||||
|
for (i, line) in HELP.iter().enumerate() {
|
||||||
|
queue!(
|
||||||
|
out,
|
||||||
|
MoveTo(ox as u16, (oy + i) as u16),
|
||||||
|
SetAttribute(Attribute::Reset),
|
||||||
|
SetForegroundColor(if i == 0 { Color::Magenta } else { Color::Grey }),
|
||||||
|
Print(line)
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.flush();
|
||||||
|
}
|
||||||
|
|
||||||
let off_x = (tw as usize).saturating_sub(W) / 2;
|
let off_x = (tw as usize).saturating_sub(W) / 2;
|
||||||
let off_y = (th as usize).saturating_sub(H + 1) / 2;
|
let off_y = (th as usize).saturating_sub(H + 1) / 2;
|
||||||
|
|
||||||
queue!(out, BeginSynchronizedUpdate, Clear(ClearType::All))?;
|
|
||||||
for row in 0..H {
|
for row in 0..H {
|
||||||
queue!(out, MoveTo(off_x as u16, (off_y + row) as u16))?;
|
|
||||||
for col in 0..W {
|
for col in 0..W {
|
||||||
let c = grid.get(col, row);
|
let c = grid.get(col, row);
|
||||||
queue!(out, SetAttribute(Attribute::Reset))?;
|
let cs = if col == cx && row == cy { Cs::Cursor } else { Cs::Fg(glyph_color(c)) };
|
||||||
if col == cx && row == cy {
|
let idx = row * W + col;
|
||||||
queue!(out, SetAttribute(Attribute::Reverse))?;
|
if r.cells[idx] == (c, cs) {
|
||||||
} else {
|
continue;
|
||||||
queue!(out, SetForegroundColor(glyph_color(c)))?;
|
}
|
||||||
|
r.cells[idx] = (c, cs);
|
||||||
|
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))?,
|
||||||
}
|
}
|
||||||
queue!(out, Print(c))?;
|
queue!(out, Print(c))?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status line beneath the grid.
|
let status = format!("voices {voices} latency {latency}f ? help Esc quit");
|
||||||
let status = format!(
|
if status != r.status {
|
||||||
"voices {voices} latency {latency}f O~ A^ U pulse V tri N noise | L H B filt | S C F D shape | E env G gain R ring M mix | ! out Esc quit"
|
r.status = status.clone();
|
||||||
);
|
let pad: String = format!("{status:<width$}", width = tw as usize);
|
||||||
let s: String = status.chars().take(tw as usize).collect();
|
queue!(
|
||||||
queue!(
|
out,
|
||||||
out,
|
MoveTo(0, th.saturating_sub(1)),
|
||||||
MoveTo(0, th.saturating_sub(1)),
|
SetAttribute(Attribute::Reset),
|
||||||
SetAttribute(Attribute::Reset),
|
SetForegroundColor(Color::DarkGrey),
|
||||||
SetForegroundColor(Color::DarkGrey),
|
Print(pad.chars().take(tw as usize).collect::<String>())
|
||||||
Print(s)
|
)?;
|
||||||
)?;
|
}
|
||||||
queue!(out, EndSynchronizedUpdate)?;
|
|
||||||
out.flush()
|
out.flush()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user