Compare commits
10
Commits
v0.1.0
..
392ceceeff
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
392ceceeff | ||
|
|
3bee9426f8 | ||
|
|
9bc44a08d5 | ||
|
|
3b4782565b | ||
|
|
fa3390f09e | ||
|
|
edd9ff70c6 | ||
|
|
071d1b6d63 | ||
|
|
fded9796fa | ||
|
|
cc7eb0e4cc | ||
|
|
e68d50a78f |
@@ -25,7 +25,7 @@ jobs:
|
||||
runs-on: ${{ matrix.runs-on }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: builtin:checkout
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
@@ -40,11 +40,16 @@ jobs:
|
||||
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
|
||||
if command -v sccache >/dev/null 2>&1; then sccache --version; exit 0; fi
|
||||
# Bare runners (aarch64 = klokka host) are pre-provisioned; CI must not
|
||||
# install onto them (and this tarball is x86_64-only). Only the
|
||||
# ephemeral x86_64 container installs; a bare host missing it fails loud.
|
||||
if [ "${{ matrix.arch }}" != x86_64 ]; then
|
||||
echo "::error::sccache missing on the bare ${{ matrix.arch }} runner — provision klokka; CI must not install on bare hosts"; exit 1
|
||||
fi
|
||||
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
|
||||
sccache --version
|
||||
|
||||
- name: Ensure build deps (libpipewire for output, alsa for MIDI, clang for bindgen)
|
||||
@@ -67,6 +72,9 @@ jobs:
|
||||
echo "bare runner already has libpipewire + alsa + clang"
|
||||
fi
|
||||
|
||||
- name: Test
|
||||
run: cargo test --release --locked --target ${{ matrix.target }}
|
||||
|
||||
- name: Build
|
||||
run: cargo build --release --locked --target ${{ matrix.target }}
|
||||
|
||||
@@ -120,7 +128,7 @@ jobs:
|
||||
runs-on: aarch64
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: builtin:checkout
|
||||
|
||||
- name: Compute checksums and update PKGBUILD
|
||||
run: |
|
||||
|
||||
Generated
+49
-300
@@ -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.3"
|
||||
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
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "galdr"
|
||||
version = "0.1.0"
|
||||
version = "0.2.3"
|
||||
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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -52,7 +59,10 @@ 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.
|
||||
|
||||
Keys: cursor keys move; type a glyph to place it; Backspace clears; `?` toggles
|
||||
help; Esc quits. Resize the grid with `(` / `)` (width) and `_` / `+` (height)
|
||||
by one ruler division, or Ctrl+arrows by one cell (orca's bindings).
|
||||
|
||||
Runtime deps: PipeWire and ALSA (MIDI). Built in Rust; reuses the native
|
||||
PipeWire path from spectrust.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Bendik Aagaard Lynghaug <bendik.lynghaug@gmail.com>
|
||||
pkgname=galdr
|
||||
pkgver=0.1.0
|
||||
pkgver=0.2.3
|
||||
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
@@ -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;
|
||||
|
||||
+255
-65
@@ -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};
|
||||
use crate::grid::{Grid, MAX_W, SLOTS};
|
||||
|
||||
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,
|
||||
})
|
||||
@@ -89,28 +110,28 @@ 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 {
|
||||
for row in 0..grid.h {
|
||||
for col in 0..grid.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)
|
||||
Some((row - 1) * MAX_W + col)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let lsig = if col > 0 { Some(row * MAX_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 right = if col + 1 < grid.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 * MAX_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 +162,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 +209,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 +295,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;
|
||||
@@ -245,3 +302,136 @@ pub fn eval_voice(v: &mut Voice, p: &Program, regs: &[f32], sr: f32) -> f32 {
|
||||
v.env_level = env_max;
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::grid::Grid;
|
||||
|
||||
fn grid_from(src: &str) -> Grid {
|
||||
let mut g = Grid::new();
|
||||
for (r, line) in src.lines().enumerate() {
|
||||
for (c, ch) in line.chars().enumerate() {
|
||||
g.set(c, r, ch);
|
||||
}
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
fn voice(freq: f32, gate: f32, vel: f32) -> Voice {
|
||||
let mut v = Voice::new();
|
||||
v.active = true;
|
||||
v.freq = freq;
|
||||
v.gate = gate;
|
||||
v.vel = vel;
|
||||
v
|
||||
}
|
||||
|
||||
fn render(v: &mut Voice, p: &Program, n: usize) -> Vec<f32> {
|
||||
let regs = [0.0f32; 128];
|
||||
(0..n).map(|_| eval_voice(v, p, ®s, 48000.0)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_ignores_non_operators() {
|
||||
// digits and dots are params/empty, not nodes
|
||||
let p = compile(&grid_from("O5.\n!.."));
|
||||
assert_eq!(p.nodes.len(), 2); // O and !
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_wires_input_from_above() {
|
||||
// O over ! -> the ! node's input is the O's slot
|
||||
let p = compile(&grid_from("O\n!"));
|
||||
let out_node = p.nodes.iter().find(|n| matches!(n.op, Op::Out)).unwrap();
|
||||
assert!(out_node.input.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_reads_left_param() {
|
||||
// '5' left of L becomes a constant param
|
||||
let p = compile(&grid_from("5L"));
|
||||
let lp = p.nodes.iter().find(|n| matches!(n.op, Op::Low)).unwrap();
|
||||
assert!(matches!(lp.left, Param::Const(x) if (x - 5.0).abs() < 1e-6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_lowercase_left_is_cc_reg() {
|
||||
let p = compile(&grid_from("aG"));
|
||||
let g = p.nodes.iter().find(|n| matches!(n.op, Op::Gain)).unwrap();
|
||||
assert!(matches!(g.left, Param::Reg(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_detects_envelope() {
|
||||
assert!(compile(&grid_from("O\nE\n!")).has_env);
|
||||
assert!(!compile(&grid_from("O\n!")).has_env);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_grid_is_silent() {
|
||||
let p = compile(&grid_from("...\n..."));
|
||||
let mut v = voice(440.0, 1.0, 1.0);
|
||||
assert!(render(&mut v, &p, 64).iter().all(|&y| y == 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sine_is_bounded_and_audible() {
|
||||
let p = compile(&grid_from("O\n!"));
|
||||
let mut v = voice(440.0, 1.0, 1.0);
|
||||
let out = render(&mut v, &p, 2048);
|
||||
assert!(out.iter().all(|&y| y.abs() <= 1.0 + 1e-6));
|
||||
assert!(out.iter().cloned().fold(0.0, f32::max) > 0.5); // reaches amplitude
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saw_first_sample_is_minus_one() {
|
||||
// phase starts at 0 -> saw = 2*0-1 = -1
|
||||
let p = compile(&grid_from("A\n!"));
|
||||
let mut v = voice(440.0, 1.0, 1.0);
|
||||
assert!((render(&mut v, &p, 1)[0] - (-1.0)).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outputs_sum_across_columns() {
|
||||
// two saw->out columns: first sample -1 + -1 = -2
|
||||
let p = compile(&grid_from("A.A\n!.!"));
|
||||
let mut v = voice(440.0, 1.0, 1.0);
|
||||
assert!((render(&mut v, &p, 1)[0] - (-2.0)).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_envelope_is_gated_by_note() {
|
||||
let p = compile(&grid_from("A\n!"));
|
||||
let mut open = voice(440.0, 1.0, 1.0);
|
||||
let mut shut = voice(440.0, 0.0, 1.0);
|
||||
assert!(render(&mut open, &p, 1)[0].abs() > 0.5);
|
||||
assert_eq!(render(&mut shut, &p, 1)[0], 0.0); // gate 0 -> silent
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_rises_then_releases() {
|
||||
let p = compile(&grid_from("O\nE\n!"));
|
||||
let mut v = voice(440.0, 1.0, 1.0);
|
||||
render(&mut v, &p, 8000); // ~sustain reached
|
||||
let peak = v.env_level;
|
||||
assert!(peak > 0.9, "env should rise to sustain, got {peak}");
|
||||
v.gate = 0.0;
|
||||
render(&mut v, &p, 24000); // release (~0.5s >> 0.15s release)
|
||||
assert!(v.env_level < 0.1, "env should decay after note off, got {}", v.env_level);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mix_adds_left_cell() {
|
||||
// (1,0)=A saw(-1); (1,1)=M reads above(empty=0) + left cell A(-1) = -1
|
||||
let p = compile(&grid_from("A.\nAM\n.!"));
|
||||
let mut v = voice(440.0, 1.0, 1.0);
|
||||
assert!((render(&mut v, &p, 1)[0] - (-1.0)).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cutoff_hz_is_monotonic() {
|
||||
assert!(cutoff_hz(0.0) < cutoff_hz(5.0));
|
||||
assert!(cutoff_hz(5.0) < cutoff_hz(9.0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,3 +91,75 @@ impl Engine {
|
||||
(mix * 0.3).tanh()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::dsp::compile;
|
||||
use crate::grid::Grid;
|
||||
|
||||
#[cfg(test)]
|
||||
impl Engine {
|
||||
fn first_freq(&self) -> Option<f32> {
|
||||
self.voices.iter().find(|v| v.active).map(|v| v.freq)
|
||||
}
|
||||
fn reg(&self, i: usize) -> f32 {
|
||||
self.regs[i]
|
||||
}
|
||||
}
|
||||
|
||||
fn grid_from(src: &str) -> Grid {
|
||||
let mut g = Grid::new();
|
||||
for (r, line) in src.lines().enumerate() {
|
||||
for (c, ch) in line.chars().enumerate() {
|
||||
g.set(c, r, ch);
|
||||
}
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noteon_maps_a4_to_440() {
|
||||
let mut e = Engine::new(4, 48000.0);
|
||||
e.apply(Event::NoteOn { note: 69, vel: 100 });
|
||||
assert!((e.first_freq().unwrap() - 440.0).abs() < 0.01);
|
||||
assert_eq!(e.active_voices(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noteoff_reaps_gated_voice() {
|
||||
let p = compile(&grid_from("A\n!")); // no envelope -> gate reaps immediately
|
||||
let mut e = Engine::new(4, 48000.0);
|
||||
e.apply(Event::NoteOn { note: 60, vel: 100 });
|
||||
e.render(&p);
|
||||
assert_eq!(e.active_voices(), 1);
|
||||
e.apply(Event::NoteOff { note: 60 });
|
||||
e.render(&p);
|
||||
assert_eq!(e.active_voices(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monophonic_steals_the_oldest_voice() {
|
||||
let mut e = Engine::new(1, 48000.0);
|
||||
e.apply(Event::NoteOn { note: 60, vel: 100 });
|
||||
e.apply(Event::NoteOn { note: 72, vel: 100 });
|
||||
assert_eq!(e.active_voices(), 1);
|
||||
assert!((e.first_freq().unwrap() - 440.0 * 2f32.powf((72.0 - 69.0) / 12.0)).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cc_writes_register() {
|
||||
let mut e = Engine::new(2, 48000.0);
|
||||
e.apply(Event::Cc { idx: 0, val: 127 });
|
||||
assert!((e.reg(0) - 1.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn master_output_is_limited() {
|
||||
// many stacked saws would exceed 1.0 without the limiter
|
||||
let p = compile(&grid_from("A.A.A.A\n!.!.!.!"));
|
||||
let mut e = Engine::new(4, 48000.0);
|
||||
e.apply(Event::NoteOn { note: 60, vel: 127 });
|
||||
assert!(e.render(&p).abs() <= 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
+75
-9
@@ -1,31 +1,97 @@
|
||||
//! The fixed-size character grid that is the editor surface.
|
||||
//! The character grid — the editor surface. Logical size (`w`×`h`) is
|
||||
//! resizable up to the max; cells are stored at a fixed `MAX_W` stride so a
|
||||
//! resize never remaps existing content and compiled slot indices stay stable.
|
||||
|
||||
pub const W: usize = 24;
|
||||
pub const H: usize = 16;
|
||||
pub const SLOTS: usize = W * H;
|
||||
pub const MAX_W: usize = 40;
|
||||
pub const MAX_H: usize = 24;
|
||||
pub const SLOTS: usize = MAX_W * MAX_H;
|
||||
pub const DEFAULT_W: usize = 24;
|
||||
pub const DEFAULT_H: usize = 16;
|
||||
pub const RULER: usize = 8;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Grid {
|
||||
pub w: usize,
|
||||
pub h: usize,
|
||||
cells: Vec<char>,
|
||||
}
|
||||
|
||||
impl Grid {
|
||||
pub fn new() -> Self {
|
||||
Grid { cells: vec!['.'; SLOTS] }
|
||||
Grid { w: DEFAULT_W, h: DEFAULT_H, cells: vec!['.'; SLOTS] }
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn get(&self, col: usize, row: usize) -> char {
|
||||
if col < W && row < H {
|
||||
self.cells[row * W + col]
|
||||
if col < self.w && row < self.h {
|
||||
self.cells[row * MAX_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;
|
||||
if col < self.w && row < self.h {
|
||||
self.cells[row * MAX_W + col] = c;
|
||||
}
|
||||
}
|
||||
|
||||
/// Resize to nh×nw, clamped to [1, MAX]. Content keeps its position.
|
||||
pub fn resize(&mut self, nh: usize, nw: usize) {
|
||||
self.h = nh.clamp(1, MAX_H);
|
||||
self.w = nw.clamp(1, MAX_W);
|
||||
}
|
||||
}
|
||||
|
||||
/// orca-c's ruler snap: resize by one ruler division, snapped to a
|
||||
/// ruler-aligned size (1, 9, 17, ...) rather than a plain +/-1.
|
||||
pub fn ruler_snap(size: usize, delta_rulers: isize) -> usize {
|
||||
if size == 0 {
|
||||
return if delta_rulers > 0 { RULER * delta_rulers as usize } else { 1 };
|
||||
}
|
||||
let size = if delta_rulers < 0 { size + RULER - 1 } else { size };
|
||||
let n = ((size as isize - 1) / RULER as isize + delta_rulers).max(0);
|
||||
(RULER as isize * n + 1) as usize
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ruler_snap_grows_and_shrinks() {
|
||||
// ruler-aligned sizes are 1, 9, 17, ...
|
||||
assert_eq!(ruler_snap(24, 1), 25);
|
||||
assert_eq!(ruler_snap(24, -1), 17);
|
||||
assert_eq!(ruler_snap(1, -1), 1); // cannot go below 1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_clamps_to_bounds() {
|
||||
let mut g = Grid::new();
|
||||
g.resize(9999, 9999);
|
||||
assert_eq!((g.h, g.w), (MAX_H, MAX_W));
|
||||
g.resize(0, 0);
|
||||
assert_eq!((g.h, g.w), (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_out_of_bounds_is_empty() {
|
||||
let g = Grid::new();
|
||||
assert_eq!(g.get(MAX_W + 5, MAX_H + 5), '.');
|
||||
assert_eq!(g.get(g.w, 0), '.'); // just past the right edge
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_get_roundtrip_and_resize_preserves() {
|
||||
let mut g = Grid::new();
|
||||
g.set(3, 2, 'O');
|
||||
assert_eq!(g.get(3, 2), 'O');
|
||||
g.resize(4, 4); // shrink below the cell? (3,2) still inside 4x4
|
||||
assert_eq!(g.get(3, 2), 'O');
|
||||
g.resize(2, 2); // now (3,2) is out of bounds -> hidden
|
||||
assert_eq!(g.get(3, 2), '.');
|
||||
g.resize(MAX_H, MAX_W); // grow back -> content returns (fixed stride)
|
||||
assert_eq!(g.get(3, 2), 'O');
|
||||
}
|
||||
}
|
||||
|
||||
+18
-15
@@ -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(())
|
||||
}
|
||||
|
||||
+253
-117
@@ -1,149 +1,285 @@
|
||||
//! 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, ruler marks, resize bindings, and a change-diff
|
||||
//! so only cells that actually change repaint (a full clear+redraw each frame
|
||||
//! flickers over SSH on terminals that ignore synchronized-update). `?` 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};
|
||||
use crate::grid::{ruler_snap, Grid, RULER};
|
||||
|
||||
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",
|
||||
" ( ) width -/+ _ + height -/+ Ctrl+arrows resize by one",
|
||||
" ? 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::White,
|
||||
}
|
||||
}
|
||||
|
||||
struct Renderer {
|
||||
cells: Vec<(char, Cs)>,
|
||||
status: String,
|
||||
tw: u16,
|
||||
th: u16,
|
||||
gw: usize,
|
||||
gh: usize,
|
||||
help: bool,
|
||||
init: bool,
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
fn new() -> Self {
|
||||
Renderer {
|
||||
cells: Vec::new(),
|
||||
status: String::new(),
|
||||
tw: 0,
|
||||
th: 0,
|
||||
gw: 0,
|
||||
gh: 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);
|
||||
let mut resized = false;
|
||||
let mut edited = false;
|
||||
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 {
|
||||
prog.store(Arc::new(compile(&grid)));
|
||||
KeyCode::Char('c') if ctrl => return Ok(()),
|
||||
KeyCode::Char('?') => help = !help,
|
||||
_ if help => {}
|
||||
KeyCode::Char('(') => {
|
||||
grid.resize(grid.h, ruler_snap(grid.w, -1));
|
||||
resized = true;
|
||||
}
|
||||
KeyCode::Char(')') => {
|
||||
grid.resize(grid.h, ruler_snap(grid.w, 1));
|
||||
resized = true;
|
||||
}
|
||||
KeyCode::Char('_') => {
|
||||
grid.resize(ruler_snap(grid.h, -1), grid.w);
|
||||
resized = true;
|
||||
}
|
||||
KeyCode::Char('+') => {
|
||||
grid.resize(ruler_snap(grid.h, 1), grid.w);
|
||||
resized = true;
|
||||
}
|
||||
KeyCode::Left if ctrl => {
|
||||
grid.resize(grid.h, grid.w.saturating_sub(1));
|
||||
resized = true;
|
||||
}
|
||||
KeyCode::Right if ctrl => {
|
||||
grid.resize(grid.h, grid.w + 1);
|
||||
resized = true;
|
||||
}
|
||||
KeyCode::Up if ctrl => {
|
||||
grid.resize(grid.h.saturating_sub(1), grid.w);
|
||||
resized = true;
|
||||
}
|
||||
KeyCode::Down if ctrl => {
|
||||
grid.resize(grid.h + 1, grid.w);
|
||||
resized = true;
|
||||
}
|
||||
KeyCode::Left => cx = cx.saturating_sub(1),
|
||||
KeyCode::Right => cx = (cx + 1).min(grid.w - 1),
|
||||
KeyCode::Up => cy = cy.saturating_sub(1),
|
||||
KeyCode::Down => cy = (cy + 1).min(grid.h - 1),
|
||||
KeyCode::Backspace | KeyCode::Delete => {
|
||||
grid.set(cx, cy, '.');
|
||||
edited = true;
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
grid.set(cx, cy, if c == ' ' { '.' } else { c });
|
||||
edited = true;
|
||||
cx = (cx + 1).min(grid.w - 1);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if resized {
|
||||
cx = cx.min(grid.w - 1);
|
||||
cy = cy.min(grid.h - 1);
|
||||
}
|
||||
if resized || edited {
|
||||
prog.store(Arc::new(compile(&grid)));
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
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);
|
||||
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),
|
||||
let full = !r.init
|
||||
|| tw != r.tw
|
||||
|| th != r.th
|
||||
|| help != r.help
|
||||
|| grid.w != r.gw
|
||||
|| grid.h != r.gh;
|
||||
if full {
|
||||
queue!(out, Clear(ClearType::All))?;
|
||||
r.cells = vec![('\0', Cs::Fg(Color::Reset)); grid.w * grid.h];
|
||||
r.status.clear();
|
||||
r.tw = tw;
|
||||
r.th = th;
|
||||
r.gw = grid.w;
|
||||
r.gh = grid.h;
|
||||
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(grid.w) / 2;
|
||||
let off_y = (th as usize).saturating_sub(grid.h + 1) / 2;
|
||||
for row in 0..grid.h {
|
||||
for col in 0..grid.w {
|
||||
let raw = grid.get(col, row);
|
||||
// ruler mark on empty intersections, for orientation while sizing
|
||||
let disp = if raw == '.' {
|
||||
if row % RULER == 0 && col % RULER == 0 { '+' } else { '.' }
|
||||
} else {
|
||||
raw
|
||||
};
|
||||
if col == cx && row == cy {
|
||||
st = st.add_modifier(Modifier::REVERSED);
|
||||
let cs = if col == cx && row == cy {
|
||||
Cs::Cursor
|
||||
} else if raw == '.' {
|
||||
Cs::Fg(Color::DarkGrey)
|
||||
} else {
|
||||
Cs::Fg(glyph_color(raw))
|
||||
};
|
||||
let idx = row * grid.w + col;
|
||||
if r.cells[idx] == (disp, 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] = (disp, 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(disp))?;
|
||||
}
|
||||
}
|
||||
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} {}x{} latency {latency}f ? help Esc quit", grid.w, grid.h);
|
||||
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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user