Compare commits
14
Commits
v0.1.0
..
e836ee0e1d
@@ -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.3.0"
|
||||
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"
|
||||
|
||||
+3
-4
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "galdr"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
edition = "2021"
|
||||
description = "A terminal grid synthesizer: signal flows spatially through single-glyph unit generators, voiced by MIDI, rendered low-latency to PipeWire"
|
||||
license = "MIT"
|
||||
@@ -11,9 +11,8 @@ codegen-units = 1
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
pipewire = "0.10"
|
||||
pipewire = { version = "0.10", features = ["v0_3_49"] } # Buffer::requested
|
||||
midir = "0.10"
|
||||
rustfft = "6.2"
|
||||
arc-swap = "1.7"
|
||||
rtrb = "0.3"
|
||||
ratatui = "0.29"
|
||||
crossterm = "0.29"
|
||||
|
||||
@@ -1,58 +1,238 @@
|
||||
# galdr
|
||||
|
||||
A terminal **grid synthesizer**. Signal flows downward through a field of
|
||||
single-glyph unit generators; every incoming MIDI note voices the whole grid;
|
||||
the mix is rendered low-latency to PipeWire. It is orca's voice — the same
|
||||
esoteric, spatial approach to programming, but for making sound instead of
|
||||
sending events.
|
||||
single-glyph unit generators; every incoming MIDI note voices the grid (or the
|
||||
columns tagged with its channel); the mix is rendered low-latency to PipeWire.
|
||||
It is orca's voice — the same esoteric, spatial approach to programming, but
|
||||
for making sound instead of sending events.
|
||||
|
||||
```
|
||||
orca-rs ──MIDI/OSC/UDP──▶ galdr ──PipeWire──▶ speakers
|
||||
compose the pattern voice it (spectrust -p visualizes it)
|
||||
orca-rs ──MIDI + UDP──▶ galdr ──PipeWire──▶ speakers / stems
|
||||
compose the pattern voice it (spectrust -p visualizes it)
|
||||
```
|
||||
|
||||
## The grid
|
||||
|
||||
Signal flows **down**: each cell takes the sample from the cell above as its
|
||||
input. The left and right neighbour glyphs are parameters — a digit `0`–`9` is
|
||||
a constant, a letter `a`–`z` reads MIDI CC (`a` = CC 0).
|
||||
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 |
|
||||
| `X` | FM: sine at the note pitch, phase pushed by the cell to its **left** (and above) | — / index |
|
||||
| `M` | mix — adds the cell to its **left** | — |
|
||||
| `K` | control envelope: 1 at note-on, decays to a floor; no audio input | decay / floor |
|
||||
| `!` | output to master | — |
|
||||
|
||||
`K` is the one operator whose output is a *param*: placed to the **left** of
|
||||
another operator it becomes that operator's left param, scaled 0–9. `2KL5` is
|
||||
a lowpass whose cutoff starts open and decays (2) to the floor, with
|
||||
resonance 5. That's the 303 filter envelope.
|
||||
|
||||
Each `!` sums into the master, so parallel columns stack timbres or voices.
|
||||
A note plays every column at once.
|
||||
An untagged column plays on every MIDI channel.
|
||||
|
||||
### Params
|
||||
|
||||
A param cell is a digit `0`–`9` (constant), a lowercase letter `a`–`z` (a
|
||||
register: MIDI CC `a` = CC 0, or set over UDP), or one of three per-note
|
||||
values: `@` velocity, `^` key tracking (note number), `?` a random value rolled
|
||||
at note-on. All read on the same 0–9 scale, so `@L` is velocity-to-cutoff and
|
||||
`?O` detunes every hit a little differently.
|
||||
|
||||
### Channel tags
|
||||
|
||||
A base-36 glyph directly **above** a column's top operator is that column's
|
||||
MIDI channel: `0`–`9`, `a`–`f` for channels 1–16 (orca's numbering). The
|
||||
column then only voices notes on that channel. A `>` right of the tag makes
|
||||
the channel **mono-legato**: a note that overlaps the previous one slides the
|
||||
running voice to the new pitch over 60 ms without retriggering the envelopes,
|
||||
the 303 slide. From orca, make the step's note length longer than the gap.
|
||||
|
||||
### Acid
|
||||
|
||||
`examples/acid.galdr`, a 303 line on channel 1:
|
||||
|
||||
```
|
||||
...0>
|
||||
...A. saw (U for the square)
|
||||
.2KL@ K sweeps the cutoff (decay 2, floor open-to-closed); accent = velocity -> resonance
|
||||
...E1 short amp decay
|
||||
..3S. overdrive
|
||||
...!.
|
||||
```
|
||||
|
||||
```
|
||||
1....2.... column 0 is channel 1 (a filtered saw),
|
||||
A....O.... column 5 is channel 2 (a plain sine)
|
||||
L....E....
|
||||
E....!....
|
||||
!.........
|
||||
```
|
||||
|
||||
### The master chain
|
||||
|
||||
Operators placed **below** a column's `!` no longer run per voice: they
|
||||
process the summed stereo master, in series, in column order. Any filter or
|
||||
shaper works there, plus two master-only effects:
|
||||
|
||||
| glyph | effect | left / right param |
|
||||
|---|---|---|
|
||||
| `Z` | delay | time (0 = 100 ms … 9 = 1 s) / feedback |
|
||||
| `W` | stereo chorus | rate / depth |
|
||||
|
||||
```
|
||||
A
|
||||
!
|
||||
5L lowpass on the whole mix
|
||||
3Z6 then a 400 ms delay with lots of feedback
|
||||
.W then a slow chorus for width
|
||||
```
|
||||
|
||||
Editing the grid recompiles and hot-swaps the running program without dropping
|
||||
audio. There is no embedded scope — run a spectrum analyzer (e.g. `spectrust`)
|
||||
on the side if you want one.
|
||||
|
||||
## A drum kit (one drum per MIDI channel)
|
||||
|
||||
A note voices every column tagged with its channel, so give each drum its own
|
||||
channel and let orca pick the channel per hit. `examples/drums.galdr`:
|
||||
|
||||
```
|
||||
.0..9....a...b.
|
||||
.A..O9...N...N.
|
||||
@L5.EX9.6B5.8H.
|
||||
.E2..E1..E1..E0
|
||||
2S...!..3S...!.
|
||||
.!.......!.....
|
||||
```
|
||||
|
||||
- **bass** (tag `0`, MIDI ch 1): saw → lowpass with the cutoff from velocity
|
||||
(`@`) and resonance 5 → envelope, release 2 → soft clip 2 → out.
|
||||
- **kick** (tag `9`, ch 10): the left column is a sine into an envelope with
|
||||
no `!`, it exists only as a modulator. The `X` next to it is a sine at the
|
||||
note pitch, phase-modulated by that decaying signal with index 9: bright at
|
||||
the hit, a clean low sine a moment later. Play it a low note (C1–C2).
|
||||
- **snare** (tag `a`, ch 11): noise → bandpass 6, resonance 5 → short
|
||||
envelope → soft clip 3. A mid note sets the bandpass body.
|
||||
- **hats** (tag `b`, ch 12): noise → highpass 8 → envelope with release 0.
|
||||
Note length from orca is the open/closed control.
|
||||
|
||||
From orca, `:` with the channel glyph per drum, e.g. `:93C` for the kick,
|
||||
`:a3D` snare, `:b5F` hats, `:02C` bass. With `--stems 12` each drum also has
|
||||
its own `galdr.ch*` node to record.
|
||||
|
||||
## An orchestra (the big example)
|
||||
|
||||
`examples/orchestra.galdr` is ten sections on ten MIDI channels, each in a
|
||||
four-column block `[left param | chain | right param | gap]`, so every digit
|
||||
is a knob. It uses most of the vocabulary: velocity (`@`) into a filter and
|
||||
into an FM index, key tracking (`^`), per-note random (`?`), two modulator
|
||||
columns feeding `X`, registers set from orca (`k` and `z`), and a hall on the
|
||||
master chain (highpass, chorus, delay with feedback from `z`).
|
||||
|
||||
```
|
||||
.0...1...2...3...44..5...6...7...8...9..
|
||||
.A...A...A2.2U...NO.1U..5U...O7..A...V..
|
||||
@L2.3L3.1EX@4L1.9HE36B6.6L1..EX3^L?.kL2.
|
||||
4E5.3E4..1E23E3.1E!.2E2.2E2..0E40E3.7E7.
|
||||
.!..2S...2S.1S..2G..1F...!....!..!..9G..
|
||||
1H...!....!..!...!...!...............!..
|
||||
2W3.....................................
|
||||
4Zz.....................................
|
||||
```
|
||||
|
||||
| ch | tag | section | how |
|
||||
|---|---|---|---|
|
||||
| 1 | `0` | strings | saw, lowpass cutoff from velocity, slow envelope; the hall hangs under its `!` |
|
||||
| 2 | `1` | low strings | saw, lowpass 3/3, soft clip |
|
||||
| 3 | `2` | brass | a saw-envelope modulator column drives `X`, FM index from velocity |
|
||||
| 4 | `3` | horns | narrow pulse, lowpass, soft clip |
|
||||
| 5 | `4` | flute | breath noise (highpass, short env, gain) beside a sine |
|
||||
| 6 | `5` | reed | pulse width 1, resonant bandpass, fold |
|
||||
| 7 | `6` | clarinet | square, lowpass |
|
||||
| 8 | `7` | timpani | sine-envelope modulator into `X`, long release, play C1 to C2 |
|
||||
| 9 | `8` | harp | lowpass cutoff key-tracked, resonance random per pluck, pluck envelope |
|
||||
| 10 | `9` | choir | triangle, lowpass cutoff from register `k`, very slow envelope |
|
||||
|
||||
Load it with `galdr examples/orchestra.galdr`, or over UDP with `#orchestra`
|
||||
once it sits in the patches directory. `;k5` and `;z3` from orca set the choir
|
||||
brightness and the hall feedback live.
|
||||
|
||||
## Stems
|
||||
|
||||
`--stems n` adds one mono PipeWire node per channel tag, `galdr.ch0` …
|
||||
`galdr.ch<n-1>` (named by the tag glyph), each carrying the dry sum of that
|
||||
channel's voices, sample-aligned with the master. They are not auto-connected:
|
||||
patch them in a graph tool, or record them with a multitrack recorder such as
|
||||
`stems`, and master the session later.
|
||||
|
||||
## UDP control from orca
|
||||
|
||||
galdr listens on UDP port 49161 (orca's `;` operator sends there). One
|
||||
datagram carries one or more commands, separated by whitespace or `;`:
|
||||
|
||||
| command | effect |
|
||||
|---|---|
|
||||
| `k7` | set register `k` to 7 of 35 (base-36), read by `k` param cells |
|
||||
| `*3` | bang column 3: a 5 ms gate on that column alone, no note needed (drums, plucks) |
|
||||
| `@34O` | write glyph `O` at column 3, row 4 — orca patches the synth itself |
|
||||
| `@34.` | clear that cell |
|
||||
| `#verse` | load the patch `verse.galdr` from the patches directory |
|
||||
|
||||
Bangs and register sets go straight into the audio thread; grid commands are
|
||||
applied by the editor and recompiled like a keystroke.
|
||||
|
||||
## Patches
|
||||
|
||||
A patch is plain text: one line per row, `.` for an empty cell, `#` lines
|
||||
ignored. `galdr file.galdr` opens one and `Ctrl+S` writes it back. With no
|
||||
file, galdr works on **today's** patch, `~/hometaping/<YYYY-MM-DD>.galdr`:
|
||||
opened at start if it exists, written by `Ctrl+S`. That is the same dated
|
||||
naming as the orca files kept in that directory, so a session's orca pattern
|
||||
and its galdr voice sort together. `#name` over UDP loads
|
||||
`<patches dir>/name.galdr` (`--patches` overrides the directory).
|
||||
|
||||
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.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
galdr [--latency <frames>] [--poly <n>] [--rate <hz>] [--midi-name <s>]
|
||||
galdr [--latency <frames>] [--poly <n>] [--rate <hz>] [--stems <n>]
|
||||
[--udp <port>] [--patches <dir>] [--midi-name <s>] [patch.galdr]
|
||||
```
|
||||
|
||||
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; `Ctrl+S`
|
||||
saves the patch; `?` toggles help; Esc quits. Resize the grid with `(` / `)` (width) and `_` / `+` (height)
|
||||
by one ruler division, or Ctrl+arrows by one cell (orca's bindings).
|
||||
|
||||
galdr survives a PipeWire restart: the status line shows `PIPEWIRE DOWN —
|
||||
reconnecting…` and the stream comes back on its own, voices and master-chain
|
||||
state intact.
|
||||
|
||||
Runtime deps: PipeWire and ALSA (MIDI). Built in Rust; reuses the native
|
||||
PipeWire path from spectrust.
|
||||
PipeWire path from spectrust. `GALDR_DEBUG=<file>` appends every event that
|
||||
reaches the engine, handy when orca and galdr disagree.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Bendik Aagaard Lynghaug <bendik.lynghaug@gmail.com>
|
||||
pkgname=galdr
|
||||
pkgver=0.1.0
|
||||
pkgver=0.3.0
|
||||
pkgrel=1
|
||||
pkgdesc="Terminal grid synthesizer: signal flows through single-glyph unit generators, MIDI in, low-latency PipeWire out"
|
||||
arch=('x86_64' 'aarch64')
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# acid: a 303-style line on MIDI channel 1 (tag 0, '>' = slide on overlapping notes).
|
||||
# A saw > lowpass whose cutoff is swept by K (decay 2, floor 1); accent = velocity -> resonance (@);
|
||||
# short amp decay, overdrive. Swap A for U (square), raise K's decay for longer wows.
|
||||
...0>
|
||||
...A.
|
||||
.2KL@
|
||||
...E1
|
||||
..3S.
|
||||
...!.
|
||||
@@ -0,0 +1,7 @@
|
||||
# bass (ch 1) | kick (ch 10) | snare (ch 11) | hats (ch 12): one drum per MIDI channel
|
||||
.0..9....a...b.
|
||||
.A..O9...N...N.
|
||||
@L5.EX9.6B5.8H.
|
||||
.E2..E1..E1..E0
|
||||
2S...!..3S...!.
|
||||
.!.......!.....
|
||||
@@ -0,0 +1,21 @@
|
||||
# galdr orchestra: one section per MIDI channel (tag glyph above each column).
|
||||
# Sections are 4 columns: [left param | chain | right param | gap]. Tweak the digits;
|
||||
# @ = velocity, ^ = key tracking, ? = random per note, a-z = registers (MIDI CC / UDP).
|
||||
# strings ch1 saw > lowpass(cutoff=@ velocity, res 2) > env 4/5 > out; the HALL below it: highpass 1 > chorus 2/3 > delay 4/fb from register z (orca: ;z3)
|
||||
# low str ch2 saw > lowpass 3/3 > env 3/4 > soft clip 2
|
||||
# brass ch3 left col = saw>env modulator (no !); right = X FM with index from @ velocity > env 1/2 > soft clip 2
|
||||
# horns ch4 pulse width 2 > lowpass 4/1 > env 3/3 > soft clip 1
|
||||
# flute ch5 left = noise breath (highpass 9 > env 1 > gain 2); right = sine > env rel 3
|
||||
# reed ch6 narrow pulse 1 > bandpass 6/6 > env 2/2 > fold 1
|
||||
# clarinet ch7 square (width 5) > lowpass 6/1 > env 2/2
|
||||
# timpani ch8 left = sine>env modulator; right = X index 3 > env 0/4 (play C1-C2)
|
||||
# harp ch9 saw > lowpass: cutoff ^ key-tracked, resonance ? random per pluck > env 0/3
|
||||
# choir ch10 triangle > lowpass: cutoff from register k (orca: ;k5), res 2 > slow env 7/7 > gain 9
|
||||
.0...1...2...3...44..5...6...7...8...9..
|
||||
.A...A...A2.2U...NO.1U..5U...O7..A...V..
|
||||
@L2.3L3.1EX@4L1.9HE36B6.6L1..EX3^L?.kL2.
|
||||
4E5.3E4..1E23E3.1E!.2E2.2E2..0E40E3.7E7.
|
||||
.!..2S...2S.1S..2G..1F...!....!..!..9G..
|
||||
1H...!....!..!...!...!...............!..
|
||||
2W3.....................................
|
||||
4Zz.....................................
|
||||
+282
-76
@@ -1,66 +1,211 @@
|
||||
//! 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.
|
||||
//! Low-latency PipeWire playback. The RT process callbacks drain input events,
|
||||
//! render the voice mixer and master chain, and fill the output buffers. No
|
||||
//! allocation or locks on this path.
|
||||
//!
|
||||
//! Nodes: `galdr` is the stereo master (after the master chain) and is linked
|
||||
//! to the default sink by the session manager. With `--stems n`, one extra
|
||||
//! mono node per MIDI channel tag (`galdr.ch0` .. `galdr.ch<n-1>`, named by
|
||||
//! the base-36 glyph used in the grid) carries that channel's dry sum. Stem
|
||||
//! nodes are not auto-connected: patch or record them as you like.
|
||||
//!
|
||||
//! All streams of this client are driven on one data-loop thread, one
|
||||
//! callback each per graph cycle. The first callback of a cycle renders the
|
||||
//! frames for every output into a shared scratch buffer; the others copy
|
||||
//! their channel out of it, so master and stems stay sample-aligned.
|
||||
//!
|
||||
//! If the daemon goes away (restart, crash) the connection is torn down and
|
||||
//! rebuilt every second until it is back; the engine state survives.
|
||||
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
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;
|
||||
use crate::engine::{Engine, Event};
|
||||
use crate::engine::{Engine, Event, CHANNELS};
|
||||
|
||||
pub type Shared = Arc<ArcSwap<Program>>;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
/// Largest cycle PipeWire will ask for (clock.quantum-limit default).
|
||||
const MAX_FRAMES: usize = 8192;
|
||||
|
||||
pub struct Config {
|
||||
pub sr: u32,
|
||||
pub latency: u32,
|
||||
pub poly: usize,
|
||||
/// Number of per-channel stem nodes (0..=16).
|
||||
pub stems: usize,
|
||||
}
|
||||
|
||||
/// Start the audio thread. Returns once the first PipeWire connection is up
|
||||
/// (or with the error if it never comes up). Afterwards the thread keeps the
|
||||
/// engine and reconnects on its own; `connected` tells the UI which state it
|
||||
/// is in.
|
||||
pub fn spawn(
|
||||
prog: Shared,
|
||||
events: Consumer<Event>,
|
||||
scope: Producer<f32>,
|
||||
voices: Arc<AtomicU32>,
|
||||
sr: u32,
|
||||
channels: u8,
|
||||
latency: u32,
|
||||
poly: usize,
|
||||
connected: Arc<AtomicBool>,
|
||||
cfg: Config,
|
||||
) -> Result<(), String> {
|
||||
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<(), String>>();
|
||||
thread::Builder::new()
|
||||
.name("galdr-audio".into())
|
||||
.spawn(move || {
|
||||
if let Err(e) =
|
||||
run(prog, events, scope, voices, sr, channels, latency, poly, ready_tx.clone())
|
||||
{
|
||||
let _ = ready_tx.send(Err(e));
|
||||
let render = Rc::new(RefCell::new(Render {
|
||||
engine: Engine::new(cfg.poly, cfg.sr as f32),
|
||||
events,
|
||||
prog,
|
||||
voices,
|
||||
latency: cfg.latency as usize,
|
||||
cycle: 0,
|
||||
frames: 0,
|
||||
master: vec![[0.0; 2]; MAX_FRAMES],
|
||||
stems: vec![[0.0; CHANNELS]; MAX_FRAMES],
|
||||
}));
|
||||
let mut first = Some(ready_tx);
|
||||
loop {
|
||||
let outcome = run(&render, &cfg, &connected, &mut first);
|
||||
connected.store(false, Ordering::Relaxed);
|
||||
match outcome {
|
||||
Err(e) => {
|
||||
if let Some(tx) = first.take() {
|
||||
// never connected: report once and give up
|
||||
let _ = tx.send(Err(e));
|
||||
return;
|
||||
}
|
||||
eprintln!("\r\n[galdr] PipeWire connection lost ({e}); reconnecting\r");
|
||||
}
|
||||
Ok(()) => eprintln!("\r\n[galdr] PipeWire connection closed; reconnecting\r"),
|
||||
}
|
||||
thread::sleep(Duration::from_secs(1));
|
||||
}
|
||||
})
|
||||
.map_err(|e| format!("spawn audio thread: {e}"))?;
|
||||
ready_rx.recv().map_err(|_| "audio thread exited before start".to_string())?
|
||||
ready_rx
|
||||
.recv()
|
||||
.map_err(|_| "audio thread exited before start".to_string())?
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn run(
|
||||
/// One graph cycle's worth of rendered audio, shared by all stream callbacks.
|
||||
struct Render {
|
||||
engine: Engine,
|
||||
events: Consumer<Event>,
|
||||
prog: Shared,
|
||||
mut events: Consumer<Event>,
|
||||
mut scope: Producer<f32>,
|
||||
voices: Arc<AtomicU32>,
|
||||
sr: u32,
|
||||
channels: u8,
|
||||
latency: u32,
|
||||
poly: usize,
|
||||
ready: Sender<Result<(), String>>,
|
||||
latency: usize,
|
||||
/// Cycle counter: bumped each time a new cycle is rendered.
|
||||
cycle: u64,
|
||||
frames: usize,
|
||||
master: Vec<[f32; 2]>,
|
||||
stems: Vec<[f32; CHANNELS]>,
|
||||
}
|
||||
|
||||
impl Render {
|
||||
/// Render `want` frames if this callback is the first of a new cycle.
|
||||
/// `seen` is the calling stream's last rendered cycle.
|
||||
fn ensure(&mut self, seen: u64, want: usize) {
|
||||
if seen != self.cycle {
|
||||
return; // another stream already rendered this cycle
|
||||
}
|
||||
// Render only what the graph asked for this cycle. The mapped
|
||||
// buffer is sized by clock.quantum-limit (8192 frames by default),
|
||||
// so filling it whole would do ~32x the work of a 256-frame
|
||||
// quantum and PipeWire would drop everything past the quantum:
|
||||
// choppy output and envelopes that race through their stages.
|
||||
let want = if want > 0 { want } else { self.latency }.min(MAX_FRAMES);
|
||||
let program = self.prog.load();
|
||||
// Drain all pending input events once per cycle.
|
||||
while let Ok(ev) = self.events.pop() {
|
||||
self.engine.apply(ev);
|
||||
}
|
||||
for f in 0..want {
|
||||
self.master[f] = self.engine.render(&program);
|
||||
self.stems[f] = self.engine.stems;
|
||||
}
|
||||
self.frames = want;
|
||||
self.cycle += 1;
|
||||
self.voices
|
||||
.store(self.engine.active_voices() as u32, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
fn format_pod(sr: u32, positions: &[u32]) -> Result<Vec<u8>, String> {
|
||||
let mut audio_info = AudioInfoRaw::new();
|
||||
audio_info.set_format(AudioFormat::F32LE);
|
||||
audio_info.set_rate(sr);
|
||||
audio_info.set_channels(positions.len() as u32);
|
||||
let mut pos = [0u32; spa::sys::SPA_AUDIO_MAX_CHANNELS as usize];
|
||||
pos[..positions.len()].copy_from_slice(positions);
|
||||
audio_info.set_position(pos);
|
||||
let obj = spa::pod::Object {
|
||||
type_: spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
|
||||
id: spa::param::ParamType::EnumFormat.as_raw(),
|
||||
properties: audio_info.into(),
|
||||
};
|
||||
Ok(spa::pod::serialize::PodSerializer::serialize(
|
||||
std::io::Cursor::new(Vec::new()),
|
||||
&spa::pod::Value::Object(obj),
|
||||
)
|
||||
.map_err(|e| format!("pod: {e}"))?
|
||||
.0
|
||||
.into_inner())
|
||||
}
|
||||
|
||||
/// The base-36 glyph for a channel index, as used for grid tags.
|
||||
pub fn glyph(ch: usize) -> char {
|
||||
b"0123456789abcdefghijklmnopqrstuvwxyz"[ch % 36] as char
|
||||
}
|
||||
|
||||
/// One connection to the daemon: build the streams, run the loop until the
|
||||
/// connection fails (core error, stream error), then return so the caller
|
||||
/// can retry with the same `Render`.
|
||||
fn run(
|
||||
render: &Rc<RefCell<Render>>,
|
||||
cfg: &Config,
|
||||
connected: &Arc<AtomicBool>,
|
||||
ready: &mut Option<Sender<Result<(), String>>>,
|
||||
) -> Result<(), String> {
|
||||
pw::init();
|
||||
let mainloop = pw::main_loop::MainLoopRc::new(None).map_err(|e| format!("mainloop: {e}"))?;
|
||||
let context = pw::context::ContextRc::new(&mainloop, None).map_err(|e| format!("context: {e}"))?;
|
||||
let core = context.connect_rc(None).map_err(|e| format!("connect PipeWire: {e}"))?;
|
||||
let context =
|
||||
pw::context::ContextRc::new(&mainloop, None).map_err(|e| format!("context: {e}"))?;
|
||||
let core = context
|
||||
.connect_rc(None)
|
||||
.map_err(|e| format!("connect PipeWire: {e}"))?;
|
||||
|
||||
let sr = cfg.sr;
|
||||
let latency = cfg.latency;
|
||||
let stems = cfg.stems.min(CHANNELS);
|
||||
|
||||
// The daemon going away surfaces as an error on the core object itself
|
||||
// (id 0). Quit the loop; the caller reconnects.
|
||||
let ml = mainloop.clone();
|
||||
let _core_listener = core
|
||||
.add_listener_local()
|
||||
.error(move |id, _seq, res, msg| {
|
||||
if id == 0 {
|
||||
eprintln!("\r\n[galdr] PipeWire core error {res}: {msg}\r");
|
||||
ml.quit();
|
||||
}
|
||||
})
|
||||
.register();
|
||||
|
||||
// Keep every stream and listener alive for the connection's lifetime.
|
||||
let mut streams = Vec::new();
|
||||
let mut listeners = Vec::new();
|
||||
|
||||
// ---- master: stereo, auto-connected ----
|
||||
let props = properties! {
|
||||
*pw::keys::MEDIA_TYPE => "Audio",
|
||||
*pw::keys::MEDIA_CATEGORY => "Playback",
|
||||
@@ -68,65 +213,56 @@ fn run(
|
||||
*pw::keys::NODE_NAME => "galdr",
|
||||
*pw::keys::NODE_LATENCY => format!("{latency}/{sr}"),
|
||||
};
|
||||
let stream = pw::stream::StreamBox::new(&core, "galdr", props).map_err(|e| format!("stream: {e}"))?;
|
||||
|
||||
let ch = channels.max(1) as usize;
|
||||
let mut engine = Engine::new(poly, sr as f32);
|
||||
|
||||
let _listener = stream
|
||||
let stream =
|
||||
pw::stream::StreamBox::new(&core, "galdr", props).map_err(|e| format!("stream: {e}"))?;
|
||||
let r = render.clone();
|
||||
let mut seen: u64 = render.borrow().cycle;
|
||||
let ml = mainloop.clone();
|
||||
let listener = stream
|
||||
.add_local_listener_with_user_data(())
|
||||
.process(move |stream, _| {
|
||||
let Some(mut buffer) = stream.dequeue_buffer() else { return };
|
||||
let datas = buffer.datas_mut();
|
||||
let Some(data) = datas.first_mut() else { return };
|
||||
let stride = ch * 4;
|
||||
let program = prog.load();
|
||||
|
||||
// Drain all pending input events once per buffer.
|
||||
while let Ok(ev) = events.pop() {
|
||||
engine.apply(ev);
|
||||
.state_changed(move |_, _, _old, new| {
|
||||
if let pw::stream::StreamState::Error(e) = new {
|
||||
eprintln!("\r\n[galdr] stream error: {e}\r");
|
||||
ml.quit();
|
||||
}
|
||||
|
||||
let mut n_frames = 0usize;
|
||||
})
|
||||
.process(move |stream, _| {
|
||||
let Some(mut buffer) = stream.dequeue_buffer() else {
|
||||
return;
|
||||
};
|
||||
let requested = buffer.requested() as usize;
|
||||
let datas = buffer.datas_mut();
|
||||
let Some(data) = datas.first_mut() else {
|
||||
return;
|
||||
};
|
||||
let stride = 2 * 4;
|
||||
let mut r = r.borrow_mut();
|
||||
r.ensure(seen, requested);
|
||||
seen = r.cycle;
|
||||
let mut n = 0usize;
|
||||
if let Some(slice) = data.data() {
|
||||
n_frames = slice.len() / stride;
|
||||
for f in 0..n_frames {
|
||||
let smp = engine.render(&program);
|
||||
let _ = scope.push(smp);
|
||||
let bytes = smp.to_le_bytes();
|
||||
for c in 0..ch {
|
||||
let o = f * stride + c * 4;
|
||||
slice[o..o + 4].copy_from_slice(&bytes);
|
||||
}
|
||||
n = r.frames.min(slice.len() / stride);
|
||||
for (f, m) in r.master[..n].iter().enumerate() {
|
||||
let o = f * stride;
|
||||
slice[o..o + 4].copy_from_slice(&m[0].to_le_bytes());
|
||||
slice[o + 4..o + 8].copy_from_slice(&m[1].to_le_bytes());
|
||||
}
|
||||
}
|
||||
let chunk = data.chunk_mut();
|
||||
*chunk.offset_mut() = 0;
|
||||
*chunk.stride_mut() = stride as _;
|
||||
*chunk.size_mut() = (stride * n_frames) as _;
|
||||
voices.store(engine.active_voices() as u32, Ordering::Relaxed);
|
||||
*chunk.size_mut() = (stride * n) as _;
|
||||
})
|
||||
.register()
|
||||
.map_err(|e| format!("listener: {e}"))?;
|
||||
|
||||
let mut audio_info = AudioInfoRaw::new();
|
||||
audio_info.set_format(AudioFormat::F32LE);
|
||||
audio_info.set_rate(sr);
|
||||
audio_info.set_channels(channels as u32);
|
||||
let obj = spa::pod::Object {
|
||||
type_: spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
|
||||
id: spa::param::ParamType::EnumFormat.as_raw(),
|
||||
properties: audio_info.into(),
|
||||
};
|
||||
let values: Vec<u8> = spa::pod::serialize::PodSerializer::serialize(
|
||||
std::io::Cursor::new(Vec::new()),
|
||||
&spa::pod::Value::Object(obj),
|
||||
)
|
||||
.map_err(|e| format!("pod: {e}"))?
|
||||
.0
|
||||
.into_inner();
|
||||
let values = format_pod(
|
||||
sr,
|
||||
&[
|
||||
spa::sys::SPA_AUDIO_CHANNEL_FL,
|
||||
spa::sys::SPA_AUDIO_CHANNEL_FR,
|
||||
],
|
||||
)?;
|
||||
let mut params = [Pod::from_bytes(&values).ok_or("bad pod")?];
|
||||
|
||||
stream
|
||||
.connect(
|
||||
spa::utils::Direction::Output,
|
||||
@@ -137,8 +273,78 @@ fn run(
|
||||
&mut params,
|
||||
)
|
||||
.map_err(|e| format!("connect stream: {e}"))?;
|
||||
listeners.push(listener);
|
||||
streams.push(stream);
|
||||
|
||||
let _ = ready.send(Ok(()));
|
||||
// ---- stems: mono, one node per channel tag, not auto-connected ----
|
||||
for s in 0..stems {
|
||||
let name = format!("galdr.ch{}", glyph(s));
|
||||
let props = properties! {
|
||||
*pw::keys::MEDIA_TYPE => "Audio",
|
||||
*pw::keys::MEDIA_CATEGORY => "Playback",
|
||||
*pw::keys::MEDIA_ROLE => "Music",
|
||||
*pw::keys::NODE_NAME => name.as_str(),
|
||||
*pw::keys::NODE_DESCRIPTION => format!("galdr stem: MIDI channel tag {}", glyph(s)),
|
||||
*pw::keys::NODE_AUTOCONNECT => "false",
|
||||
*pw::keys::NODE_LATENCY => format!("{latency}/{sr}"),
|
||||
};
|
||||
let stream = pw::stream::StreamBox::new(&core, &name, props)
|
||||
.map_err(|e| format!("stem stream: {e}"))?;
|
||||
let r = render.clone();
|
||||
let mut seen: u64 = render.borrow().cycle;
|
||||
let ml = mainloop.clone();
|
||||
let listener = stream
|
||||
.add_local_listener_with_user_data(())
|
||||
.state_changed(move |_, _, _old, new| {
|
||||
if let pw::stream::StreamState::Error(e) = new {
|
||||
eprintln!("\r\n[galdr] stem stream error: {e}\r");
|
||||
ml.quit();
|
||||
}
|
||||
})
|
||||
.process(move |stream, _| {
|
||||
let Some(mut buffer) = stream.dequeue_buffer() else {
|
||||
return;
|
||||
};
|
||||
let requested = buffer.requested() as usize;
|
||||
let datas = buffer.datas_mut();
|
||||
let Some(data) = datas.first_mut() else {
|
||||
return;
|
||||
};
|
||||
let mut r = r.borrow_mut();
|
||||
r.ensure(seen, requested);
|
||||
seen = r.cycle;
|
||||
let mut n = 0usize;
|
||||
if let Some(slice) = data.data() {
|
||||
n = r.frames.min(slice.len() / 4);
|
||||
for (f, st) in r.stems[..n].iter().enumerate() {
|
||||
slice[f * 4..f * 4 + 4].copy_from_slice(&st[s].to_le_bytes());
|
||||
}
|
||||
}
|
||||
let chunk = data.chunk_mut();
|
||||
*chunk.offset_mut() = 0;
|
||||
*chunk.stride_mut() = 4;
|
||||
*chunk.size_mut() = (4 * n) as _;
|
||||
})
|
||||
.register()
|
||||
.map_err(|e| format!("stem listener: {e}"))?;
|
||||
let values = format_pod(sr, &[spa::sys::SPA_AUDIO_CHANNEL_MONO])?;
|
||||
let mut params = [Pod::from_bytes(&values).ok_or("bad pod")?];
|
||||
stream
|
||||
.connect(
|
||||
spa::utils::Direction::Output,
|
||||
None,
|
||||
pw::stream::StreamFlags::MAP_BUFFERS | pw::stream::StreamFlags::RT_PROCESS,
|
||||
&mut params,
|
||||
)
|
||||
.map_err(|e| format!("connect stem stream: {e}"))?;
|
||||
listeners.push(listener);
|
||||
streams.push(stream);
|
||||
}
|
||||
|
||||
connected.store(true, Ordering::Relaxed);
|
||||
if let Some(tx) = ready.take() {
|
||||
let _ = tx.send(Ok(()));
|
||||
}
|
||||
mainloop.run();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+890
-92
File diff suppressed because it is too large
Load Diff
+419
-39
@@ -1,12 +1,36 @@
|
||||
//! Voice allocation and the polyphonic mixer. Owned by the audio thread.
|
||||
//! Voice allocation, the polyphonic mixer, per-channel stems and the master
|
||||
//! chain. Owned by the audio thread.
|
||||
|
||||
use crate::dsp::{eval_voice, Program, Voice};
|
||||
use crate::dsp::{eval_post, eval_voice, Program, Voice, BUF_SECONDS, MAX_BUFS, MAX_STATE};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub const CHANNELS: usize = 16;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Event {
|
||||
NoteOn { note: u8, vel: u8 },
|
||||
NoteOff { note: u8 },
|
||||
Cc { idx: u8, val: u8 },
|
||||
NoteOn {
|
||||
ch: u8,
|
||||
note: u8,
|
||||
vel: u8,
|
||||
},
|
||||
NoteOff {
|
||||
ch: u8,
|
||||
note: u8,
|
||||
},
|
||||
/// MIDI control change -> register (0..=127)
|
||||
Cc {
|
||||
idx: u8,
|
||||
val: u8,
|
||||
},
|
||||
/// Register set from UDP (0.0..=1.0)
|
||||
Reg {
|
||||
idx: u8,
|
||||
val: f32,
|
||||
},
|
||||
/// Retrigger one column, no note: a short gate at the column channel's
|
||||
/// last pitch.
|
||||
Bang {
|
||||
col: u8,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct Engine {
|
||||
@@ -14,15 +38,35 @@ pub struct Engine {
|
||||
regs: Vec<f32>,
|
||||
sr: f32,
|
||||
clock: u64,
|
||||
last_freq: [f32; CHANNELS],
|
||||
/// Dry per-MIDI-channel sums of the last rendered frame.
|
||||
pub stems: [f32; CHANNELS],
|
||||
post_state: Vec<f32>,
|
||||
bufs: Vec<Vec<f32>>,
|
||||
buf_len: usize,
|
||||
buf_pos: usize,
|
||||
/// Copied from the program each render: which channels slide.
|
||||
legato: [bool; CHANNELS],
|
||||
/// Per-sample glide coefficient (~60 ms).
|
||||
glide: f32,
|
||||
}
|
||||
|
||||
impl Engine {
|
||||
pub fn new(poly: usize, sr: f32) -> Self {
|
||||
let buf_len = (sr * BUF_SECONDS) as usize;
|
||||
Engine {
|
||||
voices: (0..poly).map(|_| Voice::new()).collect(),
|
||||
regs: vec![0.0; 128],
|
||||
sr,
|
||||
clock: 0,
|
||||
last_freq: [220.0; CHANNELS],
|
||||
stems: [0.0; CHANNELS],
|
||||
post_state: vec![0.0; MAX_STATE],
|
||||
bufs: (0..MAX_BUFS).map(|_| vec![0.0; 2 * buf_len]).collect(),
|
||||
buf_len,
|
||||
buf_pos: 0,
|
||||
legato: [false; CHANNELS],
|
||||
glide: 1.0 / (0.06 * sr),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,64 +74,400 @@ impl Engine {
|
||||
self.voices.iter().filter(|v| v.active).count()
|
||||
}
|
||||
|
||||
/// A free voice, else the oldest one (stolen).
|
||||
fn allocate(&mut self) -> usize {
|
||||
self.clock += 1;
|
||||
self.voices
|
||||
.iter()
|
||||
.position(|v| !v.active)
|
||||
.unwrap_or_else(|| {
|
||||
let mut best = 0;
|
||||
for i in 1..self.voices.len() {
|
||||
if self.voices[i].age < self.voices[best].age {
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
best
|
||||
})
|
||||
}
|
||||
|
||||
fn start(&mut self, idx: usize, ch: u8, note: u8, freq: f32, vel: f32) {
|
||||
let clock = self.clock;
|
||||
let v = &mut self.voices[idx];
|
||||
v.active = true;
|
||||
v.ch = ch;
|
||||
v.note = note;
|
||||
v.freq = freq;
|
||||
v.gate = 1.0;
|
||||
v.vel = vel;
|
||||
v.age = clock;
|
||||
v.only_col = None;
|
||||
v.trig_left = 0;
|
||||
v.resolve_ch = false;
|
||||
v.t = 0;
|
||||
v.target = freq;
|
||||
v.rand = v.next_rand();
|
||||
// fresh phases/env so a stolen voice doesn't click from old state
|
||||
for x in v.state.iter_mut() {
|
||||
*x = 0.0;
|
||||
}
|
||||
v.env_level = 0.0;
|
||||
}
|
||||
|
||||
pub fn apply(&mut self, ev: Event) {
|
||||
match ev {
|
||||
Event::NoteOn { note, vel } => {
|
||||
self.clock += 1;
|
||||
// Prefer a free voice, else steal the oldest.
|
||||
let idx = self
|
||||
.voices
|
||||
.iter()
|
||||
.position(|v| !v.active)
|
||||
.unwrap_or_else(|| {
|
||||
let mut best = 0;
|
||||
for i in 1..self.voices.len() {
|
||||
if self.voices[i].age < self.voices[best].age {
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
best
|
||||
});
|
||||
let v = &mut self.voices[idx];
|
||||
v.active = true;
|
||||
v.note = note;
|
||||
v.freq = 440.0 * 2f32.powf((note as f32 - 69.0) / 12.0);
|
||||
v.gate = 1.0;
|
||||
v.vel = vel as f32 / 127.0;
|
||||
v.age = self.clock;
|
||||
// fresh phases/env so a stolen voice doesn't click from old state
|
||||
for x in v.state.iter_mut() {
|
||||
*x = 0.0;
|
||||
Event::NoteOn { ch, note, vel } => {
|
||||
let ch = ch % CHANNELS as u8;
|
||||
let freq = 440.0 * 2f32.powf((note as f32 - 69.0) / 12.0);
|
||||
self.last_freq[ch as usize] = freq;
|
||||
// Slide: on a legato channel an overlapping note retunes the
|
||||
// held voice instead of starting one (envelopes keep running).
|
||||
if self.legato[ch as usize] {
|
||||
if let Some(v) = self
|
||||
.voices
|
||||
.iter_mut()
|
||||
.find(|v| v.active && v.ch == ch && v.gate > 0.5 && v.only_col.is_none())
|
||||
{
|
||||
v.target = freq;
|
||||
v.note = note;
|
||||
return;
|
||||
}
|
||||
}
|
||||
v.env_level = 0.0;
|
||||
let idx = self.allocate();
|
||||
self.start(idx, ch, note, freq, vel as f32 / 127.0);
|
||||
}
|
||||
Event::NoteOff { note } => {
|
||||
Event::NoteOff { ch, note } => {
|
||||
let ch = ch % CHANNELS as u8;
|
||||
for v in self.voices.iter_mut() {
|
||||
if v.active && v.note == note {
|
||||
if v.active && v.note == note && v.ch == ch && v.only_col.is_none() {
|
||||
v.gate = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Cc { idx, val } => {
|
||||
self.regs[idx as usize] = val as f32 / 127.0;
|
||||
self.regs[idx as usize & 127] = val as f32 / 127.0;
|
||||
}
|
||||
Event::Reg { idx, val } => {
|
||||
self.regs[idx as usize & 127] = val.clamp(0.0, 1.0);
|
||||
}
|
||||
Event::Bang { col } => {
|
||||
// A bang voice plays one column at the most recent pitch
|
||||
// heard, with a 5 ms gate. Its channel (hence its stem) is
|
||||
// resolved from the column's tag at the next render.
|
||||
let freq = self.last_freq.iter().cloned().fold(0.0, f32::max).max(55.0);
|
||||
let idx = self.allocate();
|
||||
let note = (69.0 + 12.0 * (freq / 440.0).log2())
|
||||
.round()
|
||||
.clamp(0.0, 127.0) as u8;
|
||||
self.start(idx, 0, note, freq, 1.0);
|
||||
let v = &mut self.voices[idx];
|
||||
v.only_col = Some(col);
|
||||
v.trig_left = (self.sr * 0.005) as u32;
|
||||
v.resolve_ch = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render one stereo frame of the master (after the master chain) and
|
||||
/// refresh `stems` with the dry per-channel sums.
|
||||
#[inline]
|
||||
pub fn render(&mut self, prog: &Program) -> f32 {
|
||||
pub fn render(&mut self, prog: &Program) -> [f32; 2] {
|
||||
let mut mix = 0.0f32;
|
||||
self.stems = [0.0; CHANNELS];
|
||||
self.legato = prog.legato;
|
||||
for v in self.voices.iter_mut() {
|
||||
if !v.active {
|
||||
continue;
|
||||
}
|
||||
mix += eval_voice(v, prog, &self.regs, self.sr);
|
||||
if v.target != v.freq {
|
||||
v.freq += (v.target - v.freq) * self.glide;
|
||||
if (v.target - v.freq).abs() < 0.01 {
|
||||
v.freq = v.target;
|
||||
}
|
||||
}
|
||||
if v.resolve_ch {
|
||||
v.resolve_ch = false;
|
||||
if let Some(col) = v.only_col {
|
||||
v.ch = prog
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.col == col)
|
||||
.and_then(|n| n.ch)
|
||||
.unwrap_or(0);
|
||||
}
|
||||
}
|
||||
let y = eval_voice(v, prog, &self.regs, self.sr);
|
||||
mix += y;
|
||||
self.stems[v.ch as usize] += y;
|
||||
if v.trig_left > 0 {
|
||||
v.trig_left -= 1;
|
||||
if v.trig_left == 0 {
|
||||
v.gate = 0.0;
|
||||
}
|
||||
}
|
||||
// Reap voices whose gate is closed and envelope has decayed.
|
||||
if v.gate < 0.5 && v.env_level < 1e-4 {
|
||||
v.active = false;
|
||||
}
|
||||
}
|
||||
for s in self.stems.iter_mut() {
|
||||
*s = (*s * 0.3).tanh();
|
||||
}
|
||||
let m = mix * 0.3;
|
||||
let y = eval_post(
|
||||
prog,
|
||||
[m, m],
|
||||
&self.regs,
|
||||
&mut self.post_state,
|
||||
&mut self.bufs,
|
||||
self.buf_len,
|
||||
&mut self.buf_pos,
|
||||
self.sr,
|
||||
);
|
||||
// Gentle headroom limiter.
|
||||
(mix * 0.3).tanh()
|
||||
[y[0].tanh(), y[1].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 {
|
||||
ch: 0,
|
||||
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 {
|
||||
ch: 0,
|
||||
note: 60,
|
||||
vel: 100,
|
||||
});
|
||||
e.render(&p);
|
||||
assert_eq!(e.active_voices(), 1);
|
||||
e.apply(Event::NoteOff { ch: 0, note: 60 });
|
||||
e.render(&p);
|
||||
assert_eq!(e.active_voices(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noteoff_matches_channel() {
|
||||
let p = compile(&grid_from("A\n!"));
|
||||
let mut e = Engine::new(4, 48000.0);
|
||||
e.apply(Event::NoteOn {
|
||||
ch: 1,
|
||||
note: 60,
|
||||
vel: 100,
|
||||
});
|
||||
e.apply(Event::NoteOff { ch: 2, note: 60 }); // wrong channel: ignored
|
||||
e.render(&p);
|
||||
assert_eq!(e.active_voices(), 1);
|
||||
e.apply(Event::NoteOff { ch: 1, 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 {
|
||||
ch: 0,
|
||||
note: 60,
|
||||
vel: 100,
|
||||
});
|
||||
e.apply(Event::NoteOn {
|
||||
ch: 0,
|
||||
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_and_udp_write_registers() {
|
||||
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);
|
||||
e.apply(Event::Reg { idx: 1, val: 0.5 });
|
||||
assert!((e.reg(1) - 0.5).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 {
|
||||
ch: 0,
|
||||
note: 60,
|
||||
vel: 127,
|
||||
});
|
||||
let y = e.render(&p);
|
||||
assert!(y[0].abs() <= 1.0 && y[1].abs() <= 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stems_follow_the_note_channel() {
|
||||
let p = compile(&grid_from("A\n!"));
|
||||
let mut e = Engine::new(4, 48000.0);
|
||||
e.apply(Event::NoteOn {
|
||||
ch: 3,
|
||||
note: 60,
|
||||
vel: 127,
|
||||
});
|
||||
e.render(&p);
|
||||
assert!(e.stems[3].abs() > 0.1);
|
||||
assert!(e.stems.iter().enumerate().all(|(i, s)| i == 3 || *s == 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tagged_columns_split_by_channel() {
|
||||
// col 0 -> channel 1 ('1'), col 2 -> channel 2 ('2')
|
||||
let p = compile(&grid_from("1.2\nA.A\n!.!"));
|
||||
let mut e = Engine::new(4, 48000.0);
|
||||
e.apply(Event::NoteOn {
|
||||
ch: 1,
|
||||
note: 60,
|
||||
vel: 127,
|
||||
});
|
||||
let y = e.render(&p);
|
||||
assert!(y[0].abs() > 0.1);
|
||||
assert!(e.stems[1].abs() > 0.1 && e.stems[2] == 0.0);
|
||||
let mut e = Engine::new(4, 48000.0);
|
||||
e.apply(Event::NoteOn {
|
||||
ch: 5,
|
||||
note: 60,
|
||||
vel: 127,
|
||||
});
|
||||
assert_eq!(e.render(&p), [0.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bang_plays_one_column_briefly() {
|
||||
let p = compile(&grid_from("A.A\n!.!"));
|
||||
let mut e = Engine::new(4, 48000.0);
|
||||
e.apply(Event::Bang { col: 2 });
|
||||
let y = e.render(&p);
|
||||
assert!(y[0].abs() > 0.1);
|
||||
for _ in 0..300 {
|
||||
e.render(&p);
|
||||
}
|
||||
assert_eq!(e.active_voices(), 0, "bang voice gates off after 5 ms");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bang_lands_in_the_tagged_stem() {
|
||||
let p = compile(&grid_from("3\nA\n!"));
|
||||
let mut e = Engine::new(4, 48000.0);
|
||||
e.apply(Event::Bang { col: 0 });
|
||||
e.render(&p);
|
||||
assert!(e.stems[3].abs() > 0.1);
|
||||
assert_eq!(e.stems[0], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legato_channel_slides_instead_of_retriggering() {
|
||||
let p = compile(&grid_from("1>\nA.\n!."));
|
||||
let mut e = Engine::new(4, 48000.0);
|
||||
e.apply(Event::NoteOn {
|
||||
ch: 1,
|
||||
note: 60,
|
||||
vel: 100,
|
||||
});
|
||||
e.render(&p);
|
||||
e.apply(Event::NoteOn {
|
||||
ch: 1,
|
||||
note: 72,
|
||||
vel: 100,
|
||||
}); // overlapping: slide
|
||||
assert_eq!(e.active_voices(), 1);
|
||||
let f0 = e.first_freq().unwrap();
|
||||
for _ in 0..24000 {
|
||||
e.render(&p);
|
||||
}
|
||||
let f1 = e.first_freq().unwrap();
|
||||
assert!(
|
||||
f0 < 300.0 && (f1 - 523.25).abs() < 0.5,
|
||||
"glided {f0} -> {f1}"
|
||||
);
|
||||
// the old note's off is ignored, the new note's off releases
|
||||
e.apply(Event::NoteOff { ch: 1, note: 60 });
|
||||
e.render(&p);
|
||||
assert_eq!(e.active_voices(), 1);
|
||||
e.apply(Event::NoteOff { ch: 1, note: 72 });
|
||||
e.render(&p);
|
||||
assert_eq!(e.active_voices(), 0);
|
||||
// without the flag the same channel is polyphonic
|
||||
let p2 = compile(&grid_from("1\nA\n!"));
|
||||
let mut e = Engine::new(4, 48000.0);
|
||||
e.apply(Event::NoteOn {
|
||||
ch: 1,
|
||||
note: 60,
|
||||
vel: 100,
|
||||
});
|
||||
e.render(&p2);
|
||||
e.apply(Event::NoteOn {
|
||||
ch: 1,
|
||||
note: 72,
|
||||
vel: 100,
|
||||
});
|
||||
e.render(&p2);
|
||||
assert_eq!(e.active_voices(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn master_chain_runs_on_the_mix() {
|
||||
// post gain x2 should double the output of a plain saw
|
||||
let plain = compile(&grid_from(".A\n.!"));
|
||||
let gained = compile(&grid_from(".A\n.!\n9G"));
|
||||
let mut a = Engine::new(1, 48000.0);
|
||||
let mut b = Engine::new(1, 48000.0);
|
||||
a.apply(Event::NoteOn {
|
||||
ch: 0,
|
||||
note: 60,
|
||||
vel: 64,
|
||||
});
|
||||
b.apply(Event::NoteOn {
|
||||
ch: 0,
|
||||
note: 60,
|
||||
vel: 64,
|
||||
});
|
||||
let ya = a.render(&plain)[0].atanh();
|
||||
let yb = b.render(&gained)[0].atanh();
|
||||
assert!((yb - 2.0 * ya).abs() < 1e-4);
|
||||
}
|
||||
}
|
||||
|
||||
+139
-9
@@ -1,31 +1,161 @@
|
||||
//! 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);
|
||||
}
|
||||
|
||||
/// Plain-text patch format: one line per row, `.` for an empty cell.
|
||||
pub fn to_text(&self) -> String {
|
||||
let mut s = String::with_capacity((self.w + 1) * self.h);
|
||||
for row in 0..self.h {
|
||||
for col in 0..self.w {
|
||||
s.push(self.get(col, row));
|
||||
}
|
||||
s.push('\n');
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Parse the text format; the grid takes the text's size (clamped).
|
||||
pub fn from_text(text: &str) -> Self {
|
||||
let lines: Vec<&str> = text.lines().filter(|l| !l.starts_with('#')).collect();
|
||||
let h = lines.len().clamp(1, MAX_H);
|
||||
let w = lines
|
||||
.iter()
|
||||
.map(|l| l.chars().count())
|
||||
.max()
|
||||
.unwrap_or(1)
|
||||
.clamp(1, MAX_W);
|
||||
let mut g = Grid {
|
||||
w,
|
||||
h,
|
||||
cells: vec!['.'; SLOTS],
|
||||
};
|
||||
for (r, line) in lines.iter().enumerate().take(h) {
|
||||
for (c, ch) in line.chars().enumerate().take(w) {
|
||||
g.set(c, r, if ch == ' ' { '.' } else { ch });
|
||||
}
|
||||
}
|
||||
g
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 text_roundtrip() {
|
||||
let mut g = Grid::new();
|
||||
g.resize(3, 4);
|
||||
g.set(1, 0, 'O');
|
||||
g.set(1, 2, '!');
|
||||
let t = g.to_text();
|
||||
assert_eq!(t, ".O..\n....\n.!..\n");
|
||||
let g2 = Grid::from_text(&t);
|
||||
assert_eq!((g2.w, g2.h), (4, 3));
|
||||
assert_eq!(g2.get(1, 2), '!');
|
||||
// spaces read as empty, comment lines are skipped
|
||||
let g3 = Grid::from_text("# patch\nA \n!.\n");
|
||||
assert_eq!((g3.w, g3.h), (2, 2));
|
||||
assert_eq!(g3.get(1, 0), '.');
|
||||
}
|
||||
|
||||
#[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');
|
||||
}
|
||||
}
|
||||
|
||||
+152
-25
@@ -1,17 +1,21 @@
|
||||
//! galdr - a terminal grid synthesizer.
|
||||
//!
|
||||
//! Signal flows downward through single-glyph unit generators; each MIDI note
|
||||
//! voices the whole grid; the mix is rendered low-latency to PipeWire. Pairs
|
||||
//! with orca (MIDI/OSC/UDP out) and spectrust (visualize the output).
|
||||
//! voices the whole grid (or the columns tagged with its channel); the mix is
|
||||
//! rendered low-latency to PipeWire. Pairs with orca (MIDI + UDP out) and
|
||||
//! spectrust (visualize the output).
|
||||
|
||||
mod audio;
|
||||
mod dsp;
|
||||
mod engine;
|
||||
mod grid;
|
||||
mod midi;
|
||||
mod patch;
|
||||
mod tui;
|
||||
mod udp;
|
||||
|
||||
use std::sync::atomic::AtomicU32;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32};
|
||||
use std::sync::Arc;
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
@@ -22,28 +26,54 @@ use crate::grid::Grid;
|
||||
const HELP: &str = "\
|
||||
galdr - terminal grid synthesizer
|
||||
|
||||
Usage: galdr [options]
|
||||
Usage: galdr [options] [patch.galdr]
|
||||
|
||||
Options:
|
||||
--latency <frames> PipeWire quantum; lower = lower latency (default: 256)
|
||||
--poly <n> Number of voices (default: 8)
|
||||
--rate <hz> Sample rate (default: 48000)
|
||||
--stems <n> Add n mono PipeWire nodes galdr.ch0..galdr.ch<n-1>, one per
|
||||
channel tag, carrying that channel's dry sum (default: 0)
|
||||
--udp <port> Listen for orca's `;` UDP strings (default: 49161;
|
||||
0 disables)
|
||||
--patches <dir> Patch directory for `#name` loads and dated saves
|
||||
(default: ~/hometaping)
|
||||
--midi-name <s> Connect to the input port matching <s> instead of
|
||||
creating the virtual 'galdr' port
|
||||
-h, --help Print this message
|
||||
|
||||
Grid glyphs (signal flows down; left/right neighbours are params 0-9 or CC a-z):
|
||||
~ sine ^ saw _ pulse v tri * noise
|
||||
f lowpass(cutoff<-left) e envelope(atk<-left,rel<-right) s shaper(drive<-left)
|
||||
! output to master
|
||||
Grid glyphs are UPPERCASE operators. Signal flows down (input = the cell
|
||||
above); the left and right neighbours are params: digit 0-9 constant, a-z a
|
||||
register (MIDI CC a = 0, or UDP), @ velocity, ^ key tracking, ? per-note random.
|
||||
A base-36 glyph above a column's top operator tags the column with a MIDI
|
||||
channel (0-9 a-f = 1-16); untagged columns play every channel. A '>' right of
|
||||
the tag makes the channel mono-legato: overlapping notes slide (60 ms).
|
||||
sources O sine A saw U pulse V tri N noise X FM (mod <- left cell, index <- right)
|
||||
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)
|
||||
control K decay envelope (decay<-left, floor<-right): put it LEFT of an operator
|
||||
and it drives that operator's left param (2KL5 = 303 filter sweep)
|
||||
routing M mix (adds the cell to its left) ! output to master
|
||||
master operators BELOW a ! process the stereo master in series:
|
||||
Z delay(time<-left, fb<-right) W chorus(rate<-left, depth<-right) + filters/shapers
|
||||
UDP commands (one or more per datagram, split on whitespace/';'):
|
||||
k7 set register k to 7/35 *3 bang column 3 (5 ms gate, no note)
|
||||
@34O write O at column 3, row 4 #verse load <patches>/verse.galdr
|
||||
|
||||
With no patch file, galdr opens ~/hometaping/<date>.galdr if it exists and
|
||||
Ctrl+S writes it (the same dated naming as the orca files kept there).
|
||||
";
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let mut latency: u32 = 256;
|
||||
let mut poly: usize = 8;
|
||||
let mut rate: u32 = 48000;
|
||||
let channels: u8 = 2;
|
||||
let mut stems: usize = 0;
|
||||
let mut udp_port: u16 = 49161;
|
||||
let mut patches: Option<PathBuf> = None;
|
||||
let mut midi_name: Option<String> = None;
|
||||
let mut file: Option<PathBuf> = None;
|
||||
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
@@ -51,41 +81,66 @@ fn main() -> anyhow::Result<()> {
|
||||
"--latency" => latency = args.next().and_then(|v| v.parse().ok()).unwrap_or(latency),
|
||||
"--poly" => poly = args.next().and_then(|v| v.parse().ok()).unwrap_or(poly),
|
||||
"--rate" => rate = args.next().and_then(|v| v.parse().ok()).unwrap_or(rate),
|
||||
"--stems" => stems = args.next().and_then(|v| v.parse().ok()).unwrap_or(stems),
|
||||
"--udp" => udp_port = args.next().and_then(|v| v.parse().ok()).unwrap_or(udp_port),
|
||||
"--patches" => patches = args.next().map(PathBuf::from),
|
||||
"--midi-name" => midi_name = args.next(),
|
||||
"-h" | "--help" => {
|
||||
print!("{HELP}");
|
||||
return Ok(());
|
||||
}
|
||||
other if !other.starts_with('-') && file.is_none() => file = Some(PathBuf::from(other)),
|
||||
other => {
|
||||
eprintln!("unknown option: {other}\n\n{HELP}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
let stems = stems.min(engine::CHANNELS);
|
||||
let patches = patches.unwrap_or_else(patch::default_dir);
|
||||
|
||||
// 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, '!');
|
||||
// No file: today's patch (~/hometaping/<date>.galdr) if it exists, so the
|
||||
// session continues where it left off and Ctrl+S keeps writing it.
|
||||
if file.is_none() {
|
||||
let today = patch::dated(&patches, &patch::today());
|
||||
if today.exists() {
|
||||
file = Some(today);
|
||||
}
|
||||
}
|
||||
let grid = match &file {
|
||||
Some(p) if p.exists() => patch::load(p)?,
|
||||
_ => default_grid(),
|
||||
};
|
||||
|
||||
let prog = Arc::new(ArcSwap::from_pointee(compile(&grid)));
|
||||
let voices = Arc::new(AtomicU32::new(0));
|
||||
let connected = Arc::new(AtomicBool::new(false));
|
||||
|
||||
// The audio thread drains one lock-free ring. MIDI (a midir callback) and
|
||||
// UDP (its own thread) are separate producers, so each gets its own ring
|
||||
// and a small pump thread merges them; no callback ever blocks.
|
||||
let (prod_ev, cons_ev) = rtrb::RingBuffer::new(1024);
|
||||
let (prod_sc, cons_sc) = rtrb::RingBuffer::new(16384);
|
||||
let (prod_midi, cons_midi) = rtrb::RingBuffer::new(1024);
|
||||
let (prod_udp, cons_udp) = rtrb::RingBuffer::new(1024);
|
||||
let (ctrl_tx, ctrl_rx) = std::sync::mpsc::channel();
|
||||
|
||||
audio::spawn(prog.clone(), cons_ev, prod_sc, voices.clone(), rate, channels, latency, poly)
|
||||
.map_err(|e| anyhow::anyhow!("audio: {e}"))?;
|
||||
audio::spawn(
|
||||
prog.clone(),
|
||||
cons_ev,
|
||||
voices.clone(),
|
||||
connected.clone(),
|
||||
audio::Config {
|
||||
sr: rate,
|
||||
latency,
|
||||
poly,
|
||||
stems,
|
||||
},
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("audio: {e}"))?;
|
||||
spawn_pump(prod_ev, vec![cons_midi, cons_udp])?;
|
||||
|
||||
// Keep the MIDI connection alive for the program's lifetime.
|
||||
let _midi = match midi::connect(prod_ev, midi_name) {
|
||||
let _midi = match midi::connect(prod_midi, midi_name) {
|
||||
Ok(conn) => Some(conn),
|
||||
Err(e) => {
|
||||
eprintln!("warning: MIDI unavailable ({e}); running without note input");
|
||||
@@ -93,6 +148,78 @@ fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
};
|
||||
|
||||
tui::run(prog, cons_sc, voices, grid, latency)?;
|
||||
let udp = if udp_port > 0 {
|
||||
match udp::spawn(udp_port, prod_udp, ctrl_tx) {
|
||||
Ok(()) => Some(udp_port),
|
||||
Err(e) => {
|
||||
eprintln!("warning: UDP unavailable ({e}); running without orca control");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
tui::run(
|
||||
tui::Session {
|
||||
prog,
|
||||
voices,
|
||||
connected,
|
||||
latency,
|
||||
ctrl: ctrl_rx,
|
||||
patches,
|
||||
file,
|
||||
udp_port: udp,
|
||||
},
|
||||
grid,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn spawn_pump(
|
||||
mut out: rtrb::Producer<engine::Event>,
|
||||
mut inputs: Vec<rtrb::Consumer<engine::Event>>,
|
||||
) -> std::io::Result<()> {
|
||||
// GALDR_DEBUG=<file> appends every event that reaches the engine.
|
||||
let mut log = std::env::var_os("GALDR_DEBUG").and_then(|p| {
|
||||
std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(p)
|
||||
.ok()
|
||||
});
|
||||
std::thread::Builder::new()
|
||||
.name("galdr-pump".into())
|
||||
.spawn(move || loop {
|
||||
let mut idle = true;
|
||||
for c in inputs.iter_mut() {
|
||||
while let Ok(ev) = c.pop() {
|
||||
if let Some(f) = log.as_mut() {
|
||||
use std::io::Write;
|
||||
let _ = writeln!(f, "{ev:?}");
|
||||
}
|
||||
let _ = out.push(ev);
|
||||
idle = false;
|
||||
}
|
||||
}
|
||||
if idle {
|
||||
std::thread::sleep(std::time::Duration::from_micros(500));
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A default patch so the first run makes sound: a plain sine->env voice and
|
||||
/// a filtered saw voice.
|
||||
fn default_grid() -> Grid {
|
||||
let mut grid = Grid::new();
|
||||
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
|
||||
grid
|
||||
}
|
||||
|
||||
+67
-15
@@ -1,5 +1,6 @@
|
||||
//! MIDI input. galdr owns a virtual input port named "galdr" (symmetric with
|
||||
//! orca's virtual output), so orca or any client connects to it in the graph.
|
||||
//! The channel nibble is kept: tagged columns voice only their channel.
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use midir::os::unix::VirtualInput;
|
||||
@@ -8,34 +9,85 @@ use rtrb::Producer;
|
||||
|
||||
use crate::engine::Event;
|
||||
|
||||
pub fn connect(prod: Producer<Event>, port_name: Option<String>) -> Result<MidiInputConnection<Producer<Event>>> {
|
||||
/// Decode one MIDI message into an engine event.
|
||||
pub fn decode(msg: &[u8]) -> Option<Event> {
|
||||
if msg.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let ch = msg[0] & 0x0F;
|
||||
match msg[0] & 0xF0 {
|
||||
0x90 if msg[2] > 0 => Some(Event::NoteOn {
|
||||
ch,
|
||||
note: msg[1],
|
||||
vel: msg[2],
|
||||
}),
|
||||
0x80 | 0x90 => Some(Event::NoteOff { ch, note: msg[1] }), // note-on velocity 0
|
||||
0xB0 => Some(Event::Cc {
|
||||
idx: msg[1],
|
||||
val: msg[2],
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn connect(
|
||||
prod: Producer<Event>,
|
||||
port_name: Option<String>,
|
||||
) -> Result<MidiInputConnection<Producer<Event>>> {
|
||||
let mut input = MidiInput::new("galdr").map_err(|e| anyhow!("MIDI init: {e}"))?;
|
||||
input.ignore(midir::Ignore::None);
|
||||
|
||||
let cb = move |_stamp: u64, msg: &[u8], prod: &mut Producer<Event>| {
|
||||
if msg.len() < 2 {
|
||||
return;
|
||||
if let Some(ev) = decode(msg) {
|
||||
let _ = prod.push(ev);
|
||||
}
|
||||
let status = msg[0] & 0xF0;
|
||||
let ev = match status {
|
||||
0x90 if msg[2] > 0 => Event::NoteOn { note: msg[1], vel: msg[2] },
|
||||
0x80 => Event::NoteOff { note: msg[1] },
|
||||
0x90 => Event::NoteOff { note: msg[1] }, // note-on velocity 0
|
||||
0xB0 => Event::Cc { idx: msg[1], val: msg[2] },
|
||||
_ => return,
|
||||
};
|
||||
let _ = prod.push(ev);
|
||||
};
|
||||
|
||||
if let Some(name) = port_name {
|
||||
let ports = input.ports();
|
||||
let port = ports
|
||||
.iter()
|
||||
.find(|p| input.port_name(p).map(|n| n.contains(&name)).unwrap_or(false))
|
||||
.find(|p| {
|
||||
input
|
||||
.port_name(p)
|
||||
.map(|n| n.contains(&name))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow!("no MIDI input port matching {name:?}"))?;
|
||||
input.connect(&port, "galdr-in", cb, prod).map_err(|e| anyhow!("MIDI connect: {e}"))
|
||||
input
|
||||
.connect(&port, "galdr-in", cb, prod)
|
||||
.map_err(|e| anyhow!("MIDI connect: {e}"))
|
||||
} else {
|
||||
input.create_virtual("galdr", cb, prod).map_err(|e| anyhow!("create virtual MIDI port: {e}"))
|
||||
input
|
||||
.create_virtual("galdr", cb, prod)
|
||||
.map_err(|e| anyhow!("create virtual MIDI port: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn decode_keeps_channel() {
|
||||
assert_eq!(
|
||||
decode(&[0x92, 60, 100]),
|
||||
Some(Event::NoteOn {
|
||||
ch: 2,
|
||||
note: 60,
|
||||
vel: 100
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
decode(&[0x8F, 60, 0]),
|
||||
Some(Event::NoteOff { ch: 15, note: 60 })
|
||||
);
|
||||
assert_eq!(
|
||||
decode(&[0x90, 60, 0]),
|
||||
Some(Event::NoteOff { ch: 0, note: 60 })
|
||||
);
|
||||
assert_eq!(decode(&[0xB3, 7, 64]), Some(Event::Cc { idx: 7, val: 64 }));
|
||||
assert_eq!(decode(&[0xF8]), None);
|
||||
}
|
||||
}
|
||||
|
||||
+293
@@ -0,0 +1,293 @@
|
||||
//! Patch files: the grid's text format on disk. `galdr <file>` opens one,
|
||||
//! Ctrl+S writes it back, and `#name` over UDP loads `<patches dir>/name.galdr`.
|
||||
//!
|
||||
//! With no file given, galdr works on today's patch, `~/hometaping/<date>.galdr`
|
||||
//! (`date -I` form), next to the orca files kept the same way: starting galdr
|
||||
//! opens it if it exists and Ctrl+S writes it.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::grid::Grid;
|
||||
|
||||
pub const EXT: &str = "galdr";
|
||||
|
||||
/// The patches directory: `~/hometaping`, where the dated orca files live.
|
||||
pub fn default_dir() -> PathBuf {
|
||||
std::env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("hometaping")
|
||||
}
|
||||
|
||||
/// Today's date as `date -I` prints it (local time), with a UTC fallback.
|
||||
pub fn today() -> String {
|
||||
if let Ok(out) = std::process::Command::new("date").arg("-I").output() {
|
||||
if out.status.success() {
|
||||
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if s.len() == 10 {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
}
|
||||
let secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
civil_from_days((secs / 86400) as i64)
|
||||
}
|
||||
|
||||
/// Howard Hinnant's days-since-epoch to YYYY-MM-DD.
|
||||
fn civil_from_days(z: i64) -> String {
|
||||
let z = z + 719468;
|
||||
let era = if z >= 0 { z } else { z - 146096 } / 146097;
|
||||
let doe = z - era * 146097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if m <= 2 { y + 1 } else { y };
|
||||
format!("{y:04}-{m:02}-{d:02}")
|
||||
}
|
||||
|
||||
/// `<dir>/<date>.galdr`: the patch Ctrl+S writes when no file was given.
|
||||
pub fn dated(dir: &Path, date: &str) -> PathBuf {
|
||||
dir.join(format!("{date}.{EXT}"))
|
||||
}
|
||||
|
||||
/// Resolve a UDP patch name inside the patches directory (no path escapes:
|
||||
/// the parser already restricts names to alphanumerics, `-` and `_`).
|
||||
/// `.galdr` is the name; a legacy `.txt` is accepted if that is what exists.
|
||||
pub fn named(dir: &Path, name: &str) -> PathBuf {
|
||||
let p = dir.join(format!("{name}.{EXT}"));
|
||||
if p.exists() {
|
||||
return p;
|
||||
}
|
||||
let legacy = dir.join(format!("{name}.txt"));
|
||||
if legacy.exists() {
|
||||
legacy
|
||||
} else {
|
||||
p
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(path: &Path) -> Result<Grid> {
|
||||
let text = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
|
||||
Ok(Grid::from_text(&text))
|
||||
}
|
||||
|
||||
pub fn save(path: &Path, grid: &Grid) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
|
||||
}
|
||||
std::fs::write(path, grid.to_text()).with_context(|| format!("write {}", path.display()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dated_path_matches_orca_convention() {
|
||||
let p = dated(Path::new("/home/x/hometaping"), "2026-10-08");
|
||||
assert_eq!(p, PathBuf::from("/home/x/hometaping/2026-10-08.galdr"));
|
||||
assert_eq!(civil_from_days(0), "1970-01-01");
|
||||
assert_eq!(civil_from_days(20734), "2026-10-08");
|
||||
assert_eq!(today().len(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_prefers_galdr_then_legacy_txt() {
|
||||
let dir = std::env::temp_dir().join(format!("galdr-named-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
assert_eq!(named(&dir, "x"), dir.join("x.galdr"));
|
||||
std::fs::write(dir.join("y.txt"), "A\n!\n").unwrap();
|
||||
assert_eq!(named(&dir, "y"), dir.join("y.txt"));
|
||||
std::fs::write(dir.join("y.galdr"), "A\n!\n").unwrap();
|
||||
assert_eq!(named(&dir, "y"), dir.join("y.galdr"));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_and_load_roundtrip() {
|
||||
let dir = std::env::temp_dir().join(format!("galdr-patch-test-{}", std::process::id()));
|
||||
let path = named(&dir, "verse");
|
||||
let mut g = Grid::new();
|
||||
g.resize(2, 3);
|
||||
g.set(0, 0, 'A');
|
||||
g.set(0, 1, '!');
|
||||
save(&path, &g).unwrap();
|
||||
let g2 = load(&path).unwrap();
|
||||
assert_eq!((g2.w, g2.h), (3, 2));
|
||||
assert_eq!(g2.get(0, 1), '!');
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod example_render {
|
||||
use super::*;
|
||||
use crate::dsp::compile;
|
||||
use crate::engine::{Engine, Event};
|
||||
#[test]
|
||||
fn drums_example_each_channel_sounds() {
|
||||
let g = load(std::path::Path::new("examples/drums.galdr")).unwrap();
|
||||
let p = compile(&g);
|
||||
for (ch, note, name) in [
|
||||
(0u8, 36u8, "bass"),
|
||||
(9, 36, "kick"),
|
||||
(10, 50, "snare"),
|
||||
(11, 65, "hats"),
|
||||
] {
|
||||
let mut e = Engine::new(8, 48000.0);
|
||||
e.apply(Event::NoteOn { ch, note, vel: 110 });
|
||||
let mut peak = 0f32;
|
||||
let mut early = 0f32;
|
||||
let mut late = 0f32;
|
||||
let mut zc_early = 0;
|
||||
let mut zc_late = 0;
|
||||
let mut prev = 0f32;
|
||||
for i in 0..24000 {
|
||||
let y = e.render(&p)[0];
|
||||
peak = peak.max(y.abs());
|
||||
if i < 2400 {
|
||||
early = early.max(y.abs());
|
||||
if (y < 0.0) != (prev < 0.0) {
|
||||
zc_early += 1;
|
||||
}
|
||||
}
|
||||
if (12000..14400).contains(&i) {
|
||||
late = late.max(y.abs());
|
||||
if (y < 0.0) != (prev < 0.0) {
|
||||
zc_late += 1;
|
||||
}
|
||||
}
|
||||
prev = y;
|
||||
if i == 4800 {
|
||||
e.apply(Event::NoteOff { ch, note });
|
||||
}
|
||||
}
|
||||
eprintln!("{name:5} peak {peak:.2} first50ms {early:.2} (zc {zc_early}) at250ms {late:.2} (zc {zc_late}) voices {}", e.active_voices());
|
||||
assert!(peak > 0.05 && peak <= 1.0, "{name} silent or clipping");
|
||||
let mut e2 = Engine::new(8, 48000.0);
|
||||
e2.apply(Event::NoteOn {
|
||||
ch: 5,
|
||||
note,
|
||||
vel: 110,
|
||||
});
|
||||
assert!(
|
||||
(0..1000)
|
||||
.map(|_| e2.render(&p)[0].abs())
|
||||
.fold(0f32, f32::max)
|
||||
== 0.0,
|
||||
"{name} leaks onto channel 6"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod orchestra_example {
|
||||
use super::*;
|
||||
use crate::dsp::compile;
|
||||
use crate::engine::{Engine, Event};
|
||||
|
||||
#[test]
|
||||
fn each_section_sounds_and_stays_on_its_channel() {
|
||||
let g = load(std::path::Path::new("examples/orchestra.galdr")).unwrap();
|
||||
let p = compile(&g);
|
||||
assert_eq!(p.post.len(), 1, "one master chain (hall) expected");
|
||||
assert_eq!(p.post[0].len(), 3, "highpass, chorus, delay");
|
||||
let names = [
|
||||
"strings",
|
||||
"low strings",
|
||||
"brass",
|
||||
"horns",
|
||||
"flute",
|
||||
"reed",
|
||||
"clarinet",
|
||||
"timpani",
|
||||
"harp",
|
||||
"choir",
|
||||
];
|
||||
for (ch, name) in names.iter().enumerate() {
|
||||
let note = if ch == 7 { 36 } else { 60 };
|
||||
let mut e = Engine::new(8, 48000.0);
|
||||
e.apply(Event::Reg {
|
||||
idx: b'k' - b'a',
|
||||
val: 0.5,
|
||||
});
|
||||
e.apply(Event::NoteOn {
|
||||
ch: ch as u8,
|
||||
note,
|
||||
vel: 100,
|
||||
});
|
||||
let mut peak = 0f32;
|
||||
for i in 0..48000 {
|
||||
peak = peak.max(e.render(&p)[0].abs());
|
||||
if i == 24000 {
|
||||
e.apply(Event::NoteOff { ch: ch as u8, note });
|
||||
}
|
||||
}
|
||||
eprintln!("{name:12} peak {peak:.2}");
|
||||
assert!(peak > 0.03 && peak <= 1.0, "{name} silent or clipping");
|
||||
let mut e2 = Engine::new(8, 48000.0);
|
||||
e2.apply(Event::NoteOn {
|
||||
ch: 12,
|
||||
note,
|
||||
vel: 100,
|
||||
});
|
||||
assert!(
|
||||
(0..2000)
|
||||
.map(|_| e2.render(&p)[0].abs())
|
||||
.fold(0f32, f32::max)
|
||||
== 0.0,
|
||||
"{name} leaks onto channel 13"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod acid_example {
|
||||
use super::*;
|
||||
use crate::dsp::compile;
|
||||
use crate::engine::{Engine, Event};
|
||||
|
||||
#[test]
|
||||
fn acid_line_sweeps_and_slides() {
|
||||
let g = load(std::path::Path::new("examples/acid.galdr")).unwrap();
|
||||
let p = compile(&g);
|
||||
assert!(p.legato[0], "channel 1 slides");
|
||||
let mut e = Engine::new(8, 48000.0);
|
||||
e.apply(Event::NoteOn {
|
||||
ch: 0,
|
||||
note: 36,
|
||||
vel: 127,
|
||||
});
|
||||
let hf =
|
||||
|x: &[f32]| x.windows(2).map(|w| (w[1] - w[0]).abs()).sum::<f32>() / x.len() as f32;
|
||||
let early: Vec<f32> = (0..2000).map(|_| e.render(&p)[0]).collect();
|
||||
for _ in 0..12000 {
|
||||
e.render(&p);
|
||||
}
|
||||
let late: Vec<f32> = (0..2000).map(|_| e.render(&p)[0]).collect();
|
||||
assert!(
|
||||
early.iter().fold(0f32, |a, b| a.max(b.abs())) > 0.1,
|
||||
"audible"
|
||||
);
|
||||
assert!(
|
||||
hf(&early) > 1.5 * hf(&late),
|
||||
"filter sweeps down over the note"
|
||||
);
|
||||
e.apply(Event::NoteOn {
|
||||
ch: 0,
|
||||
note: 48,
|
||||
vel: 127,
|
||||
}); // overlapping -> slide, one voice
|
||||
e.render(&p);
|
||||
assert_eq!(e.active_voices(), 1);
|
||||
}
|
||||
}
|
||||
+366
-126
@@ -1,149 +1,389 @@
|
||||
//! 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::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::io::{self, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::mpsc::Receiver;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
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};
|
||||
use crate::patch;
|
||||
use crate::udp::Control;
|
||||
|
||||
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. Left/right neighbours are params: digit 0-9 constant, a-z a",
|
||||
"register (MIDI CC a=0, or UDP), @ velocity, ^ key tracking, ? per-note random.",
|
||||
"A base-36 glyph ABOVE a column's top operator tags it with a MIDI channel",
|
||||
"(0-9 a-f = ch 1-16); untagged columns play every channel. Each ! sums into",
|
||||
"the master; operators BELOW a ! form the master chain (stereo, serial).",
|
||||
"",
|
||||
"SOURCES O sine A saw U pulse V triangle N noise X FM (mod <- LEFT",
|
||||
" cell + above, index <- right) left = pitch ratio (O/A) / width (U)",
|
||||
"FILTERS L lowpass H highpass B bandpass left = cutoff, right = resonance",
|
||||
"SHAPERS S soft clip (left=drive) C clip (left) F fold (left) D decimate (left)",
|
||||
"AMP/MOD E envelope (left=attack, right=release) G gain R ring mod (ratio)",
|
||||
"CONTROL K decay envelope (left=decay, right=floor); LEFT of an operator it drives",
|
||||
" that operator's left param: 2KL5 = filter sweep. tag> = slide (legato channel)",
|
||||
"ROUTING M mix — adds the cell to its LEFT ! output to master",
|
||||
"MASTER Z delay (left=time, right=feedback) W chorus (left=rate, right=depth)",
|
||||
" + any filter/shaper, placed below a !",
|
||||
"UDP k7 reg k=7/35 · *3 bang column 3 · @34O write O at col 3,row 4 · #name load",
|
||||
"",
|
||||
"KEYS arrows move · type a glyph · Backspace clears · Ctrl+S save patch",
|
||||
" ( ) width -/+ _ + height -/+ Ctrl+arrows resize by one",
|
||||
" ? toggle this help · Esc quit",
|
||||
];
|
||||
|
||||
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();
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Cs {
|
||||
Fg(Color),
|
||||
Cursor,
|
||||
}
|
||||
|
||||
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();
|
||||
fn glyph_color(c: char) -> Color {
|
||||
match c {
|
||||
'O' | 'A' | 'U' | 'V' | 'N' | 'X' => Color::Cyan,
|
||||
'L' | 'H' | 'B' => Color::Blue,
|
||||
'S' | 'C' | 'F' | 'D' => Color::Yellow,
|
||||
'E' | 'G' | 'R' | 'M' | 'Z' | 'W' | 'K' => Color::Green,
|
||||
'!' => Color::Magenta,
|
||||
'0'..='9' => Color::Grey,
|
||||
'a'..='z' => Color::DarkYellow,
|
||||
'@' | '^' | '?' => Color::Red,
|
||||
_ => Color::White,
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything the editor needs besides the grid itself.
|
||||
pub struct Session {
|
||||
pub prog: Arc<ArcSwap<Program>>,
|
||||
pub voices: Arc<AtomicU32>,
|
||||
pub connected: Arc<AtomicBool>,
|
||||
pub latency: u32,
|
||||
pub ctrl: Receiver<Control>,
|
||||
pub patches: PathBuf,
|
||||
pub file: Option<PathBuf>,
|
||||
pub udp_port: Option<u16>,
|
||||
}
|
||||
|
||||
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(mut s: Session, mut grid: Grid) -> 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();
|
||||
let mut note: Option<(String, Instant)> = None;
|
||||
let set_note =
|
||||
|note: &mut Option<(String, Instant)>, msg: String| *note = Some((msg, Instant::now()));
|
||||
|
||||
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 res = (|| -> io::Result<()> {
|
||||
loop {
|
||||
if note
|
||||
.as_ref()
|
||||
.is_some_and(|(_, t)| t.elapsed() > Duration::from_secs(4))
|
||||
{
|
||||
note = None;
|
||||
}
|
||||
let status = status_line(
|
||||
&s,
|
||||
&grid,
|
||||
s.voices.load(Ordering::Relaxed),
|
||||
note.as_ref().map(|(m, _)| m.as_str()),
|
||||
);
|
||||
draw(&mut out, &grid, cx, cy, &status, help, &mut r)?;
|
||||
|
||||
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;
|
||||
// Edits arriving over UDP (orca patching the grid, patch loads).
|
||||
let mut remote = false;
|
||||
while let Ok(c) = s.ctrl.try_recv() {
|
||||
match c {
|
||||
Control::Set { col, row, glyph } => {
|
||||
if col < grid.w && row < grid.h {
|
||||
grid.set(col, row, if glyph == ' ' { '.' } else { glyph });
|
||||
remote = true;
|
||||
}
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
grid.set(cx, cy, if c == ' ' { '.' } else { c });
|
||||
changed = true;
|
||||
Control::Load(name) => {
|
||||
let path = patch::named(&s.patches, &name);
|
||||
match patch::load(&path) {
|
||||
Ok(g) => {
|
||||
grid = g;
|
||||
s.file = Some(path);
|
||||
remote = true;
|
||||
set_note(&mut note, format!("loaded {name}"));
|
||||
}
|
||||
Err(e) => set_note(&mut note, format!("load {name}: {e}")),
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if changed {
|
||||
prog.store(Arc::new(compile(&grid)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if remote {
|
||||
cx = cx.min(grid.w - 1);
|
||||
cy = cy.min(grid.h - 1);
|
||||
s.prog.store(Arc::new(compile(&grid)));
|
||||
r.init = false; // size may have changed: repaint fully
|
||||
}
|
||||
|
||||
ratatui::restore();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ui(f: &mut Frame, grid: &Grid, cx: usize, cy: usize, mags: &[f32], voices: u32, latency: u32) {
|
||||
let root = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(f.area());
|
||||
let cols = Layout::horizontal([Constraint::Length((W + 3) as u16), Constraint::Min(10)]).split(root[0]);
|
||||
|
||||
// Grid pane.
|
||||
let mut lines = Vec::with_capacity(H);
|
||||
for row in 0..H {
|
||||
let mut spans = Vec::with_capacity(W);
|
||||
for col in 0..W {
|
||||
let ch = grid.get(col, row);
|
||||
let mut st = match ch {
|
||||
'~' | '^' | '_' | 'v' | '*' => Style::default().fg(Color::Cyan),
|
||||
'f' | 's' => Style::default().fg(Color::Yellow),
|
||||
'e' => Style::default().fg(Color::Green),
|
||||
'!' => Style::default().fg(Color::Magenta),
|
||||
'.' => Style::default().fg(Color::DarkGray),
|
||||
_ => Style::default().fg(Color::White),
|
||||
if !event::poll(Duration::from_millis(33))? {
|
||||
continue;
|
||||
}
|
||||
let Event::Key(k) = event::read()? else {
|
||||
continue;
|
||||
};
|
||||
if col == cx && row == cy {
|
||||
st = st.add_modifier(Modifier::REVERSED);
|
||||
if k.kind != KeyEventKind::Press {
|
||||
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 { ' ' });
|
||||
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') if ctrl => return Ok(()),
|
||||
KeyCode::Char('s') if ctrl => {
|
||||
// No file given: today's patch next to the orca files.
|
||||
let path = s
|
||||
.file
|
||||
.clone()
|
||||
.unwrap_or_else(|| patch::dated(&s.patches, &patch::today()));
|
||||
match patch::save(&path, &grid) {
|
||||
Ok(()) => {
|
||||
set_note(&mut note, format!("saved {}", path.display()));
|
||||
s.file = Some(path);
|
||||
}
|
||||
Err(e) => set_note(&mut note, format!("save failed: {e}")),
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if resized {
|
||||
cx = cx.min(grid.w - 1);
|
||||
cy = cy.min(grid.h - 1);
|
||||
}
|
||||
if resized || edited {
|
||||
s.prog.store(Arc::new(compile(&grid)));
|
||||
}
|
||||
}
|
||||
}
|
||||
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],
|
||||
);
|
||||
execute!(out, Show, LeaveAlternateScreen)?;
|
||||
disable_raw_mode()?;
|
||||
res
|
||||
}
|
||||
|
||||
fn status_line(s: &Session, grid: &Grid, voices: u32, note: Option<&str>) -> String {
|
||||
let file = s
|
||||
.file
|
||||
.as_ref()
|
||||
.and_then(|p| p.file_name())
|
||||
.map(|f| f.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "(unsaved)".into());
|
||||
let udp = s
|
||||
.udp_port
|
||||
.map(|p| format!(" udp {p}"))
|
||||
.unwrap_or_default();
|
||||
if !s.connected.load(Ordering::Relaxed) {
|
||||
return format!(
|
||||
"PIPEWIRE DOWN — reconnecting… {}x{} {file}",
|
||||
grid.w, grid.h
|
||||
);
|
||||
}
|
||||
match note {
|
||||
Some(n) => format!("{n} · voices {voices} {}x{} {file}", grid.w, grid.h),
|
||||
None => format!(
|
||||
"voices {voices} {}x{} latency {}f{udp} {file} ? help Esc quit",
|
||||
grid.w, grid.h, s.latency
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn draw(
|
||||
out: &mut io::Stdout,
|
||||
grid: &Grid,
|
||||
cx: usize,
|
||||
cy: usize,
|
||||
status: &str,
|
||||
help: bool,
|
||||
r: &mut Renderer,
|
||||
) -> io::Result<()> {
|
||||
let (tw, th) = terminal::size()?;
|
||||
|
||||
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(84) / 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
|
||||
};
|
||||
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;
|
||||
}
|
||||
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))?;
|
||||
}
|
||||
}
|
||||
|
||||
if status != r.status {
|
||||
r.status = status.to_string();
|
||||
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()
|
||||
}
|
||||
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
//! UDP control from orca (`;` sends a bare string to a port). One datagram
|
||||
//! carries one or more commands separated by whitespace or `;`:
|
||||
//!
|
||||
//! * `k7` set register k (a-z) to base-36 7 of 35 -> audio thread
|
||||
//! * `*3` bang column 3 (base-36) -> audio thread
|
||||
//! * `@34O` write glyph `O` at column 3, row 4 (base-36 each) -> editor
|
||||
//! * `@34.` clear that cell
|
||||
//! * `#verse` load the patch file `verse` from the patches directory -> editor
|
||||
//!
|
||||
//! Register and bang commands go straight into the lock-free event ring the
|
||||
//! MIDI callback uses; editor commands go over a channel to the TUI thread,
|
||||
//! which owns the grid and recompiles.
|
||||
|
||||
use std::net::UdpSocket;
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::thread;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use rtrb::Producer;
|
||||
|
||||
use crate::dsp::b36;
|
||||
use crate::engine::Event;
|
||||
|
||||
/// Commands that change the grid; handled by the editor thread.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Control {
|
||||
Set { col: usize, row: usize, glyph: char },
|
||||
Load(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Cmd {
|
||||
Audio(Event),
|
||||
Edit(Control),
|
||||
}
|
||||
|
||||
pub fn parse(text: &str) -> Vec<Cmd> {
|
||||
let mut out = Vec::new();
|
||||
for tok in text.split(|c: char| c.is_whitespace() || c == ';') {
|
||||
let mut it = tok.chars();
|
||||
let Some(head) = it.next() else { continue };
|
||||
let rest: String = it.collect();
|
||||
match head {
|
||||
'a'..='z' => {
|
||||
if let Some(v) = rest.chars().next().and_then(b36) {
|
||||
let idx = head as u8 - b'a';
|
||||
out.push(Cmd::Audio(Event::Reg {
|
||||
idx,
|
||||
val: v as f32 / 35.0,
|
||||
}));
|
||||
}
|
||||
}
|
||||
'*' => {
|
||||
if let Some(col) = rest.chars().next().and_then(b36) {
|
||||
out.push(Cmd::Audio(Event::Bang { col }));
|
||||
}
|
||||
}
|
||||
'@' => {
|
||||
let mut r = rest.chars();
|
||||
let (Some(c), Some(w), Some(g)) = (r.next(), r.next(), r.next()) else {
|
||||
continue;
|
||||
};
|
||||
if let (Some(col), Some(row)) = (b36(c), b36(w)) {
|
||||
out.push(Cmd::Edit(Control::Set {
|
||||
col: col as usize,
|
||||
row: row as usize,
|
||||
glyph: g,
|
||||
}));
|
||||
}
|
||||
}
|
||||
'#' => {
|
||||
let name: String = rest
|
||||
.chars()
|
||||
.filter(|c| c.is_alphanumeric() || matches!(c, '-' | '_'))
|
||||
.collect();
|
||||
if !name.is_empty() {
|
||||
out.push(Cmd::Edit(Control::Load(name)));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Bind the socket and serve it from a background thread for the program's
|
||||
/// lifetime.
|
||||
pub fn spawn(port: u16, mut audio: Producer<Event>, editor: Sender<Control>) -> Result<()> {
|
||||
let sock =
|
||||
UdpSocket::bind(("0.0.0.0", port)).with_context(|| format!("bind UDP port {port}"))?;
|
||||
thread::Builder::new()
|
||||
.name("galdr-udp".into())
|
||||
.spawn(move || {
|
||||
let mut buf = [0u8; 2048];
|
||||
while let Ok((n, _)) = sock.recv_from(&mut buf) {
|
||||
let text = String::from_utf8_lossy(&buf[..n]);
|
||||
for cmd in parse(&text) {
|
||||
match cmd {
|
||||
Cmd::Audio(ev) => {
|
||||
let _ = audio.push(ev);
|
||||
}
|
||||
Cmd::Edit(c) => {
|
||||
if editor.send(c).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.context("spawn UDP thread")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_register_set() {
|
||||
assert_eq!(
|
||||
parse("k7"),
|
||||
vec![Cmd::Audio(Event::Reg {
|
||||
idx: 10,
|
||||
val: 7.0 / 35.0
|
||||
})]
|
||||
);
|
||||
assert_eq!(
|
||||
parse("az"),
|
||||
vec![Cmd::Audio(Event::Reg { idx: 0, val: 1.0 })]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_bang_and_edit_and_load() {
|
||||
assert_eq!(parse("*3"), vec![Cmd::Audio(Event::Bang { col: 3 })]);
|
||||
assert_eq!(
|
||||
parse("@34O"),
|
||||
vec![Cmd::Edit(Control::Set {
|
||||
col: 3,
|
||||
row: 4,
|
||||
glyph: 'O'
|
||||
})]
|
||||
);
|
||||
assert_eq!(
|
||||
parse("@a0."),
|
||||
vec![Cmd::Edit(Control::Set {
|
||||
col: 10,
|
||||
row: 0,
|
||||
glyph: '.'
|
||||
})]
|
||||
);
|
||||
assert_eq!(
|
||||
parse("#verse"),
|
||||
vec![Cmd::Edit(Control::Load("verse".into()))]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_commands_per_datagram() {
|
||||
let cmds = parse("k7 *2;#drop\n");
|
||||
assert_eq!(cmds.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_is_ignored() {
|
||||
assert!(parse("").is_empty());
|
||||
assert!(parse("K7 @3 *").is_empty());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user