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

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

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