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:
@@ -0,0 +1,174 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- arch: x86_64
|
||||
target: x86_64-unknown-linux-gnu
|
||||
runs-on: ubuntu-latest
|
||||
sccache_dir: /sccache
|
||||
- arch: aarch64
|
||||
target: aarch64-unknown-linux-gnu
|
||||
runs-on: aarch64
|
||||
sccache_dir: /var/lib/gitea-runner/sccache
|
||||
|
||||
runs-on: ${{ matrix.runs-on }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
# Compiled-crate cache via sccache. x86_64 uses /sccache (a persistent
|
||||
# host dir bind-mounted into the job container by the runner config);
|
||||
# aarch64 runs on the klokka host and uses a runner-owned dir.
|
||||
- name: Set up sccache
|
||||
run: |
|
||||
echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV"
|
||||
echo "SCCACHE_DIR=${{ matrix.sccache_dir }}" >> "$GITHUB_ENV"
|
||||
mkdir -p "${{ matrix.sccache_dir }}"
|
||||
if ! command -v sccache >/dev/null 2>&1; then
|
||||
V=0.8.2
|
||||
curl -sSL "https://github.com/mozilla/sccache/releases/download/v${V}/sccache-v${V}-x86_64-unknown-linux-musl.tar.gz" | tar -xz
|
||||
sudo install -m0755 "sccache-v${V}-x86_64-unknown-linux-musl/sccache" /usr/local/bin/sccache
|
||||
fi
|
||||
sccache --version
|
||||
|
||||
- name: Ensure build deps (libpipewire for output, alsa for MIDI, clang for bindgen)
|
||||
run: |
|
||||
if command -v apt-get >/dev/null; then
|
||||
# Ephemeral container runner: safe to install into it.
|
||||
SUDO=""; command -v sudo >/dev/null && SUDO=sudo
|
||||
$SUDO apt-get update
|
||||
$SUDO apt-get install -y libpipewire-0.3-dev libspa-0.2-dev libasound2-dev clang libclang-dev pkg-config
|
||||
else
|
||||
# Bare/host runner: never mutate it - require deps preinstalled.
|
||||
missing=""
|
||||
pkg-config --exists libpipewire-0.3 2>/dev/null || missing="$missing libpipewire(+dev headers)"
|
||||
pkg-config --exists alsa 2>/dev/null || missing="$missing alsa-lib(+dev headers)"
|
||||
command -v clang >/dev/null || missing="$missing clang"
|
||||
if [ -n "$missing" ]; then
|
||||
echo "::error::bare runner missing build deps:$missing — install once on the host; CI does not modify bare runners"
|
||||
exit 1
|
||||
fi
|
||||
echo "bare runner already has libpipewire + alsa + clang"
|
||||
fi
|
||||
|
||||
- name: Build
|
||||
run: cargo build --release --locked --target ${{ matrix.target }}
|
||||
|
||||
- name: Package
|
||||
run: |
|
||||
TAG=${{ gitea.ref_name }}
|
||||
TARBALL="galdr-${TAG}-${{ matrix.arch }}.tar.gz"
|
||||
mkdir pkg
|
||||
cp target/${{ matrix.target }}/release/galdr pkg/
|
||||
cp LICENSE pkg/
|
||||
tar -czf "${TARBALL}" -C pkg .
|
||||
sha256sum "${TARBALL}" > "${TARBALL}.sha256"
|
||||
echo "TARBALL=${TARBALL}" >> $GITHUB_ENV
|
||||
|
||||
- name: Create release
|
||||
run: |
|
||||
curl -sX POST \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases" \
|
||||
-d "{\"tag_name\":\"${{ gitea.ref_name }}\",\"name\":\"${{ gitea.ref_name }}\"}" \
|
||||
--fail-with-body || true
|
||||
|
||||
- name: Upload assets
|
||||
run: |
|
||||
RELEASE_ID=$(curl -s \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/tags/${{ gitea.ref_name }}" \
|
||||
| jq -r '.id')
|
||||
|
||||
for FILE in "${{ env.TARBALL }}" "${{ env.TARBALL }}.sha256"; do
|
||||
EXISTING=$(curl -s \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/${RELEASE_ID}/assets" \
|
||||
| jq -r ".[] | select(.name == \"${FILE}\") | .id")
|
||||
for AID in $EXISTING; do
|
||||
curl -sX DELETE \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/${RELEASE_ID}/assets/${AID}"
|
||||
done
|
||||
|
||||
curl -sX POST \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/${RELEASE_ID}/assets?name=${FILE}" \
|
||||
--data-binary "@${FILE}" --fail-with-body
|
||||
done
|
||||
|
||||
update-aur:
|
||||
needs: build
|
||||
runs-on: aarch64
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Compute checksums and update PKGBUILD
|
||||
run: |
|
||||
TAG=${{ gitea.ref_name }}
|
||||
BASE="${{ gitea.server_url }}/${{ gitea.repository }}/releases/download/${TAG}"
|
||||
SUM_X86=$(curl -sL "${BASE}/galdr-${TAG}-x86_64.tar.gz" | sha256sum | cut -d' ' -f1)
|
||||
SUM_AARCH=$(curl -sL "${BASE}/galdr-${TAG}-aarch64.tar.gz" | sha256sum | cut -d' ' -f1)
|
||||
sed -i "s/sha256sums_x86_64=('.*')/sha256sums_x86_64=('${SUM_X86}')/" aur/PKGBUILD
|
||||
sed -i "s/sha256sums_aarch64=('.*')/sha256sums_aarch64=('${SUM_AARCH}')/" aur/PKGBUILD
|
||||
|
||||
# Best-effort mirror to the instance Arch registry via a write:package
|
||||
# REGISTRY_TOKEN secret; skipped + non-fatal when unset.
|
||||
- name: Publish to the Arch package registry
|
||||
continue-on-error: true
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${{ secrets.REGISTRY_TOKEN }}" ]; then
|
||||
echo "::warning::REGISTRY_TOKEN not set — skipping Arch registry publish"
|
||||
exit 0
|
||||
fi
|
||||
cd aur
|
||||
for carch in aarch64 x86_64; do
|
||||
pkgfile=$(CARCH="$carch" makepkg --packagelist | tail -1)
|
||||
CARCH="$carch" makepkg -f --nodeps --noconfirm --skipinteg
|
||||
curl --fail-with-body --user "bl:${{ secrets.REGISTRY_TOKEN }}" \
|
||||
--upload-file "$pkgfile" \
|
||||
"${{ gitea.server_url }}/api/packages/${{ gitea.repository_owner }}/arch/uhhm"
|
||||
done
|
||||
|
||||
- name: Push to AUR
|
||||
env:
|
||||
AUR_SSH_KEY: ${{ secrets.AUR_SSH_KEY }}
|
||||
run: |
|
||||
install -dm700 ~/.ssh
|
||||
echo "$AUR_SSH_KEY" > ~/.ssh/aur
|
||||
chmod 600 ~/.ssh/aur
|
||||
echo "Host aur.archlinux.org" >> ~/.ssh/config
|
||||
echo " IdentityFile ~/.ssh/aur" >> ~/.ssh/config
|
||||
echo " User aur" >> ~/.ssh/config
|
||||
ssh-keyscan aur.archlinux.org >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
rm -rf /tmp/aur-galdr
|
||||
git clone ssh://aur@aur.archlinux.org/galdr.git /tmp/aur-galdr
|
||||
cp aur/PKGBUILD /tmp/aur-galdr/
|
||||
cd /tmp/aur-galdr
|
||||
makepkg --printsrcinfo > .SRCINFO
|
||||
git config user.name "Bendik Aagaard Lynghaug"
|
||||
git config user.email "bendik.lynghaug@gmail.com"
|
||||
git add PKGBUILD .SRCINFO
|
||||
git commit -m "Update to ${{ gitea.ref_name }}"
|
||||
git push origin master
|
||||
@@ -0,0 +1 @@
|
||||
/target
|
||||
Generated
+1358
File diff suppressed because it is too large
Load Diff
+19
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "galdr"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "A terminal grid synthesizer: signal flows spatially through single-glyph unit generators, voiced by MIDI, rendered low-latency to PipeWire"
|
||||
license = "MIT"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
pipewire = "0.10"
|
||||
midir = "0.10"
|
||||
rustfft = "6.2"
|
||||
arc-swap = "1.7"
|
||||
rtrb = "0.3"
|
||||
ratatui = "0.29"
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Bendik Aagaard Lynghaug
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,58 @@
|
||||
# galdr
|
||||
|
||||
A terminal **grid synthesizer**. Signal flows downward through a field of
|
||||
single-glyph unit generators; every incoming MIDI note voices the whole grid;
|
||||
the mix is rendered low-latency to PipeWire. It is orca's voice — the same
|
||||
esoteric, spatial approach to programming, but for making sound instead of
|
||||
sending events.
|
||||
|
||||
```
|
||||
orca-rs ──MIDI/OSC/UDP──▶ galdr ──PipeWire──▶ speakers
|
||||
compose the pattern voice it (spectrust -p visualizes it)
|
||||
```
|
||||
|
||||
## The grid
|
||||
|
||||
Signal flows **down**: each cell takes the sample from the cell above as its
|
||||
input. The left and right neighbour glyphs are parameters — a digit `0`–`9` is
|
||||
a constant, a letter `a`–`z` reads MIDI CC (`a` = CC 0).
|
||||
|
||||
| glyph | unit generator |
|
||||
|---|---|
|
||||
| `~` | sine (ratio ← left) |
|
||||
| `^` | saw (ratio ← left) |
|
||||
| `_` | pulse (width ← left) |
|
||||
| `v` | triangle |
|
||||
| `*` | noise |
|
||||
| `f` | lowpass (cutoff ← left) |
|
||||
| `e` | envelope, attack/release ← left/right, gated by the note |
|
||||
| `s` | shaper / drive ← left |
|
||||
| `!` | output to master |
|
||||
|
||||
Each `!` sums into the master, so parallel columns stack timbres or voices.
|
||||
A note plays every column at once.
|
||||
|
||||
Example — a filtered saw with an envelope:
|
||||
|
||||
```
|
||||
^ saw at the note pitch
|
||||
f lowpass, cutoff from the digit to its left...
|
||||
e ...enveloped by the note gate
|
||||
! ...to master
|
||||
```
|
||||
|
||||
Editing the grid recompiles and hot-swaps the running program without dropping
|
||||
audio.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
galdr [--latency <frames>] [--poly <n>] [--rate <hz>] [--midi-name <s>]
|
||||
```
|
||||
|
||||
galdr creates a virtual MIDI input port named **galdr**; connect orca or any
|
||||
sequencer/DAW to it. `--midi-name` connects to an existing port instead.
|
||||
Cursor keys move; type a glyph to place it; Backspace clears; Esc quits.
|
||||
|
||||
Runtime deps: PipeWire and ALSA (MIDI). Built in Rust; reuses the native
|
||||
PipeWire path from spectrust.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Maintainer: Bendik Aagaard Lynghaug <bendik.lynghaug@gmail.com>
|
||||
pkgname=galdr
|
||||
pkgver=0.1.0
|
||||
pkgrel=1
|
||||
pkgdesc="Terminal grid synthesizer: signal flows through single-glyph unit generators, MIDI in, low-latency PipeWire out"
|
||||
arch=('x86_64' 'aarch64')
|
||||
options=('!strip')
|
||||
url="https://project.uhhm.no/bl/galdr"
|
||||
license=('MIT')
|
||||
depends=('libpipewire' 'alsa-lib')
|
||||
provides=('galdr')
|
||||
conflicts=('galdr-git' 'galdr-bin')
|
||||
source_x86_64=("galdr-v${pkgver}-x86_64.tar.gz::https://project.uhhm.no/bl/galdr/releases/download/v${pkgver}/galdr-v${pkgver}-x86_64.tar.gz")
|
||||
source_aarch64=("galdr-v${pkgver}-aarch64.tar.gz::https://project.uhhm.no/bl/galdr/releases/download/v${pkgver}/galdr-v${pkgver}-aarch64.tar.gz")
|
||||
sha256sums_x86_64=('SKIP')
|
||||
sha256sums_aarch64=('SKIP')
|
||||
|
||||
package() {
|
||||
install -Dm755 galdr "$pkgdir/usr/bin/galdr"
|
||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
publish = false
|
||||
allow-branch = ["main"]
|
||||
|
||||
[[pre-release-replacements]]
|
||||
file = "aur/PKGBUILD"
|
||||
search = "pkgver=.*"
|
||||
replace = "pkgver={{version}}"
|
||||
|
||||
[[pre-release-replacements]]
|
||||
file = "aur/PKGBUILD"
|
||||
search = "pkgrel=.*"
|
||||
replace = "pkgrel=1"
|
||||
+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