150 lines
5.5 KiB
Rust
150 lines
5.5 KiB
Rust
//! 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],
|
||
|
|
);
|
||
|
|
}
|