Uppercase operator glyphs, more effects, drop embedded spectrum

- Operators are now UPPERCASE (orca-style): O A U V N sources, L H B SVF
  filters (with resonance), E G R envelope/gain/ringmod, S C F D shapers
  (soft/clip/fold/decimate), M mix, ! out. Digits = constants, lowercase = CC.
- TUI rewritten on crossterm reusing orca-rs's grid-view approach (centered
  canvas, per-glyph colour); the spectrum is gone - run spectrust on the side.
- Drops the ratatui and rustfft deps.

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 14:06:12 +02:00
co-authored by Claude Opus 4.8
parent d2778e169a
commit e68d50a78f
7 changed files with 305 additions and 500 deletions
+4 -7
View File
@@ -1,6 +1,6 @@
//! 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.
//! renders the voice mixer, and fills the output buffer. No allocation or locks
//! on this path.
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::mpsc::Sender;
@@ -11,7 +11,7 @@ use arc_swap::ArcSwap;
use pipewire as pw;
use pw::spa;
use pw::{properties::properties, spa::pod::Pod};
use rtrb::{Consumer, Producer};
use rtrb::Consumer;
use spa::param::audio::{AudioFormat, AudioInfoRaw};
use crate::dsp::Program;
@@ -23,7 +23,6 @@ pub type Shared = Arc<ArcSwap<Program>>;
pub fn spawn(
prog: Shared,
events: Consumer<Event>,
scope: Producer<f32>,
voices: Arc<AtomicU32>,
sr: u32,
channels: u8,
@@ -35,7 +34,7 @@ pub fn spawn(
.name("galdr-audio".into())
.spawn(move || {
if let Err(e) =
run(prog, events, scope, voices, sr, channels, latency, poly, ready_tx.clone())
run(prog, events, voices, sr, channels, latency, poly, ready_tx.clone())
{
let _ = ready_tx.send(Err(e));
}
@@ -48,7 +47,6 @@ pub fn spawn(
fn run(
prog: Shared,
mut events: Consumer<Event>,
mut scope: Producer<f32>,
voices: Arc<AtomicU32>,
sr: u32,
channels: u8,
@@ -92,7 +90,6 @@ fn run(
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;
+119 -65
View File
@@ -4,11 +4,16 @@
//! 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`.
//!
//! 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.
use crate::grid::{Grid, H, SLOTS, W};
pub const MAX_STATE: usize = 4 * SLOTS;
pub const TAU: f32 = std::f32::consts::TAU;
const PI: f32 = std::f32::consts::PI;
#[derive(Clone, Copy)]
pub enum Op {
@@ -17,13 +22,20 @@ pub enum Op {
Pulse,
Tri,
Noise,
Lpf,
Low,
High,
Band,
Env,
Shape,
Soft,
Clip,
Fold,
Decimate,
Gain,
Ring,
Mix,
Out,
}
/// A parameter read from a horizontal neighbour glyph.
#[derive(Clone, Copy)]
pub enum Param {
Const(f32),
@@ -47,6 +59,7 @@ pub struct Node {
pub op: Op,
pub out: usize,
pub input: Option<usize>,
pub lsig: Option<usize>,
pub left: Param,
pub right: Param,
pub st: usize,
@@ -57,17 +70,25 @@ pub struct Program {
pub has_env: bool,
}
/// (operator, persistent state slots per voice)
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),
'O' => (Op::Sine, 1),
'A' => (Op::Saw, 1),
'U' => (Op::Pulse, 1),
'V' => (Op::Tri, 1),
'N' => (Op::Noise, 0),
'L' => (Op::Low, 2),
'H' => (Op::High, 2),
'B' => (Op::Band, 2),
'E' => (Op::Env, 1),
'S' => (Op::Soft, 0),
'C' => (Op::Clip, 0),
'F' => (Op::Fold, 0),
'D' => (Op::Decimate, 2),
'G' => (Op::Gain, 0),
'R' => (Op::Ring, 1),
'M' => (Op::Mix, 0),
'!' => (Op::Out, 0),
_ => return None,
})
@@ -95,22 +116,19 @@ pub fn compile(grid: &Grid) -> Program {
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 input =
if row > 0 && occupied(grid.get(col, row - 1)) { Some((row - 1) * W + col) } else { None };
let lsig = if col > 0 { Some(row * 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 < 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 });
nodes.push(Node { op, out: row * W + col, input, lsig, 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,
@@ -141,12 +159,41 @@ impl Voice {
#[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]
fn phasor(ph: &mut f32, inc: f32) -> f32 {
let p = *ph;
*ph += inc;
if *ph >= 1.0 {
*ph -= 1.0;
}
p
}
/// TPT state-variable filter step. Returns (low, band, high).
#[inline]
fn svf(inp: f32, fc: f32, k: f32, sr: f32, ic1: &mut f32, ic2: &mut f32) -> (f32, f32, f32) {
let g = (PI * fc / sr).tan();
let a1 = 1.0 / (1.0 + g * (g + k));
let a2 = g * a1;
let a3 = g * a2;
let v3 = inp - *ic2;
let v1 = a1 * *ic1 + a2 * v3;
let v2 = *ic2 + a2 * *ic1 + a3 * v3;
*ic1 = 2.0 * v1 - *ic1;
*ic2 = 2.0 * v2 - *ic2;
(v2, v1, inp - k * v1 - v2)
}
#[inline]
fn res_k(right: Option<f32>) -> f32 {
// higher right glyph -> more resonance (lower k = higher Q)
(1.5 - right.unwrap_or(3.0) * 0.15).clamp(0.15, 1.5)
}
#[inline]
pub fn eval_voice(v: &mut Voice, p: &Program, regs: &[f32], sr: f32) -> f32 {
let mut s = [0f32; SLOTS];
@@ -159,76 +206,85 @@ pub fn eval_voice(v: &mut Voice, p: &Program, regs: &[f32], sr: f32) -> f32 {
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
(phasor(&mut v.state[n.st], v.freq * ratio / sr) * TAU).sin()
}
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
2.0 * phasor(&mut v.state[n.st], v.freq * ratio / sr) - 1.0
}
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
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 => {
// 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 => {
Op::Low | Op::High | Op::Band => {
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
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,
}
}
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;
*level += (target - *level) * (1.0 / (rate * sr)).min(1.0);
if *level > env_max {
env_max = *level;
}
inp * *level * v.vel
}
Op::Shape => {
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::Mix => inp + n.lsig.map(|i| s[i]).unwrap_or(0.0),
Op::Out => {
out += inp;
inp
@@ -236,8 +292,6 @@ pub fn eval_voice(v: &mut Voice, p: &Program, regs: &[f32], sr: f32) -> f32 {
};
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;
+18 -15
View File
@@ -32,10 +32,14 @@ Options:
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
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)
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)
routing M mix (adds the cell to its left) ! output to master
";
fn main() -> anyhow::Result<()> {
@@ -66,22 +70,21 @@ fn main() -> anyhow::Result<()> {
// 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, '!');
grid.set(0, 0, 'O'); // sine
grid.set(0, 1, 'E'); // -> envelope
grid.set(0, 2, '!'); // -> out
grid.set(2, 0, 'A'); // saw
grid.set(1, 1, '5'); // cutoff param for the filter
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);
let (prod_sc, cons_sc) = rtrb::RingBuffer::new(16384);
audio::spawn(prog.clone(), cons_ev, prod_sc, voices.clone(), rate, channels, latency, poly)
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.
@@ -93,6 +96,6 @@ fn main() -> anyhow::Result<()> {
}
};
tui::run(prog, cons_sc, voices, grid, latency)?;
tui::run(prog, voices, grid, latency)?;
Ok(())
}
+91 -95
View File
@@ -1,149 +1,145 @@
//! 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.
//! Grid editor. crossterm renderer adapted from orca-rs's grid view (same
//! centered-canvas + per-glyph colour approach); no embedded scope - run a
//! spectrum analyzer (e.g. spectrust) on the side if you want one.
use std::io::{self, Write};
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use std::time::Duration;
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 crossterm::cursor::{Hide, MoveTo, Show};
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use crossterm::style::{Attribute, Color, Print, SetAttribute, SetForegroundColor};
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, BeginSynchronizedUpdate, Clear, ClearType,
EndSynchronizedUpdate, EnterAlternateScreen, LeaveAlternateScreen,
};
use crossterm::{execute, queue, terminal};
use crate::dsp::{compile, Program};
use crate::grid::{Grid, H, W};
const FFT: usize = 1024;
// Foreground colour by glyph category. (orca-rs uses one Normal colour; galdr
// tints by role so a patch reads at a glance.)
fn glyph_color(c: char) -> Color {
match c {
'O' | 'A' | 'U' | 'V' | 'N' => Color::Cyan, // sources
'L' | 'H' | 'B' => Color::Blue, // filters
'S' | 'C' | 'F' | 'D' => Color::Yellow, // shapers
'E' | 'G' | 'R' | 'M' => Color::Green, // amp / mod
'!' => Color::Magenta, // output
'0'..='9' => Color::Grey, // constants
'a'..='z' => Color::DarkYellow, // CC registers
'.' => Color::DarkGrey, // empty
_ => Color::White,
}
}
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();
) -> io::Result<()> {
enable_raw_mode()?;
let mut out = io::stdout();
execute!(out, EnterAlternateScreen, Hide)?;
let (mut cx, mut cy) = (0usize, 0usize);
let res = main_loop(&mut out, &prog, &voices, &mut grid, latency, &mut cx, &mut cy);
execute!(out, Show, LeaveAlternateScreen)?;
disable_raw_mode()?;
res
}
fn main_loop(
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 {
// 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();
draw(out, grid, *cx, *cy, voices.load(Ordering::Relaxed), latency)?;
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 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))
{
break;
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::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, '.');
grid.set(*cx, *cy, '.');
changed = true;
}
KeyCode::Char(c) => {
grid.set(cx, cy, if c == ' ' { '.' } else { 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)));
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]);
fn draw(
out: &mut io::Stdout,
grid: &Grid,
cx: usize,
cy: usize,
voices: u32,
latency: u32,
) -> io::Result<()> {
let (tw, th) = terminal::size()?;
let off_x = (tw as usize).saturating_sub(W) / 2;
let off_y = (th as usize).saturating_sub(H + 1) / 2;
// Grid pane.
let mut lines = Vec::with_capacity(H);
queue!(out, BeginSynchronizedUpdate, Clear(ClearType::All))?;
for row in 0..H {
let mut spans = Vec::with_capacity(W);
queue!(out, MoveTo(off_x as u16, (off_y + row) as u16))?;
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),
};
let c = grid.get(col, row);
queue!(out, SetAttribute(Attribute::Reset))?;
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 { ' ' });
queue!(out, SetAttribute(Attribute::Reverse))?;
} else {
queue!(out, SetForegroundColor(glyph_color(c)))?;
}
queue!(out, Print(c))?;
}
}
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.
// Status line beneath the grid.
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],
"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"
);
let s: String = status.chars().take(tw as usize).collect();
queue!(
out,
MoveTo(0, th.saturating_sub(1)),
SetAttribute(Attribute::Reset),
SetForegroundColor(Color::DarkGrey),
Print(s)
)?;
queue!(out, EndSynchronizedUpdate)?;
out.flush()
}