From d2778e169ab5d600b53ffff44bd49165480c5e87 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Sun, 13 Sep 2026 13:24:18 +0200 Subject: [PATCH] galdr 0.1.0: terminal grid synthesizer (MIDI in, PipeWire out) Signal flows downward through single-glyph unit generators; each MIDI note voices the whole grid; the mix renders low-latency to PipeWire (reusing the native path from spectrust). Grid edits recompile and hot-swap without dropping audio. Pairs with orca (note/CC source) and spectrust (visualizer). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01GLUwWE2KmFPzhKaf67tWbx --- .gitea/workflows/release.yml | 174 +++++ .gitignore | 1 + Cargo.lock | 1358 ++++++++++++++++++++++++++++++++++ Cargo.toml | 19 + LICENSE | 21 + README.md | 58 ++ aur/PKGBUILD | 21 + release.toml | 12 + src/audio.rs | 144 ++++ src/dsp.rs | 247 +++++++ src/engine.rs | 93 +++ src/grid.rs | 31 + src/main.rs | 98 +++ src/midi.rs | 41 + src/tui.rs | 149 ++++ 15 files changed, 2467 insertions(+) create mode 100644 .gitea/workflows/release.yml create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 aur/PKGBUILD create mode 100644 release.toml create mode 100644 src/audio.rs create mode 100644 src/dsp.rs create mode 100644 src/engine.rs create mode 100644 src/grid.rs create mode 100644 src/main.rs create mode 100644 src/midi.rs create mode 100644 src/tui.rs diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..e30b68c --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,174 @@ +name: Release + +on: + push: + tags: + - 'v*' + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + strategy: + matrix: + include: + - arch: x86_64 + target: x86_64-unknown-linux-gnu + runs-on: ubuntu-latest + sccache_dir: /sccache + - arch: aarch64 + target: aarch64-unknown-linux-gnu + runs-on: aarch64 + sccache_dir: /var/lib/gitea-runner/sccache + + runs-on: ${{ matrix.runs-on }} + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + # Compiled-crate cache via sccache. x86_64 uses /sccache (a persistent + # host dir bind-mounted into the job container by the runner config); + # aarch64 runs on the klokka host and uses a runner-owned dir. + - name: Set up sccache + run: | + echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV" + echo "SCCACHE_DIR=${{ matrix.sccache_dir }}" >> "$GITHUB_ENV" + mkdir -p "${{ matrix.sccache_dir }}" + if ! command -v sccache >/dev/null 2>&1; then + V=0.8.2 + curl -sSL "https://github.com/mozilla/sccache/releases/download/v${V}/sccache-v${V}-x86_64-unknown-linux-musl.tar.gz" | tar -xz + sudo install -m0755 "sccache-v${V}-x86_64-unknown-linux-musl/sccache" /usr/local/bin/sccache + fi + sccache --version + + - name: Ensure build deps (libpipewire for output, alsa for MIDI, clang for bindgen) + run: | + if command -v apt-get >/dev/null; then + # Ephemeral container runner: safe to install into it. + SUDO=""; command -v sudo >/dev/null && SUDO=sudo + $SUDO apt-get update + $SUDO apt-get install -y libpipewire-0.3-dev libspa-0.2-dev libasound2-dev clang libclang-dev pkg-config + else + # Bare/host runner: never mutate it - require deps preinstalled. + missing="" + pkg-config --exists libpipewire-0.3 2>/dev/null || missing="$missing libpipewire(+dev headers)" + pkg-config --exists alsa 2>/dev/null || missing="$missing alsa-lib(+dev headers)" + command -v clang >/dev/null || missing="$missing clang" + if [ -n "$missing" ]; then + echo "::error::bare runner missing build deps:$missing — install once on the host; CI does not modify bare runners" + exit 1 + fi + echo "bare runner already has libpipewire + alsa + clang" + fi + + - name: Build + run: cargo build --release --locked --target ${{ matrix.target }} + + - name: Package + run: | + TAG=${{ gitea.ref_name }} + TARBALL="galdr-${TAG}-${{ matrix.arch }}.tar.gz" + mkdir pkg + cp target/${{ matrix.target }}/release/galdr pkg/ + cp LICENSE pkg/ + tar -czf "${TARBALL}" -C pkg . + sha256sum "${TARBALL}" > "${TARBALL}.sha256" + echo "TARBALL=${TARBALL}" >> $GITHUB_ENV + + - name: Create release + run: | + curl -sX POST \ + -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ + -H "Content-Type: application/json" \ + "${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases" \ + -d "{\"tag_name\":\"${{ gitea.ref_name }}\",\"name\":\"${{ gitea.ref_name }}\"}" \ + --fail-with-body || true + + - name: Upload assets + run: | + RELEASE_ID=$(curl -s \ + -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ + "${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/tags/${{ gitea.ref_name }}" \ + | jq -r '.id') + + for FILE in "${{ env.TARBALL }}" "${{ env.TARBALL }}.sha256"; do + EXISTING=$(curl -s \ + -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ + "${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/${RELEASE_ID}/assets" \ + | jq -r ".[] | select(.name == \"${FILE}\") | .id") + for AID in $EXISTING; do + curl -sX DELETE \ + -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ + "${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/${RELEASE_ID}/assets/${AID}" + done + + curl -sX POST \ + -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ + -H "Content-Type: application/octet-stream" \ + "${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/${RELEASE_ID}/assets?name=${FILE}" \ + --data-binary "@${FILE}" --fail-with-body + done + + update-aur: + needs: build + runs-on: aarch64 + + steps: + - uses: actions/checkout@v4 + + - name: Compute checksums and update PKGBUILD + run: | + TAG=${{ gitea.ref_name }} + BASE="${{ gitea.server_url }}/${{ gitea.repository }}/releases/download/${TAG}" + SUM_X86=$(curl -sL "${BASE}/galdr-${TAG}-x86_64.tar.gz" | sha256sum | cut -d' ' -f1) + SUM_AARCH=$(curl -sL "${BASE}/galdr-${TAG}-aarch64.tar.gz" | sha256sum | cut -d' ' -f1) + sed -i "s/sha256sums_x86_64=('.*')/sha256sums_x86_64=('${SUM_X86}')/" aur/PKGBUILD + sed -i "s/sha256sums_aarch64=('.*')/sha256sums_aarch64=('${SUM_AARCH}')/" aur/PKGBUILD + + # Best-effort mirror to the instance Arch registry via a write:package + # REGISTRY_TOKEN secret; skipped + non-fatal when unset. + - name: Publish to the Arch package registry + continue-on-error: true + run: | + set -euo pipefail + if [ -z "${{ secrets.REGISTRY_TOKEN }}" ]; then + echo "::warning::REGISTRY_TOKEN not set — skipping Arch registry publish" + exit 0 + fi + cd aur + for carch in aarch64 x86_64; do + pkgfile=$(CARCH="$carch" makepkg --packagelist | tail -1) + CARCH="$carch" makepkg -f --nodeps --noconfirm --skipinteg + curl --fail-with-body --user "bl:${{ secrets.REGISTRY_TOKEN }}" \ + --upload-file "$pkgfile" \ + "${{ gitea.server_url }}/api/packages/${{ gitea.repository_owner }}/arch/uhhm" + done + + - name: Push to AUR + env: + AUR_SSH_KEY: ${{ secrets.AUR_SSH_KEY }} + run: | + install -dm700 ~/.ssh + echo "$AUR_SSH_KEY" > ~/.ssh/aur + chmod 600 ~/.ssh/aur + echo "Host aur.archlinux.org" >> ~/.ssh/config + echo " IdentityFile ~/.ssh/aur" >> ~/.ssh/config + echo " User aur" >> ~/.ssh/config + ssh-keyscan aur.archlinux.org >> ~/.ssh/known_hosts 2>/dev/null + + rm -rf /tmp/aur-galdr + git clone ssh://aur@aur.archlinux.org/galdr.git /tmp/aur-galdr + cp aur/PKGBUILD /tmp/aur-galdr/ + cd /tmp/aur-galdr + makepkg --printsrcinfo > .SRCINFO + git config user.name "Bendik Aagaard Lynghaug" + git config user.email "bendik.lynghaug@gmail.com" + git add PKGBUILD .SRCINFO + git commit -m "Update to ${{ gitea.ref_name }}" + git push origin master diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..0569924 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1358 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +dependencies = [ + "alsa-sys", + "bitflags 2.13.2", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "annotate-snippets" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710e8eae58854cdc1790fcb56cca04d712a17be849eeb81da2a724bf4bae2bc4" +dependencies = [ + "anstyle", + "unicode-width 0.2.0", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "annotate-snippets", + "bitflags 2.13.2", + "cexpr", + "clang-sys", + "itertools", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 2.0.119", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "bumpalo" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" +dependencies = [ + "find-msvc-tools", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "cfg-expr" +version = "0.20.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe4ece8474b5f766c63426647e7b4b316b67431ade1036a8313cee24a03ae917" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "cookie-factory" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "coremidi" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a57ede822fdaf19280cf1320a5a5d3a522c75c910d01750af1e8122b6ad2595b" +dependencies = [ + "block2", + "core-foundation", + "core-foundation-sys", + "coremidi-sys", +] + +[[package]] +name = "coremidi-sys" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2b8c2cefa9a8f712213c5a1383ffe428efc8f1a1fd1e2f757be94daf7e256a" +dependencies = [ + "core-foundation-sys", +] + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.13.2", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.5", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "galdr" +version = "0.1.0" +dependencies = [ + "anyhow", + "arc-swap", + "midir", + "pipewire", + "ratatui", + "rtrb", + "rustfft", +] + +[[package]] +name = "glob" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +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" +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", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libspa" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882f7427e7989dcc9d388b7f05c4630390a1d7696f9ffa469cd4a7a48f0b4c40" +dependencies = [ + "bitflags 2.13.2", + "cc", + "cookie-factory", + "libc", + "libspa-sys", + "nom 8.0.0", + "rustix 1.1.4", + "system-deps", +] + +[[package]] +name = "libspa-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b6e17bdaf63ed0d5e4144022624032b41fd9733112e8c74ac26fc9bf1291924" +dependencies = [ + "bindgen", + "cc", + "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 = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "midir" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56542e359bb7e4bd1a77cb79042be32d4af0713a9ce58160355eaf72df9db87c" +dependencies = [ + "alsa", + "bitflags 1.3.2", + "coremidi", + "js-sys", + "libc", + "parking_lot", + "wasm-bindgen", + "web-sys", + "windows", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pipewire" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde71084c4e25959d68f1ea54daa75e5ecdb338e5caf0b5510143b79baa32d5c" +dependencies = [ + "bitflags 2.13.2", + "libc", + "libspa", + "libspa-sys", + "pipewire-sys", + "rustix 1.1.4", +] + +[[package]] +name = "pipewire-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce653f53e63e5b93853218092ee9a8906a5d082c92f3f1db26316955dd63ce0" +dependencies = [ + "bindgen", + "libspa-sys", + "system-deps", +] + +[[package]] +name = "pkg-config" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rtrb" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fae8ee26b0371a29a77d2b2d6b3ae13aa81def6f9bf1b1b92a32d279a5e709b7" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustfft" +version = "6.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" +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", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +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 = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "system-deps" +version = "7.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de69df01bdf1ead2f4ac895dc77c9351aefff65b2f3db429a343f9cbf05e132" +dependencies = [ + "windows-core", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4698e52ed2d08f8658ab0c39512a7c00ee5fe2688c65f8c0a4f06750d729f2a6" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result", + "windows-targets", +] + +[[package]] +name = "windows-implement" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..a4143a4 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "galdr" +version = "0.1.0" +edition = "2021" +description = "A terminal grid synthesizer: signal flows spatially through single-glyph unit generators, voiced by MIDI, rendered low-latency to PipeWire" +license = "MIT" + +[profile.release] +lto = true +codegen-units = 1 + +[dependencies] +anyhow = "1" +pipewire = "0.10" +midir = "0.10" +rustfft = "6.2" +arc-swap = "1.7" +rtrb = "0.3" +ratatui = "0.29" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..add8a78 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Bendik Aagaard Lynghaug + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d9fd44e --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# galdr + +A terminal **grid synthesizer**. Signal flows downward through a field of +single-glyph unit generators; every incoming MIDI note voices the whole grid; +the mix is rendered low-latency to PipeWire. It is orca's voice — the same +esoteric, spatial approach to programming, but for making sound instead of +sending events. + +``` +orca-rs ──MIDI/OSC/UDP──▶ galdr ──PipeWire──▶ speakers + compose the pattern voice it (spectrust -p visualizes it) +``` + +## The grid + +Signal flows **down**: each cell takes the sample from the cell above as its +input. The left and right neighbour glyphs are parameters — a digit `0`–`9` is +a constant, a letter `a`–`z` reads MIDI CC (`a` = CC 0). + +| glyph | unit generator | +|---|---| +| `~` | sine (ratio ← left) | +| `^` | saw (ratio ← left) | +| `_` | pulse (width ← left) | +| `v` | triangle | +| `*` | noise | +| `f` | lowpass (cutoff ← left) | +| `e` | envelope, attack/release ← left/right, gated by the note | +| `s` | shaper / drive ← left | +| `!` | output to master | + +Each `!` sums into the master, so parallel columns stack timbres or voices. +A note plays every column at once. + +Example — a filtered saw with an envelope: + +``` +^ saw at the note pitch +f lowpass, cutoff from the digit to its left... +e ...enveloped by the note gate +! ...to master +``` + +Editing the grid recompiles and hot-swaps the running program without dropping +audio. + +## Usage + +``` +galdr [--latency ] [--poly ] [--rate ] [--midi-name ] +``` + +galdr creates a virtual MIDI input port named **galdr**; connect orca or any +sequencer/DAW to it. `--midi-name` connects to an existing port instead. +Cursor keys move; type a glyph to place it; Backspace clears; Esc quits. + +Runtime deps: PipeWire and ALSA (MIDI). Built in Rust; reuses the native +PipeWire path from spectrust. diff --git a/aur/PKGBUILD b/aur/PKGBUILD new file mode 100644 index 0000000..6018483 --- /dev/null +++ b/aur/PKGBUILD @@ -0,0 +1,21 @@ +# Maintainer: Bendik Aagaard Lynghaug +pkgname=galdr +pkgver=0.1.0 +pkgrel=1 +pkgdesc="Terminal grid synthesizer: signal flows through single-glyph unit generators, MIDI in, low-latency PipeWire out" +arch=('x86_64' 'aarch64') +options=('!strip') +url="https://project.uhhm.no/bl/galdr" +license=('MIT') +depends=('libpipewire' 'alsa-lib') +provides=('galdr') +conflicts=('galdr-git' 'galdr-bin') +source_x86_64=("galdr-v${pkgver}-x86_64.tar.gz::https://project.uhhm.no/bl/galdr/releases/download/v${pkgver}/galdr-v${pkgver}-x86_64.tar.gz") +source_aarch64=("galdr-v${pkgver}-aarch64.tar.gz::https://project.uhhm.no/bl/galdr/releases/download/v${pkgver}/galdr-v${pkgver}-aarch64.tar.gz") +sha256sums_x86_64=('SKIP') +sha256sums_aarch64=('SKIP') + +package() { + install -Dm755 galdr "$pkgdir/usr/bin/galdr" + install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" +} diff --git a/release.toml b/release.toml new file mode 100644 index 0000000..1c30fae --- /dev/null +++ b/release.toml @@ -0,0 +1,12 @@ +publish = false +allow-branch = ["main"] + +[[pre-release-replacements]] +file = "aur/PKGBUILD" +search = "pkgver=.*" +replace = "pkgver={{version}}" + +[[pre-release-replacements]] +file = "aur/PKGBUILD" +search = "pkgrel=.*" +replace = "pkgrel=1" diff --git a/src/audio.rs b/src/audio.rs new file mode 100644 index 0000000..6d38a71 --- /dev/null +++ b/src/audio.rs @@ -0,0 +1,144 @@ +//! Low-latency PipeWire playback. The RT process callback drains input events, +//! renders the voice mixer, fills the output buffer, and taps the master into a +//! scope ring for the TUI. No allocation or locks on this path. + +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::mpsc::Sender; +use std::sync::Arc; +use std::thread; + +use arc_swap::ArcSwap; +use pipewire as pw; +use pw::spa; +use pw::{properties::properties, spa::pod::Pod}; +use rtrb::{Consumer, Producer}; +use spa::param::audio::{AudioFormat, AudioInfoRaw}; + +use crate::dsp::Program; +use crate::engine::{Engine, Event}; + +pub type Shared = Arc>; + +#[allow(clippy::too_many_arguments)] +pub fn spawn( + prog: Shared, + events: Consumer, + scope: Producer, + voices: Arc, + sr: u32, + channels: u8, + latency: u32, + poly: usize, +) -> Result<(), String> { + let (ready_tx, ready_rx) = std::sync::mpsc::channel::>(); + thread::Builder::new() + .name("galdr-audio".into()) + .spawn(move || { + if let Err(e) = + run(prog, events, scope, voices, sr, channels, latency, poly, ready_tx.clone()) + { + let _ = ready_tx.send(Err(e)); + } + }) + .map_err(|e| format!("spawn audio thread: {e}"))?; + ready_rx.recv().map_err(|_| "audio thread exited before start".to_string())? +} + +#[allow(clippy::too_many_arguments)] +fn run( + prog: Shared, + mut events: Consumer, + mut scope: Producer, + voices: Arc, + sr: u32, + channels: u8, + latency: u32, + poly: usize, + ready: Sender>, +) -> Result<(), String> { + pw::init(); + let mainloop = pw::main_loop::MainLoopRc::new(None).map_err(|e| format!("mainloop: {e}"))?; + let context = pw::context::ContextRc::new(&mainloop, None).map_err(|e| format!("context: {e}"))?; + let core = context.connect_rc(None).map_err(|e| format!("connect PipeWire: {e}"))?; + + let props = properties! { + *pw::keys::MEDIA_TYPE => "Audio", + *pw::keys::MEDIA_CATEGORY => "Playback", + *pw::keys::MEDIA_ROLE => "Music", + *pw::keys::NODE_NAME => "galdr", + *pw::keys::NODE_LATENCY => format!("{latency}/{sr}"), + }; + let stream = pw::stream::StreamBox::new(&core, "galdr", props).map_err(|e| format!("stream: {e}"))?; + + let ch = channels.max(1) as usize; + let mut engine = Engine::new(poly, sr as f32); + + let _listener = stream + .add_local_listener_with_user_data(()) + .process(move |stream, _| { + let Some(mut buffer) = stream.dequeue_buffer() else { return }; + let datas = buffer.datas_mut(); + let Some(data) = datas.first_mut() else { return }; + let stride = ch * 4; + let program = prog.load(); + + // Drain all pending input events once per buffer. + while let Ok(ev) = events.pop() { + engine.apply(ev); + } + + let mut n_frames = 0usize; + if let Some(slice) = data.data() { + n_frames = slice.len() / stride; + for f in 0..n_frames { + let smp = engine.render(&program); + let _ = scope.push(smp); + let bytes = smp.to_le_bytes(); + for c in 0..ch { + let o = f * stride + c * 4; + slice[o..o + 4].copy_from_slice(&bytes); + } + } + } + let chunk = data.chunk_mut(); + *chunk.offset_mut() = 0; + *chunk.stride_mut() = stride as _; + *chunk.size_mut() = (stride * n_frames) as _; + voices.store(engine.active_voices() as u32, Ordering::Relaxed); + }) + .register() + .map_err(|e| format!("listener: {e}"))?; + + let mut audio_info = AudioInfoRaw::new(); + audio_info.set_format(AudioFormat::F32LE); + audio_info.set_rate(sr); + audio_info.set_channels(channels as u32); + let obj = spa::pod::Object { + type_: spa::utils::SpaTypes::ObjectParamFormat.as_raw(), + id: spa::param::ParamType::EnumFormat.as_raw(), + properties: audio_info.into(), + }; + let values: Vec = spa::pod::serialize::PodSerializer::serialize( + std::io::Cursor::new(Vec::new()), + &spa::pod::Value::Object(obj), + ) + .map_err(|e| format!("pod: {e}"))? + .0 + .into_inner(); + let mut params = [Pod::from_bytes(&values).ok_or("bad pod")?]; + + stream + .connect( + spa::utils::Direction::Output, + None, + pw::stream::StreamFlags::AUTOCONNECT + | pw::stream::StreamFlags::MAP_BUFFERS + | pw::stream::StreamFlags::RT_PROCESS, + &mut params, + ) + .map_err(|e| format!("connect stream: {e}"))?; + + let _ = ready.send(Ok(())); + mainloop.run(); + Ok(()) +} diff --git a/src/dsp.rs b/src/dsp.rs new file mode 100644 index 0000000..fa74c4b --- /dev/null +++ b/src/dsp.rs @@ -0,0 +1,247 @@ +//! Grid -> compiled signal program, and the per-sample voice evaluator. +//! +//! The grid is the editor surface only. On every edit it compiles to a flat, +//! row-ordered list of nodes (topological by construction: signal flows +//! downward, so a cell's input - the cell above - is always evaluated first). +//! The audio thread never sees the grid, only the compiled `Program`. + +use crate::grid::{Grid, H, SLOTS, W}; + +pub const MAX_STATE: usize = 4 * SLOTS; +pub const TAU: f32 = std::f32::consts::TAU; + +#[derive(Clone, Copy)] +pub enum Op { + Sine, + Saw, + Pulse, + Tri, + Noise, + Lpf, + Env, + Shape, + Out, +} + +/// A parameter read from a horizontal neighbour glyph. +#[derive(Clone, Copy)] +pub enum Param { + Const(f32), + Reg(usize), + None, +} + +impl Param { + #[inline] + fn get(self, regs: &[f32]) -> Option { + match self { + Param::Const(c) => Some(c), + Param::Reg(i) => Some(regs[i]), + Param::None => None, + } + } +} + +#[derive(Clone, Copy)] +pub struct Node { + pub op: Op, + pub out: usize, + pub input: Option, + pub left: Param, + pub right: Param, + pub st: usize, +} + +pub struct Program { + pub nodes: Vec, + pub has_env: bool, +} + +fn op_of(c: char) -> Option<(Op, usize)> { + // (operator, number of persistent state slots it needs per voice) + Some(match c { + '~' => (Op::Sine, 1), + '^' => (Op::Saw, 1), + '_' => (Op::Pulse, 1), + 'v' => (Op::Tri, 1), + '*' => (Op::Noise, 0), + 'f' => (Op::Lpf, 2), + 'e' => (Op::Env, 2), + 's' => (Op::Shape, 0), + '!' => (Op::Out, 0), + _ => return None, + }) +} + +fn param_of(c: char) -> Param { + match c { + '0'..='9' => Param::Const((c as u8 - b'0') as f32), + 'a'..='z' => Param::Reg((c as u8 - b'a') as usize), + _ => Param::None, + } +} + +fn occupied(c: char) -> bool { + !matches!(c, ' ' | '.' | '\0') +} + +pub fn compile(grid: &Grid) -> Program { + let mut nodes = Vec::new(); + let mut st = 0usize; + let mut has_env = false; + for row in 0..H { + for col in 0..W { + let Some((op, ssize)) = op_of(grid.get(col, row)) else { continue }; + if matches!(op, Op::Env) { + has_env = true; + } + let input = if row > 0 && occupied(grid.get(col, row - 1)) { + Some((row - 1) * W + col) + } else { + None + }; + let left = if col > 0 { param_of(grid.get(col - 1, row)) } else { Param::None }; + let right = if col + 1 < W { param_of(grid.get(col + 1, row)) } else { Param::None }; + let this_st = st; + st = (st + ssize).min(MAX_STATE - 4); + nodes.push(Node { op, out: row * W + col, input, left, right, st: this_st }); + } + } + Program { nodes, has_env } +} + +/// Per-voice mutable state consumed by the evaluator. +pub struct Voice { + pub active: bool, + pub note: u8, + pub freq: f32, + pub gate: f32, + pub vel: f32, + pub env_level: f32, + pub age: u64, + pub rng: u32, + pub state: Vec, +} + +impl Voice { + pub fn new() -> Self { + Voice { + active: false, + note: 0, + freq: 0.0, + gate: 0.0, + vel: 0.0, + env_level: 0.0, + age: 0, + rng: 0x1234_5678, + state: vec![0.0; MAX_STATE], + } + } +} + +#[inline] +fn cutoff_hz(v: f32) -> f32 { + // 0..=9 or a 0..1 CC value -> ~40 Hz .. ~16 kHz, exponential. + let n = if v > 1.0 { v / 9.0 } else { v }; + 40.0 * 400f32.powf(n.clamp(0.0, 1.0)) +} + +/// Evaluate one output sample for one voice. Returns the summed `!` outputs. +#[inline] +pub fn eval_voice(v: &mut Voice, p: &Program, regs: &[f32], sr: f32) -> f32 { + let mut s = [0f32; SLOTS]; + let mut out = 0.0f32; + let mut env_max = 0.0f32; + for n in &p.nodes { + let inp = n.input.map(|i| s[i]).unwrap_or(0.0); + let l = n.left.get(regs); + let r = n.right.get(regs); + let y = match n.op { + Op::Sine => { + let ratio = l.filter(|&x| x > 0.0).unwrap_or(1.0); + let ph = &mut v.state[n.st]; + let y = (*ph * TAU).sin(); + *ph += v.freq * ratio / sr; + if *ph >= 1.0 { + *ph -= 1.0; + } + y + } + Op::Saw => { + let ratio = l.filter(|&x| x > 0.0).unwrap_or(1.0); + let ph = &mut v.state[n.st]; + let y = 2.0 * *ph - 1.0; + *ph += v.freq * ratio / sr; + if *ph >= 1.0 { + *ph -= 1.0; + } + y + } + Op::Pulse => { + let width = l.map(|x| (x / 9.0).clamp(0.05, 0.95)).unwrap_or(0.5); + let ph = &mut v.state[n.st]; + let y = if *ph < width { 1.0 } else { -1.0 }; + *ph += v.freq / sr; + if *ph >= 1.0 { + *ph -= 1.0; + } + y + } + Op::Tri => { + let ph = &mut v.state[n.st]; + let y = 4.0 * (*ph - 0.5).abs() - 1.0; + *ph += v.freq / sr; + if *ph >= 1.0 { + *ph -= 1.0; + } + y + } + Op::Noise => { + // xorshift32 + v.rng ^= v.rng << 13; + v.rng ^= v.rng >> 17; + v.rng ^= v.rng << 5; + (v.rng as f32 / u32::MAX as f32) * 2.0 - 1.0 + } + Op::Lpf => { + let fc = cutoff_hz(l.unwrap_or(9.0)); + let a = (1.0 - (-TAU * fc / sr).exp()).clamp(0.0, 1.0); + let z = &mut v.state[n.st]; + *z += a * (inp - *z); + let _res = r; // reserved for resonance later + *z + } + Op::Env => { + // Attack/Release, sustain = 1. l = attack, r = release (0..9). + let atk = l.map(|x| 0.002 + x * 0.05).unwrap_or(0.01); + let rel = r.map(|x| 0.01 + x * 0.15).unwrap_or(0.15); + let level = &mut v.state[n.st]; + let target = if v.gate > 0.5 { 1.0 } else { 0.0 }; + let rate = if v.gate > 0.5 { atk } else { rel }; + let step = (1.0 / (rate * sr)).min(1.0); + *level += (target - *level) * step; + if *level > env_max { + env_max = *level; + } + inp * *level * v.vel + } + Op::Shape => { + let drive = l.filter(|&x| x > 0.0).unwrap_or(1.0); + (inp * drive).tanh() + } + Op::Out => { + out += inp; + inp + } + }; + s[n.out] = y; + } + // With no envelope, gate directly gates the voice so notes don't sustain + // forever. + if !p.has_env { + out *= v.gate * v.vel; + env_max = v.gate; + } + v.env_level = env_max; + out +} diff --git a/src/engine.rs b/src/engine.rs new file mode 100644 index 0000000..04923e8 --- /dev/null +++ b/src/engine.rs @@ -0,0 +1,93 @@ +//! Voice allocation and the polyphonic mixer. Owned by the audio thread. + +use crate::dsp::{eval_voice, Program, Voice}; + +#[derive(Clone, Copy)] +pub enum Event { + NoteOn { note: u8, vel: u8 }, + NoteOff { note: u8 }, + Cc { idx: u8, val: u8 }, +} + +pub struct Engine { + voices: Vec, + regs: Vec, + sr: f32, + clock: u64, +} + +impl Engine { + pub fn new(poly: usize, sr: f32) -> Self { + Engine { + voices: (0..poly).map(|_| Voice::new()).collect(), + regs: vec![0.0; 128], + sr, + clock: 0, + } + } + + pub fn active_voices(&self) -> usize { + self.voices.iter().filter(|v| v.active).count() + } + + pub fn apply(&mut self, ev: Event) { + match ev { + Event::NoteOn { note, vel } => { + self.clock += 1; + // Prefer a free voice, else steal the oldest. + let idx = self + .voices + .iter() + .position(|v| !v.active) + .unwrap_or_else(|| { + let mut best = 0; + for i in 1..self.voices.len() { + if self.voices[i].age < self.voices[best].age { + best = i; + } + } + best + }); + let v = &mut self.voices[idx]; + v.active = true; + v.note = note; + v.freq = 440.0 * 2f32.powf((note as f32 - 69.0) / 12.0); + v.gate = 1.0; + v.vel = vel as f32 / 127.0; + v.age = self.clock; + // fresh phases/env so a stolen voice doesn't click from old state + for x in v.state.iter_mut() { + *x = 0.0; + } + v.env_level = 0.0; + } + Event::NoteOff { note } => { + for v in self.voices.iter_mut() { + if v.active && v.note == note { + v.gate = 0.0; + } + } + } + Event::Cc { idx, val } => { + self.regs[idx as usize] = val as f32 / 127.0; + } + } + } + + #[inline] + pub fn render(&mut self, prog: &Program) -> f32 { + let mut mix = 0.0f32; + for v in self.voices.iter_mut() { + if !v.active { + continue; + } + mix += eval_voice(v, prog, &self.regs, self.sr); + // Reap voices whose gate is closed and envelope has decayed. + if v.gate < 0.5 && v.env_level < 1e-4 { + v.active = false; + } + } + // Gentle headroom limiter. + (mix * 0.3).tanh() + } +} diff --git a/src/grid.rs b/src/grid.rs new file mode 100644 index 0000000..2c3fc1c --- /dev/null +++ b/src/grid.rs @@ -0,0 +1,31 @@ +//! The fixed-size character grid that is the editor surface. + +pub const W: usize = 24; +pub const H: usize = 16; +pub const SLOTS: usize = W * H; + +#[derive(Clone)] +pub struct Grid { + cells: Vec, +} + +impl Grid { + pub fn new() -> Self { + Grid { cells: vec!['.'; SLOTS] } + } + + #[inline] + pub fn get(&self, col: usize, row: usize) -> char { + if col < W && row < H { + self.cells[row * W + col] + } else { + '.' + } + } + + pub fn set(&mut self, col: usize, row: usize, c: char) { + if col < W && row < H { + self.cells[row * W + col] = c; + } + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..48bb83a --- /dev/null +++ b/src/main.rs @@ -0,0 +1,98 @@ +//! galdr - a terminal grid synthesizer. +//! +//! Signal flows downward through single-glyph unit generators; each MIDI note +//! voices the whole grid; the mix is rendered low-latency to PipeWire. Pairs +//! with orca (MIDI/OSC/UDP out) and spectrust (visualize the output). + +mod audio; +mod dsp; +mod engine; +mod grid; +mod midi; +mod tui; + +use std::sync::atomic::AtomicU32; +use std::sync::Arc; + +use arc_swap::ArcSwap; + +use crate::dsp::compile; +use crate::grid::Grid; + +const HELP: &str = "\ +galdr - terminal grid synthesizer + +Usage: galdr [options] + +Options: + --latency PipeWire quantum; lower = lower latency (default: 256) + --poly Number of voices (default: 8) + --rate Sample rate (default: 48000) + --midi-name Connect to the input port matching instead of + creating the virtual 'galdr' port + -h, --help Print this message + +Grid glyphs (signal flows down; left/right neighbours are params 0-9 or CC a-z): + ~ sine ^ saw _ pulse v tri * noise + f lowpass(cutoff<-left) e envelope(atk<-left,rel<-right) s shaper(drive<-left) + ! output to master +"; + +fn main() -> anyhow::Result<()> { + let mut latency: u32 = 256; + let mut poly: usize = 8; + let mut rate: u32 = 48000; + let channels: u8 = 2; + let mut midi_name: Option = None; + + let mut args = std::env::args().skip(1); + while let Some(a) = args.next() { + match a.as_str() { + "--latency" => latency = args.next().and_then(|v| v.parse().ok()).unwrap_or(latency), + "--poly" => poly = args.next().and_then(|v| v.parse().ok()).unwrap_or(poly), + "--rate" => rate = args.next().and_then(|v| v.parse().ok()).unwrap_or(rate), + "--midi-name" => midi_name = args.next(), + "-h" | "--help" => { + print!("{HELP}"); + return Ok(()); + } + other => { + eprintln!("unknown option: {other}\n\n{HELP}"); + std::process::exit(2); + } + } + } + + // A default patch so the first run makes sound: a plain sine->env voice and + // a filtered saw voice. + let mut grid = Grid::new(); + grid.set(0, 0, '~'); + grid.set(0, 1, 'e'); + grid.set(0, 2, '!'); + grid.set(2, 0, '^'); + grid.set(1, 1, '5'); + grid.set(2, 1, 'f'); + grid.set(2, 2, 'e'); + grid.set(2, 3, '!'); + + let prog = Arc::new(ArcSwap::from_pointee(compile(&grid))); + let voices = Arc::new(AtomicU32::new(0)); + + let (prod_ev, cons_ev) = rtrb::RingBuffer::new(1024); + let (prod_sc, cons_sc) = rtrb::RingBuffer::new(16384); + + audio::spawn(prog.clone(), cons_ev, prod_sc, voices.clone(), rate, channels, latency, poly) + .map_err(|e| anyhow::anyhow!("audio: {e}"))?; + + // Keep the MIDI connection alive for the program's lifetime. + let _midi = match midi::connect(prod_ev, midi_name) { + Ok(conn) => Some(conn), + Err(e) => { + eprintln!("warning: MIDI unavailable ({e}); running without note input"); + None + } + }; + + tui::run(prog, cons_sc, voices, grid, latency)?; + Ok(()) +} diff --git a/src/midi.rs b/src/midi.rs new file mode 100644 index 0000000..ec20dd1 --- /dev/null +++ b/src/midi.rs @@ -0,0 +1,41 @@ +//! MIDI input. galdr owns a virtual input port named "galdr" (symmetric with +//! orca's virtual output), so orca or any client connects to it in the graph. + +use anyhow::{anyhow, Result}; +use midir::os::unix::VirtualInput; +use midir::{MidiInput, MidiInputConnection}; +use rtrb::Producer; + +use crate::engine::Event; + +pub fn connect(prod: Producer, port_name: Option) -> Result>> { + 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| { + if msg.len() < 2 { + return; + } + let status = msg[0] & 0xF0; + let ev = match status { + 0x90 if msg[2] > 0 => Event::NoteOn { note: msg[1], vel: msg[2] }, + 0x80 => Event::NoteOff { note: msg[1] }, + 0x90 => Event::NoteOff { note: msg[1] }, // note-on velocity 0 + 0xB0 => Event::Cc { idx: msg[1], val: msg[2] }, + _ => return, + }; + let _ = prod.push(ev); + }; + + if let Some(name) = port_name { + let ports = input.ports(); + let port = ports + .iter() + .find(|p| input.port_name(p).map(|n| n.contains(&name)).unwrap_or(false)) + .cloned() + .ok_or_else(|| anyhow!("no MIDI input port matching {name:?}"))?; + input.connect(&port, "galdr-in", cb, prod).map_err(|e| anyhow!("MIDI connect: {e}")) + } else { + input.create_virtual("galdr", cb, prod).map_err(|e| anyhow!("create virtual MIDI port: {e}")) + } +} diff --git a/src/tui.rs b/src/tui.rs new file mode 100644 index 0000000..f8a935c --- /dev/null +++ b/src/tui.rs @@ -0,0 +1,149 @@ +//! Terminal editor: the grid on the left, a live spectrum of the master on the +//! right, a status line below. Editing recompiles and hot-swaps the program. + +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Arc; + +use arc_swap::ArcSwap; +use ratatui::crossterm::event::{self, Event as CEvent, KeyCode, KeyEventKind, KeyModifiers}; +use ratatui::prelude::*; +use ratatui::widgets::{Block, Borders, Paragraph}; +use rtrb::Consumer; +use rustfft::{num_complex::Complex, FftPlanner}; + +use crate::dsp::{compile, Program}; +use crate::grid::{Grid, H, W}; + +const FFT: usize = 1024; + +pub fn run( + prog: Arc>, + mut scope: Consumer, + voices: Arc, + mut grid: Grid, + latency: u32, +) -> std::io::Result<()> { + let mut terminal = ratatui::init(); + + let mut samples = vec![0.0f32; FFT]; + let mut planner = FftPlanner::new(); + let fft = planner.plan_fft_forward(FFT); + let hann: Vec = (0..FFT) + .map(|i| 0.5 * (1.0 - (std::f32::consts::TAU * i as f32 / FFT as f32).cos())) + .collect(); + let (mut cx, mut cy) = (0usize, 0usize); + + loop { + // Pull whatever the audio thread produced into the rolling window. + while let Ok(s) = scope.pop() { + samples.rotate_left(1); + samples[FFT - 1] = s; + } + let mut buf: Vec> = + samples.iter().zip(&hann).map(|(s, w)| Complex { re: s * w, im: 0.0 }).collect(); + fft.process(&mut buf); + let mags: Vec = buf[..FFT / 2].iter().map(|c| c.norm()).collect(); + + let nv = voices.load(Ordering::Relaxed); + terminal.draw(|f| ui(f, &grid, cx, cy, &mags, nv, latency))?; + + if event::poll(std::time::Duration::from_millis(33))? { + if let CEvent::Key(k) = event::read()? { + if k.kind != KeyEventKind::Press { + continue; + } + if k.code == KeyCode::Esc + || (k.code == KeyCode::Char('c') && k.modifiers.contains(KeyModifiers::CONTROL)) + { + break; + } + let mut changed = false; + match k.code { + KeyCode::Left => cx = cx.saturating_sub(1), + KeyCode::Right => cx = (cx + 1).min(W - 1), + KeyCode::Up => cy = cy.saturating_sub(1), + KeyCode::Down => cy = (cy + 1).min(H - 1), + KeyCode::Backspace | KeyCode::Delete => { + grid.set(cx, cy, '.'); + changed = true; + } + KeyCode::Char(c) => { + grid.set(cx, cy, if c == ' ' { '.' } else { c }); + changed = true; + } + _ => {} + } + if changed { + prog.store(Arc::new(compile(&grid))); + } + } + } + } + + ratatui::restore(); + Ok(()) +} + +fn ui(f: &mut Frame, grid: &Grid, cx: usize, cy: usize, mags: &[f32], voices: u32, latency: u32) { + let root = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(f.area()); + let cols = Layout::horizontal([Constraint::Length((W + 3) as u16), Constraint::Min(10)]).split(root[0]); + + // Grid pane. + let mut lines = Vec::with_capacity(H); + for row in 0..H { + let mut spans = Vec::with_capacity(W); + for col in 0..W { + let ch = grid.get(col, row); + let mut st = match ch { + '~' | '^' | '_' | 'v' | '*' => Style::default().fg(Color::Cyan), + 'f' | 's' => Style::default().fg(Color::Yellow), + 'e' => Style::default().fg(Color::Green), + '!' => Style::default().fg(Color::Magenta), + '.' => Style::default().fg(Color::DarkGray), + _ => Style::default().fg(Color::White), + }; + if col == cx && row == cy { + st = st.add_modifier(Modifier::REVERSED); + } + spans.push(Span::styled(ch.to_string(), st)); + } + lines.push(Line::from(spans)); + } + f.render_widget( + Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(" galdr ")), + cols[0], + ); + + // Spectrum pane. + let area = cols[1]; + let inner_w = area.width.saturating_sub(2) as usize; + let inner_h = area.height.saturating_sub(2) as usize; + let mut srows = vec![String::new(); inner_h]; + if inner_w > 0 && inner_h > 0 { + let maxm = mags.iter().cloned().fold(1e-6f32, f32::max); + for x in 0..inner_w { + // log-ish bin mapping into the lower spectrum + let frac = (x as f32 / inner_w as f32).powf(1.6); + let bin = ((frac * mags.len() as f32) as usize).min(mags.len() - 1); + let mag = (mags[bin] / maxm).sqrt(); + let filled = (mag * inner_h as f32) as usize; + for y in 0..inner_h { + srows[inner_h - 1 - y].push(if y < filled { '█' } else { ' ' }); + } + } + } + let scope_lines: Vec = 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], + ); +}