4 Commits
Author SHA1 Message Date
Bendik Aagaard Lynghaug 071d1b6d63 chore: Release galdr version 0.2.1
Release / build (aarch64, aarch64, /var/lib/gitea-runner/sccache, aarch64-unknown-linux-gnu) (push) Successful in 1m24s
Release / build (x86_64, ubuntu-latest, /sccache, x86_64-unknown-linux-gnu) (push) Successful in 1m32s
Release / update-aur (push) Successful in 21s
2026-09-13 14:13:18 +02:00
Bendik Aagaard LynghaugandClaude Opus 4.8 fded9796fa TUI: '?' help overlay, minimal status bar, diff renderer (fix SSH flicker)
Full clear+redraw each frame flickered on terminals that ignore
synchronized-update (macOS Terminal over SSH). Now only changed cells
repaint; full clear happens only on resize or help toggle. The glyph
legend moves into a '?'-toggled help overlay; the bottom bar just shows
voices/latency and the quit/help hints.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GLUwWE2KmFPzhKaf67tWbx
2026-09-13 14:13:16 +02:00
Bendik Aagaard Lynghaug cc7eb0e4cc chore: Release galdr version 0.2.0
Release / build (x86_64, ubuntu-latest, /sccache, x86_64-unknown-linux-gnu) (push) Successful in 1m26s
Release / build (aarch64, aarch64, /var/lib/gitea-runner/sccache, aarch64-unknown-linux-gnu) (push) Successful in 1m31s
Release / update-aur (push) Successful in 22s
2026-09-13 14:06:22 +02:00
Bendik Aagaard LynghaugandClaude Opus 4.8 e68d50a78f 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
2026-09-13 14:06:12 +02:00
8 changed files with 397 additions and 522 deletions
Generated
+49 -300
View File
@@ -11,12 +11,6 @@ dependencies = [
"memchr",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "alsa"
version = "0.9.1"
@@ -46,7 +40,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "710e8eae58854cdc1790fcb56cca04d712a17be849eeb81da2a724bf4bae2bc4"
dependencies = [
"anstyle",
"unicode-width 0.2.0",
"unicode-width",
]
[[package]]
@@ -70,12 +64,6 @@ dependencies = [
"rustversion",
]
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bindgen"
version = "0.72.1"
@@ -122,21 +110,6 @@ version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "cassowary"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
[[package]]
name = "castaway"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
dependencies = [
"rustversion",
]
[[package]]
name = "cc"
version = "1.4.6"
@@ -184,17 +157,12 @@ dependencies = [
]
[[package]]
name = "compact_str"
version = "0.8.2"
name = "convert_case"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e"
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
dependencies = [
"castaway",
"cfg-if",
"itoa",
"rustversion",
"ryu",
"static_assertions",
"unicode-segmentation",
]
[[package]]
@@ -242,15 +210,17 @@ dependencies = [
[[package]]
name = "crossterm"
version = "0.28.1"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
dependencies = [
"bitflags 2.13.2",
"crossterm_winapi",
"derive_more",
"document-features",
"mio",
"parking_lot",
"rustix 0.38.44",
"rustix",
"signal-hook",
"signal-hook-mio",
"winapi",
@@ -266,37 +236,34 @@ dependencies = [
]
[[package]]
name = "darling"
version = "0.24.1"
name = "derive_more"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec"
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
dependencies = [
"darling_core",
"darling_macro",
"derive_more-impl",
]
[[package]]
name = "darling_core"
version = "0.24.1"
name = "derive_more-impl"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff"
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
dependencies = [
"ident_case",
"convert_case",
"proc-macro2",
"quote",
"strsim",
"syn 3.0.5",
"rustc_version",
"syn 2.0.119",
]
[[package]]
name = "darling_macro"
version = "0.24.1"
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"darling_core",
"quote",
"syn 3.0.5",
"litrs",
]
[[package]]
@@ -318,7 +285,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys",
]
[[package]]
@@ -327,12 +294,6 @@ version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "futures-core"
version = "0.3.34"
@@ -359,15 +320,14 @@ dependencies = [
[[package]]
name = "galdr"
version = "0.1.0"
version = "0.2.1"
dependencies = [
"anyhow",
"arc-swap",
"crossterm",
"midir",
"pipewire",
"ratatui",
"rtrb",
"rustfft",
]
[[package]]
@@ -376,17 +336,6 @@ version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
@@ -399,12 +348,6 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "indexmap"
version = "2.14.2"
@@ -412,29 +355,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown 0.17.1",
]
[[package]]
name = "indoc"
version = "2.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
dependencies = [
"rustversion",
]
[[package]]
name = "instability"
version = "0.3.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8"
dependencies = [
"darling",
"indoc",
"proc-macro2",
"quote",
"syn 3.0.5",
"hashbrown",
]
[[package]]
@@ -446,12 +367,6 @@ dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.105"
@@ -491,7 +406,7 @@ dependencies = [
"libc",
"libspa-sys",
"nom 8.0.0",
"rustix 1.1.4",
"rustix",
"system-deps",
]
@@ -506,18 +421,18 @@ dependencies = [
"system-deps",
]
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -533,15 +448,6 @@ version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "lru"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
dependencies = [
"hashbrown 0.15.5",
]
[[package]]
name = "memchr"
version = "2.8.3"
@@ -580,7 +486,7 @@ dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.61.2",
"windows-sys",
]
[[package]]
@@ -602,33 +508,6 @@ dependencies = [
"memchr",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-integer"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
dependencies = [
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "objc2"
version = "0.6.4"
@@ -673,12 +552,6 @@ dependencies = [
"windows-link",
]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@@ -696,7 +569,7 @@ dependencies = [
"libspa",
"libspa-sys",
"pipewire-sys",
"rustix 1.1.4",
"rustix",
]
[[package]]
@@ -716,15 +589,6 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "primal-check"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08"
dependencies = [
"num-integer",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
@@ -743,27 +607,6 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "ratatui"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
dependencies = [
"bitflags 2.13.2",
"cassowary",
"compact_str",
"crossterm",
"indoc",
"instability",
"itertools",
"lru",
"paste",
"strum",
"unicode-segmentation",
"unicode-truncate",
"unicode-width 0.2.0",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -815,30 +658,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "rustfft"
version = "6.4.1"
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"num-complex",
"num-integer",
"num-traits",
"primal-check",
"strength_reduce",
"transpose",
]
[[package]]
name = "rustix"
version = "0.38.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [
"bitflags 2.13.2",
"errno",
"libc",
"linux-raw-sys 0.4.15",
"windows-sys 0.59.0",
"semver",
]
[[package]]
@@ -850,8 +675,8 @@ dependencies = [
"bitflags 2.13.2",
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
"linux-raw-sys",
"windows-sys",
]
[[package]]
@@ -860,18 +685,18 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde_core"
version = "1.0.229"
@@ -956,46 +781,6 @@ version = "1.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891"
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "strength_reduce"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
dependencies = [
"heck",
"proc-macro2",
"quote",
"rustversion",
"syn 2.0.119",
]
[[package]]
name = "syn"
version = "2.0.119"
@@ -1076,16 +861,6 @@ version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
[[package]]
name = "transpose"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e"
dependencies = [
"num-integer",
"strength_reduce",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -1098,23 +873,6 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "unicode-truncate"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
dependencies = [
"itertools",
"unicode-segmentation",
"unicode-width 0.1.14",
]
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-width"
version = "0.2.0"
@@ -1269,15 +1027,6 @@ dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
+2 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "galdr"
version = "0.1.0"
version = "0.2.1"
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"
@@ -13,7 +13,6 @@ codegen-units = 1
anyhow = "1"
pipewire = "0.10"
midir = "0.10"
rustfft = "6.2"
arc-swap = "1.7"
rtrb = "0.3"
ratatui = "0.29"
crossterm = "0.29"
+24 -17
View File
@@ -14,20 +14,26 @@ orca-rs ──MIDI/OSC/UDP──▶ galdr ──PipeWire──▶ speakers
## 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).
input. Glyphs are **UPPERCASE** operators. A digit `0`–`9` to the left/right is
a constant parameter; a lowercase 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 |
| glyph | unit generator | left / right param |
|---|---|---|
| `O` | sine | ratio |
| `A` | saw | ratio |
| `U` | pulse | width |
| `V` | triangle | — |
| `N` | noise | — |
| `L` `H` `B` | low / high / band-pass (SVF) | cutoff / resonance |
| `E` | envelope (gated by the note) | attack / release |
| `S` | soft clip (tanh) | drive |
| `C` | hard clip | threshold |
| `F` | wavefolder | amount |
| `D` | decimate / bitcrush | rate |
| `G` | gain | amount |
| `R` | ring modulator | ratio |
| `M` | mix — adds the cell to its **left** | — |
| `!` | output to master | — |
Each `!` sums into the master, so parallel columns stack timbres or voices.
A note plays every column at once.
@@ -35,14 +41,15 @@ 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
A saw at the note pitch
L 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.
audio. There is no embedded scope — run a spectrum analyzer (e.g. `spectrust`)
on the side if you want one.
## Usage
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Bendik Aagaard Lynghaug <bendik.lynghaug@gmail.com>
pkgname=galdr
pkgver=0.1.0
pkgver=0.2.1
pkgrel=1
pkgdesc="Terminal grid synthesizer: signal flows through single-glyph unit generators, MIDI in, low-latency PipeWire out"
arch=('x86_64' 'aarch64')
+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(())
}
+180 -114
View File
@@ -1,149 +1,215 @@
//! 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: a centered
//! canvas with per-glyph colour and a change-diff so only cells that actually
//! change get repainted (a full clear+redraw each frame flickers over SSH on
//! terminals that ignore synchronized-update, e.g. macOS Terminal). `?` toggles
//! a detailed help overlay; run a spectrum analyzer 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, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen,
};
use crossterm::{execute, queue, terminal};
use crate::dsp::{compile, Program};
use crate::grid::{Grid, H, W};
const FFT: usize = 1024;
const HELP: &[&str] = &[
"galdr — grid synthesizer",
"",
"Signal flows DOWN: a cell's input is the cell above it. Operators are",
"UPPERCASE. A digit 0-9 to the left/right is a constant param; a lowercase",
"a-z reads MIDI CC (a = CC 0). Every note plays the whole grid; each ! sums",
"into the master, so parallel columns stack voices/timbres.",
"",
"SOURCES O sine A saw U pulse V triangle N noise",
" left param = pitch ratio (O/A) or pulse width (U)",
"FILTERS L lowpass H highpass B bandpass (state-variable)",
" left = cutoff, right = resonance (higher glyph = more)",
"SHAPERS S soft clip/tanh (left=drive) C hard clip (left=threshold)",
" F wavefolder (left=amount) D decimate/bitcrush (left=rate)",
"AMP/MOD E envelope, note-gated (left=attack, right=release)",
" G gain (left=amount) R ring modulator (left=ratio)",
"ROUTING M mix — adds the cell to its LEFT ! output to master",
"",
"KEYS arrows move · type a glyph to place · Backspace clears",
" ? toggle this help · Esc quit",
];
#[derive(Clone, Copy, PartialEq)]
enum Cs {
Fg(Color),
Cursor,
}
fn glyph_color(c: char) -> Color {
match c {
'O' | 'A' | 'U' | 'V' | 'N' => Color::Cyan,
'L' | 'H' | 'B' => Color::Blue,
'S' | 'C' | 'F' | 'D' => Color::Yellow,
'E' | 'G' | 'R' | 'M' => Color::Green,
'!' => Color::Magenta,
'0'..='9' => Color::Grey,
'a'..='z' => Color::DarkYellow,
'.' => Color::DarkGrey,
_ => Color::White,
}
}
struct Renderer {
cells: Vec<(char, Cs)>,
status: String,
tw: u16,
th: u16,
help: bool,
init: bool,
}
impl Renderer {
fn new() -> Self {
Renderer { cells: Vec::new(), status: String::new(), tw: 0, th: 0, help: false, init: false }
}
}
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 mut help = false;
let mut r = Renderer::new();
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;
let res = (|| -> io::Result<()> {
loop {
draw(&mut out, &grid, cx, cy, voices.load(Ordering::Relaxed), latency, help, &mut r)?;
if !event::poll(Duration::from_millis(33))? {
continue;
}
let Event::Key(k) = event::read()? else { continue };
if k.kind != KeyEventKind::Press {
continue;
}
let ctrl = k.modifiers.contains(KeyModifiers::CONTROL);
match k.code {
KeyCode::Esc => {
if help {
help = false;
} else {
return Ok(());
}
KeyCode::Char(c) => {
grid.set(cx, cy, if c == ' ' { '.' } else { c });
changed = true;
}
_ => {}
}
if changed {
KeyCode::Char('c') if ctrl => return Ok(()),
KeyCode::Char('?') => help = !help,
_ if help => {} // any other key: ignore while help is open
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, '.');
prog.store(Arc::new(compile(&grid)));
}
KeyCode::Char(c) => {
grid.set(cx, cy, if c == ' ' { '.' } else { c });
prog.store(Arc::new(compile(&grid)));
cx = (cx + 1).min(W - 1);
}
_ => {}
}
}
}
})();
ratatui::restore();
Ok(())
execute!(out, Show, LeaveAlternateScreen)?;
disable_raw_mode()?;
res
}
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]);
#[allow(clippy::too_many_arguments)]
fn draw(
out: &mut io::Stdout,
grid: &Grid,
cx: usize,
cy: usize,
voices: u32,
latency: u32,
help: bool,
r: &mut Renderer,
) -> io::Result<()> {
let (tw, th) = terminal::size()?;
// Grid pane.
let mut lines = Vec::with_capacity(H);
// Full repaint only on first frame, resize, or a help toggle - never every
// frame (that is the flicker).
let full = !r.init || tw != r.tw || th != r.th || help != r.help;
if full {
queue!(out, Clear(ClearType::All))?;
r.cells = vec![('\0', Cs::Fg(Color::Reset)); W * H];
r.status.clear();
r.tw = tw;
r.th = th;
r.help = help;
r.init = true;
}
if help {
if full {
let oy = (th as usize).saturating_sub(HELP.len()) / 2;
let ox = (tw as usize).saturating_sub(74) / 2;
for (i, line) in HELP.iter().enumerate() {
queue!(
out,
MoveTo(ox as u16, (oy + i) as u16),
SetAttribute(Attribute::Reset),
SetForegroundColor(if i == 0 { Color::Magenta } else { Color::Grey }),
Print(line)
)?;
}
}
return out.flush();
}
let off_x = (tw as usize).saturating_sub(W) / 2;
let off_y = (th as usize).saturating_sub(H + 1) / 2;
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);
let c = grid.get(col, row);
let cs = if col == cx && row == cy { Cs::Cursor } else { Cs::Fg(glyph_color(c)) };
let idx = row * W + col;
if r.cells[idx] == (c, cs) {
continue;
}
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 { ' ' });
r.cells[idx] = (c, cs);
queue!(out, MoveTo((off_x + col) as u16, (off_y + row) as u16), SetAttribute(Attribute::Reset))?;
match cs {
Cs::Cursor => queue!(out, SetAttribute(Attribute::Reverse))?,
Cs::Fg(color) => queue!(out, SetForegroundColor(color))?,
}
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.
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],
);
let status = format!("voices {voices} latency {latency}f ? help Esc quit");
if status != r.status {
r.status = status.clone();
let pad: String = format!("{status:<width$}", width = tw as usize);
queue!(
out,
MoveTo(0, th.saturating_sub(1)),
SetAttribute(Attribute::Reset),
SetForegroundColor(Color::DarkGrey),
Print(pad.chars().take(tw as usize).collect::<String>())
)?;
}
out.flush()
}