galdr 0.1.0: terminal grid synthesizer (MIDI in, PipeWire out)
Signal flows downward through single-glyph unit generators; each MIDI note voices the whole grid; the mix renders low-latency to PipeWire (reusing the native path from spectrust). Grid edits recompile and hot-swap without dropping audio. Pairs with orca (note/CC source) and spectrust (visualizer). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GLUwWE2KmFPzhKaf67tWbx
This commit is contained in:
+144
@@ -0,0 +1,144 @@
|
||||
//! Low-latency PipeWire playback. The RT process callback drains input events,
|
||||
//! renders the voice mixer, fills the output buffer, and taps the master into a
|
||||
//! scope ring for the TUI. No allocation or locks on this path.
|
||||
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use pipewire as pw;
|
||||
use pw::spa;
|
||||
use pw::{properties::properties, spa::pod::Pod};
|
||||
use rtrb::{Consumer, Producer};
|
||||
use spa::param::audio::{AudioFormat, AudioInfoRaw};
|
||||
|
||||
use crate::dsp::Program;
|
||||
use crate::engine::{Engine, Event};
|
||||
|
||||
pub type Shared = Arc<ArcSwap<Program>>;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn spawn(
|
||||
prog: Shared,
|
||||
events: Consumer<Event>,
|
||||
scope: Producer<f32>,
|
||||
voices: Arc<AtomicU32>,
|
||||
sr: u32,
|
||||
channels: u8,
|
||||
latency: u32,
|
||||
poly: usize,
|
||||
) -> Result<(), String> {
|
||||
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<(), String>>();
|
||||
thread::Builder::new()
|
||||
.name("galdr-audio".into())
|
||||
.spawn(move || {
|
||||
if let Err(e) =
|
||||
run(prog, events, scope, voices, sr, channels, latency, poly, ready_tx.clone())
|
||||
{
|
||||
let _ = ready_tx.send(Err(e));
|
||||
}
|
||||
})
|
||||
.map_err(|e| format!("spawn audio thread: {e}"))?;
|
||||
ready_rx.recv().map_err(|_| "audio thread exited before start".to_string())?
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn run(
|
||||
prog: Shared,
|
||||
mut events: Consumer<Event>,
|
||||
mut scope: Producer<f32>,
|
||||
voices: Arc<AtomicU32>,
|
||||
sr: u32,
|
||||
channels: u8,
|
||||
latency: u32,
|
||||
poly: usize,
|
||||
ready: Sender<Result<(), String>>,
|
||||
) -> Result<(), String> {
|
||||
pw::init();
|
||||
let mainloop = pw::main_loop::MainLoopRc::new(None).map_err(|e| format!("mainloop: {e}"))?;
|
||||
let context = pw::context::ContextRc::new(&mainloop, None).map_err(|e| format!("context: {e}"))?;
|
||||
let core = context.connect_rc(None).map_err(|e| format!("connect PipeWire: {e}"))?;
|
||||
|
||||
let props = properties! {
|
||||
*pw::keys::MEDIA_TYPE => "Audio",
|
||||
*pw::keys::MEDIA_CATEGORY => "Playback",
|
||||
*pw::keys::MEDIA_ROLE => "Music",
|
||||
*pw::keys::NODE_NAME => "galdr",
|
||||
*pw::keys::NODE_LATENCY => format!("{latency}/{sr}"),
|
||||
};
|
||||
let stream = pw::stream::StreamBox::new(&core, "galdr", props).map_err(|e| format!("stream: {e}"))?;
|
||||
|
||||
let ch = channels.max(1) as usize;
|
||||
let mut engine = Engine::new(poly, sr as f32);
|
||||
|
||||
let _listener = stream
|
||||
.add_local_listener_with_user_data(())
|
||||
.process(move |stream, _| {
|
||||
let Some(mut buffer) = stream.dequeue_buffer() else { return };
|
||||
let datas = buffer.datas_mut();
|
||||
let Some(data) = datas.first_mut() else { return };
|
||||
let stride = ch * 4;
|
||||
let program = prog.load();
|
||||
|
||||
// Drain all pending input events once per buffer.
|
||||
while let Ok(ev) = events.pop() {
|
||||
engine.apply(ev);
|
||||
}
|
||||
|
||||
let mut n_frames = 0usize;
|
||||
if let Some(slice) = data.data() {
|
||||
n_frames = slice.len() / stride;
|
||||
for f in 0..n_frames {
|
||||
let smp = engine.render(&program);
|
||||
let _ = scope.push(smp);
|
||||
let bytes = smp.to_le_bytes();
|
||||
for c in 0..ch {
|
||||
let o = f * stride + c * 4;
|
||||
slice[o..o + 4].copy_from_slice(&bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
let chunk = data.chunk_mut();
|
||||
*chunk.offset_mut() = 0;
|
||||
*chunk.stride_mut() = stride as _;
|
||||
*chunk.size_mut() = (stride * n_frames) as _;
|
||||
voices.store(engine.active_voices() as u32, Ordering::Relaxed);
|
||||
})
|
||||
.register()
|
||||
.map_err(|e| format!("listener: {e}"))?;
|
||||
|
||||
let mut audio_info = AudioInfoRaw::new();
|
||||
audio_info.set_format(AudioFormat::F32LE);
|
||||
audio_info.set_rate(sr);
|
||||
audio_info.set_channels(channels as u32);
|
||||
let obj = spa::pod::Object {
|
||||
type_: spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
|
||||
id: spa::param::ParamType::EnumFormat.as_raw(),
|
||||
properties: audio_info.into(),
|
||||
};
|
||||
let values: Vec<u8> = spa::pod::serialize::PodSerializer::serialize(
|
||||
std::io::Cursor::new(Vec::new()),
|
||||
&spa::pod::Value::Object(obj),
|
||||
)
|
||||
.map_err(|e| format!("pod: {e}"))?
|
||||
.0
|
||||
.into_inner();
|
||||
let mut params = [Pod::from_bytes(&values).ok_or("bad pod")?];
|
||||
|
||||
stream
|
||||
.connect(
|
||||
spa::utils::Direction::Output,
|
||||
None,
|
||||
pw::stream::StreamFlags::AUTOCONNECT
|
||||
| pw::stream::StreamFlags::MAP_BUFFERS
|
||||
| pw::stream::StreamFlags::RT_PROCESS,
|
||||
&mut params,
|
||||
)
|
||||
.map_err(|e| format!("connect stream: {e}"))?;
|
||||
|
||||
let _ = ready.send(Ok(()));
|
||||
mainloop.run();
|
||||
Ok(())
|
||||
}
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
//! Grid -> compiled signal program, and the per-sample voice evaluator.
|
||||
//!
|
||||
//! The grid is the editor surface only. On every edit it compiles to a flat,
|
||||
//! row-ordered list of nodes (topological by construction: signal flows
|
||||
//! downward, so a cell's input - the cell above - is always evaluated first).
|
||||
//! The audio thread never sees the grid, only the compiled `Program`.
|
||||
|
||||
use crate::grid::{Grid, H, SLOTS, W};
|
||||
|
||||
pub const MAX_STATE: usize = 4 * SLOTS;
|
||||
pub const TAU: f32 = std::f32::consts::TAU;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum Op {
|
||||
Sine,
|
||||
Saw,
|
||||
Pulse,
|
||||
Tri,
|
||||
Noise,
|
||||
Lpf,
|
||||
Env,
|
||||
Shape,
|
||||
Out,
|
||||
}
|
||||
|
||||
/// A parameter read from a horizontal neighbour glyph.
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum Param {
|
||||
Const(f32),
|
||||
Reg(usize),
|
||||
None,
|
||||
}
|
||||
|
||||
impl Param {
|
||||
#[inline]
|
||||
fn get(self, regs: &[f32]) -> Option<f32> {
|
||||
match self {
|
||||
Param::Const(c) => Some(c),
|
||||
Param::Reg(i) => Some(regs[i]),
|
||||
Param::None => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Node {
|
||||
pub op: Op,
|
||||
pub out: usize,
|
||||
pub input: Option<usize>,
|
||||
pub left: Param,
|
||||
pub right: Param,
|
||||
pub st: usize,
|
||||
}
|
||||
|
||||
pub struct Program {
|
||||
pub nodes: Vec<Node>,
|
||||
pub has_env: bool,
|
||||
}
|
||||
|
||||
fn op_of(c: char) -> Option<(Op, usize)> {
|
||||
// (operator, number of persistent state slots it needs per voice)
|
||||
Some(match c {
|
||||
'~' => (Op::Sine, 1),
|
||||
'^' => (Op::Saw, 1),
|
||||
'_' => (Op::Pulse, 1),
|
||||
'v' => (Op::Tri, 1),
|
||||
'*' => (Op::Noise, 0),
|
||||
'f' => (Op::Lpf, 2),
|
||||
'e' => (Op::Env, 2),
|
||||
's' => (Op::Shape, 0),
|
||||
'!' => (Op::Out, 0),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn param_of(c: char) -> Param {
|
||||
match c {
|
||||
'0'..='9' => Param::Const((c as u8 - b'0') as f32),
|
||||
'a'..='z' => Param::Reg((c as u8 - b'a') as usize),
|
||||
_ => Param::None,
|
||||
}
|
||||
}
|
||||
|
||||
fn occupied(c: char) -> bool {
|
||||
!matches!(c, ' ' | '.' | '\0')
|
||||
}
|
||||
|
||||
pub fn compile(grid: &Grid) -> Program {
|
||||
let mut nodes = Vec::new();
|
||||
let mut st = 0usize;
|
||||
let mut has_env = false;
|
||||
for row in 0..H {
|
||||
for col in 0..W {
|
||||
let Some((op, ssize)) = op_of(grid.get(col, row)) else { continue };
|
||||
if matches!(op, Op::Env) {
|
||||
has_env = true;
|
||||
}
|
||||
let input = if row > 0 && occupied(grid.get(col, row - 1)) {
|
||||
Some((row - 1) * W + col)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let left = if col > 0 { param_of(grid.get(col - 1, row)) } else { Param::None };
|
||||
let right = if col + 1 < W { param_of(grid.get(col + 1, row)) } else { Param::None };
|
||||
let this_st = st;
|
||||
st = (st + ssize).min(MAX_STATE - 4);
|
||||
nodes.push(Node { op, out: row * W + col, input, left, right, st: this_st });
|
||||
}
|
||||
}
|
||||
Program { nodes, has_env }
|
||||
}
|
||||
|
||||
/// Per-voice mutable state consumed by the evaluator.
|
||||
pub struct Voice {
|
||||
pub active: bool,
|
||||
pub note: u8,
|
||||
pub freq: f32,
|
||||
pub gate: f32,
|
||||
pub vel: f32,
|
||||
pub env_level: f32,
|
||||
pub age: u64,
|
||||
pub rng: u32,
|
||||
pub state: Vec<f32>,
|
||||
}
|
||||
|
||||
impl Voice {
|
||||
pub fn new() -> Self {
|
||||
Voice {
|
||||
active: false,
|
||||
note: 0,
|
||||
freq: 0.0,
|
||||
gate: 0.0,
|
||||
vel: 0.0,
|
||||
env_level: 0.0,
|
||||
age: 0,
|
||||
rng: 0x1234_5678,
|
||||
state: vec![0.0; MAX_STATE],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn cutoff_hz(v: f32) -> f32 {
|
||||
// 0..=9 or a 0..1 CC value -> ~40 Hz .. ~16 kHz, exponential.
|
||||
let n = if v > 1.0 { v / 9.0 } else { v };
|
||||
40.0 * 400f32.powf(n.clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
/// Evaluate one output sample for one voice. Returns the summed `!` outputs.
|
||||
#[inline]
|
||||
pub fn eval_voice(v: &mut Voice, p: &Program, regs: &[f32], sr: f32) -> f32 {
|
||||
let mut s = [0f32; SLOTS];
|
||||
let mut out = 0.0f32;
|
||||
let mut env_max = 0.0f32;
|
||||
for n in &p.nodes {
|
||||
let inp = n.input.map(|i| s[i]).unwrap_or(0.0);
|
||||
let l = n.left.get(regs);
|
||||
let r = n.right.get(regs);
|
||||
let y = match n.op {
|
||||
Op::Sine => {
|
||||
let ratio = l.filter(|&x| x > 0.0).unwrap_or(1.0);
|
||||
let ph = &mut v.state[n.st];
|
||||
let y = (*ph * TAU).sin();
|
||||
*ph += v.freq * ratio / sr;
|
||||
if *ph >= 1.0 {
|
||||
*ph -= 1.0;
|
||||
}
|
||||
y
|
||||
}
|
||||
Op::Saw => {
|
||||
let ratio = l.filter(|&x| x > 0.0).unwrap_or(1.0);
|
||||
let ph = &mut v.state[n.st];
|
||||
let y = 2.0 * *ph - 1.0;
|
||||
*ph += v.freq * ratio / sr;
|
||||
if *ph >= 1.0 {
|
||||
*ph -= 1.0;
|
||||
}
|
||||
y
|
||||
}
|
||||
Op::Pulse => {
|
||||
let width = l.map(|x| (x / 9.0).clamp(0.05, 0.95)).unwrap_or(0.5);
|
||||
let ph = &mut v.state[n.st];
|
||||
let y = if *ph < width { 1.0 } else { -1.0 };
|
||||
*ph += v.freq / sr;
|
||||
if *ph >= 1.0 {
|
||||
*ph -= 1.0;
|
||||
}
|
||||
y
|
||||
}
|
||||
Op::Tri => {
|
||||
let ph = &mut v.state[n.st];
|
||||
let y = 4.0 * (*ph - 0.5).abs() - 1.0;
|
||||
*ph += v.freq / sr;
|
||||
if *ph >= 1.0 {
|
||||
*ph -= 1.0;
|
||||
}
|
||||
y
|
||||
}
|
||||
Op::Noise => {
|
||||
// xorshift32
|
||||
v.rng ^= v.rng << 13;
|
||||
v.rng ^= v.rng >> 17;
|
||||
v.rng ^= v.rng << 5;
|
||||
(v.rng as f32 / u32::MAX as f32) * 2.0 - 1.0
|
||||
}
|
||||
Op::Lpf => {
|
||||
let fc = cutoff_hz(l.unwrap_or(9.0));
|
||||
let a = (1.0 - (-TAU * fc / sr).exp()).clamp(0.0, 1.0);
|
||||
let z = &mut v.state[n.st];
|
||||
*z += a * (inp - *z);
|
||||
let _res = r; // reserved for resonance later
|
||||
*z
|
||||
}
|
||||
Op::Env => {
|
||||
// Attack/Release, sustain = 1. l = attack, r = release (0..9).
|
||||
let atk = l.map(|x| 0.002 + x * 0.05).unwrap_or(0.01);
|
||||
let rel = r.map(|x| 0.01 + x * 0.15).unwrap_or(0.15);
|
||||
let level = &mut v.state[n.st];
|
||||
let target = if v.gate > 0.5 { 1.0 } else { 0.0 };
|
||||
let rate = if v.gate > 0.5 { atk } else { rel };
|
||||
let step = (1.0 / (rate * sr)).min(1.0);
|
||||
*level += (target - *level) * step;
|
||||
if *level > env_max {
|
||||
env_max = *level;
|
||||
}
|
||||
inp * *level * v.vel
|
||||
}
|
||||
Op::Shape => {
|
||||
let drive = l.filter(|&x| x > 0.0).unwrap_or(1.0);
|
||||
(inp * drive).tanh()
|
||||
}
|
||||
Op::Out => {
|
||||
out += inp;
|
||||
inp
|
||||
}
|
||||
};
|
||||
s[n.out] = y;
|
||||
}
|
||||
// With no envelope, gate directly gates the voice so notes don't sustain
|
||||
// forever.
|
||||
if !p.has_env {
|
||||
out *= v.gate * v.vel;
|
||||
env_max = v.gate;
|
||||
}
|
||||
v.env_level = env_max;
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Voice allocation and the polyphonic mixer. Owned by the audio thread.
|
||||
|
||||
use crate::dsp::{eval_voice, Program, Voice};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum Event {
|
||||
NoteOn { note: u8, vel: u8 },
|
||||
NoteOff { note: u8 },
|
||||
Cc { idx: u8, val: u8 },
|
||||
}
|
||||
|
||||
pub struct Engine {
|
||||
voices: Vec<Voice>,
|
||||
regs: Vec<f32>,
|
||||
sr: f32,
|
||||
clock: u64,
|
||||
}
|
||||
|
||||
impl Engine {
|
||||
pub fn new(poly: usize, sr: f32) -> Self {
|
||||
Engine {
|
||||
voices: (0..poly).map(|_| Voice::new()).collect(),
|
||||
regs: vec![0.0; 128],
|
||||
sr,
|
||||
clock: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn active_voices(&self) -> usize {
|
||||
self.voices.iter().filter(|v| v.active).count()
|
||||
}
|
||||
|
||||
pub fn apply(&mut self, ev: Event) {
|
||||
match ev {
|
||||
Event::NoteOn { note, vel } => {
|
||||
self.clock += 1;
|
||||
// Prefer a free voice, else steal the oldest.
|
||||
let idx = self
|
||||
.voices
|
||||
.iter()
|
||||
.position(|v| !v.active)
|
||||
.unwrap_or_else(|| {
|
||||
let mut best = 0;
|
||||
for i in 1..self.voices.len() {
|
||||
if self.voices[i].age < self.voices[best].age {
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
best
|
||||
});
|
||||
let v = &mut self.voices[idx];
|
||||
v.active = true;
|
||||
v.note = note;
|
||||
v.freq = 440.0 * 2f32.powf((note as f32 - 69.0) / 12.0);
|
||||
v.gate = 1.0;
|
||||
v.vel = vel as f32 / 127.0;
|
||||
v.age = self.clock;
|
||||
// fresh phases/env so a stolen voice doesn't click from old state
|
||||
for x in v.state.iter_mut() {
|
||||
*x = 0.0;
|
||||
}
|
||||
v.env_level = 0.0;
|
||||
}
|
||||
Event::NoteOff { note } => {
|
||||
for v in self.voices.iter_mut() {
|
||||
if v.active && v.note == note {
|
||||
v.gate = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Cc { idx, val } => {
|
||||
self.regs[idx as usize] = val as f32 / 127.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn render(&mut self, prog: &Program) -> f32 {
|
||||
let mut mix = 0.0f32;
|
||||
for v in self.voices.iter_mut() {
|
||||
if !v.active {
|
||||
continue;
|
||||
}
|
||||
mix += eval_voice(v, prog, &self.regs, self.sr);
|
||||
// Reap voices whose gate is closed and envelope has decayed.
|
||||
if v.gate < 0.5 && v.env_level < 1e-4 {
|
||||
v.active = false;
|
||||
}
|
||||
}
|
||||
// Gentle headroom limiter.
|
||||
(mix * 0.3).tanh()
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
//! The fixed-size character grid that is the editor surface.
|
||||
|
||||
pub const W: usize = 24;
|
||||
pub const H: usize = 16;
|
||||
pub const SLOTS: usize = W * H;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Grid {
|
||||
cells: Vec<char>,
|
||||
}
|
||||
|
||||
impl Grid {
|
||||
pub fn new() -> Self {
|
||||
Grid { cells: vec!['.'; SLOTS] }
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn get(&self, col: usize, row: usize) -> char {
|
||||
if col < W && row < H {
|
||||
self.cells[row * W + col]
|
||||
} else {
|
||||
'.'
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set(&mut self, col: usize, row: usize, c: char) {
|
||||
if col < W && row < H {
|
||||
self.cells[row * W + col] = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
//! galdr - a terminal grid synthesizer.
|
||||
//!
|
||||
//! Signal flows downward through single-glyph unit generators; each MIDI note
|
||||
//! voices the whole grid; the mix is rendered low-latency to PipeWire. Pairs
|
||||
//! with orca (MIDI/OSC/UDP out) and spectrust (visualize the output).
|
||||
|
||||
mod audio;
|
||||
mod dsp;
|
||||
mod engine;
|
||||
mod grid;
|
||||
mod midi;
|
||||
mod tui;
|
||||
|
||||
use std::sync::atomic::AtomicU32;
|
||||
use std::sync::Arc;
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
|
||||
use crate::dsp::compile;
|
||||
use crate::grid::Grid;
|
||||
|
||||
const HELP: &str = "\
|
||||
galdr - terminal grid synthesizer
|
||||
|
||||
Usage: galdr [options]
|
||||
|
||||
Options:
|
||||
--latency <frames> PipeWire quantum; lower = lower latency (default: 256)
|
||||
--poly <n> Number of voices (default: 8)
|
||||
--rate <hz> Sample rate (default: 48000)
|
||||
--midi-name <s> Connect to the input port matching <s> instead of
|
||||
creating the virtual 'galdr' port
|
||||
-h, --help Print this message
|
||||
|
||||
Grid glyphs (signal flows down; left/right neighbours are params 0-9 or CC a-z):
|
||||
~ sine ^ saw _ pulse v tri * noise
|
||||
f lowpass(cutoff<-left) e envelope(atk<-left,rel<-right) s shaper(drive<-left)
|
||||
! output to master
|
||||
";
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let mut latency: u32 = 256;
|
||||
let mut poly: usize = 8;
|
||||
let mut rate: u32 = 48000;
|
||||
let channels: u8 = 2;
|
||||
let mut midi_name: Option<String> = None;
|
||||
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"--latency" => latency = args.next().and_then(|v| v.parse().ok()).unwrap_or(latency),
|
||||
"--poly" => poly = args.next().and_then(|v| v.parse().ok()).unwrap_or(poly),
|
||||
"--rate" => rate = args.next().and_then(|v| v.parse().ok()).unwrap_or(rate),
|
||||
"--midi-name" => midi_name = args.next(),
|
||||
"-h" | "--help" => {
|
||||
print!("{HELP}");
|
||||
return Ok(());
|
||||
}
|
||||
other => {
|
||||
eprintln!("unknown option: {other}\n\n{HELP}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A default patch so the first run makes sound: a plain sine->env voice and
|
||||
// a filtered saw voice.
|
||||
let mut grid = Grid::new();
|
||||
grid.set(0, 0, '~');
|
||||
grid.set(0, 1, 'e');
|
||||
grid.set(0, 2, '!');
|
||||
grid.set(2, 0, '^');
|
||||
grid.set(1, 1, '5');
|
||||
grid.set(2, 1, 'f');
|
||||
grid.set(2, 2, 'e');
|
||||
grid.set(2, 3, '!');
|
||||
|
||||
let prog = Arc::new(ArcSwap::from_pointee(compile(&grid)));
|
||||
let voices = Arc::new(AtomicU32::new(0));
|
||||
|
||||
let (prod_ev, cons_ev) = rtrb::RingBuffer::new(1024);
|
||||
let (prod_sc, cons_sc) = rtrb::RingBuffer::new(16384);
|
||||
|
||||
audio::spawn(prog.clone(), cons_ev, prod_sc, voices.clone(), rate, channels, latency, poly)
|
||||
.map_err(|e| anyhow::anyhow!("audio: {e}"))?;
|
||||
|
||||
// Keep the MIDI connection alive for the program's lifetime.
|
||||
let _midi = match midi::connect(prod_ev, midi_name) {
|
||||
Ok(conn) => Some(conn),
|
||||
Err(e) => {
|
||||
eprintln!("warning: MIDI unavailable ({e}); running without note input");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
tui::run(prog, cons_sc, voices, grid, latency)?;
|
||||
Ok(())
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
//! MIDI input. galdr owns a virtual input port named "galdr" (symmetric with
|
||||
//! orca's virtual output), so orca or any client connects to it in the graph.
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use midir::os::unix::VirtualInput;
|
||||
use midir::{MidiInput, MidiInputConnection};
|
||||
use rtrb::Producer;
|
||||
|
||||
use crate::engine::Event;
|
||||
|
||||
pub fn connect(prod: Producer<Event>, port_name: Option<String>) -> Result<MidiInputConnection<Producer<Event>>> {
|
||||
let mut input = MidiInput::new("galdr").map_err(|e| anyhow!("MIDI init: {e}"))?;
|
||||
input.ignore(midir::Ignore::None);
|
||||
|
||||
let cb = move |_stamp: u64, msg: &[u8], prod: &mut Producer<Event>| {
|
||||
if msg.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let status = msg[0] & 0xF0;
|
||||
let ev = match status {
|
||||
0x90 if msg[2] > 0 => Event::NoteOn { note: msg[1], vel: msg[2] },
|
||||
0x80 => Event::NoteOff { note: msg[1] },
|
||||
0x90 => Event::NoteOff { note: msg[1] }, // note-on velocity 0
|
||||
0xB0 => Event::Cc { idx: msg[1], val: msg[2] },
|
||||
_ => return,
|
||||
};
|
||||
let _ = prod.push(ev);
|
||||
};
|
||||
|
||||
if let Some(name) = port_name {
|
||||
let ports = input.ports();
|
||||
let port = ports
|
||||
.iter()
|
||||
.find(|p| input.port_name(p).map(|n| n.contains(&name)).unwrap_or(false))
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow!("no MIDI input port matching {name:?}"))?;
|
||||
input.connect(&port, "galdr-in", cb, prod).map_err(|e| anyhow!("MIDI connect: {e}"))
|
||||
} else {
|
||||
input.create_virtual("galdr", cb, prod).map_err(|e| anyhow!("create virtual MIDI port: {e}"))
|
||||
}
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
//! Terminal editor: the grid on the left, a live spectrum of the master on the
|
||||
//! right, a status line below. Editing recompiles and hot-swaps the program.
|
||||
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use ratatui::crossterm::event::{self, Event as CEvent, KeyCode, KeyEventKind, KeyModifiers};
|
||||
use ratatui::prelude::*;
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
use rtrb::Consumer;
|
||||
use rustfft::{num_complex::Complex, FftPlanner};
|
||||
|
||||
use crate::dsp::{compile, Program};
|
||||
use crate::grid::{Grid, H, W};
|
||||
|
||||
const FFT: usize = 1024;
|
||||
|
||||
pub fn run(
|
||||
prog: Arc<ArcSwap<Program>>,
|
||||
mut scope: Consumer<f32>,
|
||||
voices: Arc<AtomicU32>,
|
||||
mut grid: Grid,
|
||||
latency: u32,
|
||||
) -> std::io::Result<()> {
|
||||
let mut terminal = ratatui::init();
|
||||
|
||||
let mut samples = vec![0.0f32; FFT];
|
||||
let mut planner = FftPlanner::new();
|
||||
let fft = planner.plan_fft_forward(FFT);
|
||||
let hann: Vec<f32> = (0..FFT)
|
||||
.map(|i| 0.5 * (1.0 - (std::f32::consts::TAU * i as f32 / FFT as f32).cos()))
|
||||
.collect();
|
||||
let (mut cx, mut cy) = (0usize, 0usize);
|
||||
|
||||
loop {
|
||||
// Pull whatever the audio thread produced into the rolling window.
|
||||
while let Ok(s) = scope.pop() {
|
||||
samples.rotate_left(1);
|
||||
samples[FFT - 1] = s;
|
||||
}
|
||||
let mut buf: Vec<Complex<f32>> =
|
||||
samples.iter().zip(&hann).map(|(s, w)| Complex { re: s * w, im: 0.0 }).collect();
|
||||
fft.process(&mut buf);
|
||||
let mags: Vec<f32> = buf[..FFT / 2].iter().map(|c| c.norm()).collect();
|
||||
|
||||
let nv = voices.load(Ordering::Relaxed);
|
||||
terminal.draw(|f| ui(f, &grid, cx, cy, &mags, nv, latency))?;
|
||||
|
||||
if event::poll(std::time::Duration::from_millis(33))? {
|
||||
if let CEvent::Key(k) = event::read()? {
|
||||
if k.kind != KeyEventKind::Press {
|
||||
continue;
|
||||
}
|
||||
if k.code == KeyCode::Esc
|
||||
|| (k.code == KeyCode::Char('c') && k.modifiers.contains(KeyModifiers::CONTROL))
|
||||
{
|
||||
break;
|
||||
}
|
||||
let mut changed = false;
|
||||
match k.code {
|
||||
KeyCode::Left => cx = cx.saturating_sub(1),
|
||||
KeyCode::Right => cx = (cx + 1).min(W - 1),
|
||||
KeyCode::Up => cy = cy.saturating_sub(1),
|
||||
KeyCode::Down => cy = (cy + 1).min(H - 1),
|
||||
KeyCode::Backspace | KeyCode::Delete => {
|
||||
grid.set(cx, cy, '.');
|
||||
changed = true;
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
grid.set(cx, cy, if c == ' ' { '.' } else { c });
|
||||
changed = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if changed {
|
||||
prog.store(Arc::new(compile(&grid)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ratatui::restore();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ui(f: &mut Frame, grid: &Grid, cx: usize, cy: usize, mags: &[f32], voices: u32, latency: u32) {
|
||||
let root = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(f.area());
|
||||
let cols = Layout::horizontal([Constraint::Length((W + 3) as u16), Constraint::Min(10)]).split(root[0]);
|
||||
|
||||
// Grid pane.
|
||||
let mut lines = Vec::with_capacity(H);
|
||||
for row in 0..H {
|
||||
let mut spans = Vec::with_capacity(W);
|
||||
for col in 0..W {
|
||||
let ch = grid.get(col, row);
|
||||
let mut st = match ch {
|
||||
'~' | '^' | '_' | 'v' | '*' => Style::default().fg(Color::Cyan),
|
||||
'f' | 's' => Style::default().fg(Color::Yellow),
|
||||
'e' => Style::default().fg(Color::Green),
|
||||
'!' => Style::default().fg(Color::Magenta),
|
||||
'.' => Style::default().fg(Color::DarkGray),
|
||||
_ => Style::default().fg(Color::White),
|
||||
};
|
||||
if col == cx && row == cy {
|
||||
st = st.add_modifier(Modifier::REVERSED);
|
||||
}
|
||||
spans.push(Span::styled(ch.to_string(), st));
|
||||
}
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
f.render_widget(
|
||||
Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(" galdr ")),
|
||||
cols[0],
|
||||
);
|
||||
|
||||
// Spectrum pane.
|
||||
let area = cols[1];
|
||||
let inner_w = area.width.saturating_sub(2) as usize;
|
||||
let inner_h = area.height.saturating_sub(2) as usize;
|
||||
let mut srows = vec![String::new(); inner_h];
|
||||
if inner_w > 0 && inner_h > 0 {
|
||||
let maxm = mags.iter().cloned().fold(1e-6f32, f32::max);
|
||||
for x in 0..inner_w {
|
||||
// log-ish bin mapping into the lower spectrum
|
||||
let frac = (x as f32 / inner_w as f32).powf(1.6);
|
||||
let bin = ((frac * mags.len() as f32) as usize).min(mags.len() - 1);
|
||||
let mag = (mags[bin] / maxm).sqrt();
|
||||
let filled = (mag * inner_h as f32) as usize;
|
||||
for y in 0..inner_h {
|
||||
srows[inner_h - 1 - y].push(if y < filled { '█' } else { ' ' });
|
||||
}
|
||||
}
|
||||
}
|
||||
let scope_lines: Vec<Line> = srows.into_iter().map(|s| Line::from(Span::styled(s, Style::default().fg(Color::Green)))).collect();
|
||||
f.render_widget(
|
||||
Paragraph::new(scope_lines).block(Block::default().borders(Borders::ALL).title(" spectrum ")),
|
||||
area,
|
||||
);
|
||||
|
||||
// Status line.
|
||||
let status = format!(
|
||||
" voices {voices} · latency {latency}f · glyphs ~^_v* f e s ! · Esc quit "
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(status).style(Style::default().fg(Color::Black).bg(Color::Cyan)),
|
||||
root[1],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user