diff --git a/Cargo.lock b/Cargo.lock index 0569924..2cb929a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" @@ -363,11 +324,10 @@ version = "0.1.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" diff --git a/Cargo.toml b/Cargo.toml index a4143a4..d26540e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,6 @@ codegen-units = 1 anyhow = "1" pipewire = "0.10" midir = "0.10" -rustfft = "6.2" arc-swap = "1.7" rtrb = "0.3" -ratatui = "0.29" +crossterm = "0.29" diff --git a/README.md b/README.md index d9fd44e..5e0b6e5 100644 --- a/README.md +++ b/README.md @@ -14,20 +14,26 @@ orca-rs ──MIDI/OSC/UDP──▶ galdr ──PipeWire──▶ speakers ## The grid Signal flows **down**: each cell takes the sample from the cell above as its -input. The left and right neighbour glyphs are parameters — a digit `0`–`9` is -a constant, a letter `a`–`z` reads MIDI CC (`a` = CC 0). +input. Glyphs are **UPPERCASE** operators. A digit `0`–`9` to the left/right is +a constant parameter; a lowercase letter `a`–`z` reads MIDI CC (`a` = CC 0). -| glyph | unit generator | -|---|---| -| `~` | sine (ratio ← left) | -| `^` | saw (ratio ← left) | -| `_` | pulse (width ← left) | -| `v` | triangle | -| `*` | noise | -| `f` | lowpass (cutoff ← left) | -| `e` | envelope, attack/release ← left/right, gated by the note | -| `s` | shaper / drive ← left | -| `!` | output to master | +| glyph | unit generator | left / right param | +|---|---|---| +| `O` | sine | ratio | +| `A` | saw | ratio | +| `U` | pulse | width | +| `V` | triangle | — | +| `N` | noise | — | +| `L` `H` `B` | low / high / band-pass (SVF) | cutoff / resonance | +| `E` | envelope (gated by the note) | attack / release | +| `S` | soft clip (tanh) | drive | +| `C` | hard clip | threshold | +| `F` | wavefolder | amount | +| `D` | decimate / bitcrush | rate | +| `G` | gain | amount | +| `R` | ring modulator | ratio | +| `M` | mix — adds the cell to its **left** | — | +| `!` | output to master | — | Each `!` sums into the master, so parallel columns stack timbres or voices. A note plays every column at once. @@ -35,14 +41,15 @@ A note plays every column at once. Example — a filtered saw with an envelope: ``` -^ saw at the note pitch -f lowpass, cutoff from the digit to its left... -e ...enveloped by the note gate +A saw at the note pitch +L lowpass, cutoff from the digit to its left... +E ...enveloped by the note gate ! ...to master ``` Editing the grid recompiles and hot-swaps the running program without dropping -audio. +audio. There is no embedded scope — run a spectrum analyzer (e.g. `spectrust`) +on the side if you want one. ## Usage diff --git a/src/audio.rs b/src/audio.rs index 6d38a71..8a1d45a 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -1,6 +1,6 @@ //! Low-latency PipeWire playback. The RT process callback drains input events, -//! renders the voice mixer, fills the output buffer, and taps the master into a -//! scope ring for the TUI. No allocation or locks on this path. +//! renders the voice mixer, and fills the output buffer. No allocation or locks +//! on this path. use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::mpsc::Sender; @@ -11,7 +11,7 @@ use arc_swap::ArcSwap; use pipewire as pw; use pw::spa; use pw::{properties::properties, spa::pod::Pod}; -use rtrb::{Consumer, Producer}; +use rtrb::Consumer; use spa::param::audio::{AudioFormat, AudioInfoRaw}; use crate::dsp::Program; @@ -23,7 +23,6 @@ pub type Shared = Arc>; pub fn spawn( prog: Shared, events: Consumer, - scope: Producer, voices: Arc, sr: u32, channels: u8, @@ -35,7 +34,7 @@ pub fn spawn( .name("galdr-audio".into()) .spawn(move || { if let Err(e) = - run(prog, events, scope, voices, sr, channels, latency, poly, ready_tx.clone()) + run(prog, events, voices, sr, channels, latency, poly, ready_tx.clone()) { let _ = ready_tx.send(Err(e)); } @@ -48,7 +47,6 @@ pub fn spawn( fn run( prog: Shared, mut events: Consumer, - mut scope: Producer, voices: Arc, sr: u32, channels: u8, @@ -92,7 +90,6 @@ fn run( n_frames = slice.len() / stride; for f in 0..n_frames { let smp = engine.render(&program); - let _ = scope.push(smp); let bytes = smp.to_le_bytes(); for c in 0..ch { let o = f * stride + c * 4; diff --git a/src/dsp.rs b/src/dsp.rs index fa74c4b..acbfa9a 100644 --- a/src/dsp.rs +++ b/src/dsp.rs @@ -4,11 +4,16 @@ //! row-ordered list of nodes (topological by construction: signal flows //! downward, so a cell's input - the cell above - is always evaluated first). //! The audio thread never sees the grid, only the compiled `Program`. +//! +//! Glyph convention: UPPERCASE letters are operators, digits 0-9 are constant +//! params, lowercase a-z read MIDI CC (a = CC 0). A neighbour to the left/right +//! supplies a parameter; `M` instead reads the left cell as a second signal. use crate::grid::{Grid, H, SLOTS, W}; pub const MAX_STATE: usize = 4 * SLOTS; pub const TAU: f32 = std::f32::consts::TAU; +const PI: f32 = std::f32::consts::PI; #[derive(Clone, Copy)] pub enum Op { @@ -17,13 +22,20 @@ pub enum Op { Pulse, Tri, Noise, - Lpf, + Low, + High, + Band, Env, - Shape, + Soft, + Clip, + Fold, + Decimate, + Gain, + Ring, + Mix, Out, } -/// A parameter read from a horizontal neighbour glyph. #[derive(Clone, Copy)] pub enum Param { Const(f32), @@ -47,6 +59,7 @@ pub struct Node { pub op: Op, pub out: usize, pub input: Option, + pub lsig: Option, pub left: Param, pub right: Param, pub st: usize, @@ -57,17 +70,25 @@ pub struct Program { pub has_env: bool, } +/// (operator, persistent state slots per voice) fn op_of(c: char) -> Option<(Op, usize)> { - // (operator, number of persistent state slots it needs per voice) Some(match c { - '~' => (Op::Sine, 1), - '^' => (Op::Saw, 1), - '_' => (Op::Pulse, 1), - 'v' => (Op::Tri, 1), - '*' => (Op::Noise, 0), - 'f' => (Op::Lpf, 2), - 'e' => (Op::Env, 2), - 's' => (Op::Shape, 0), + 'O' => (Op::Sine, 1), + 'A' => (Op::Saw, 1), + 'U' => (Op::Pulse, 1), + 'V' => (Op::Tri, 1), + 'N' => (Op::Noise, 0), + 'L' => (Op::Low, 2), + 'H' => (Op::High, 2), + 'B' => (Op::Band, 2), + 'E' => (Op::Env, 1), + 'S' => (Op::Soft, 0), + 'C' => (Op::Clip, 0), + 'F' => (Op::Fold, 0), + 'D' => (Op::Decimate, 2), + 'G' => (Op::Gain, 0), + 'R' => (Op::Ring, 1), + 'M' => (Op::Mix, 0), '!' => (Op::Out, 0), _ => return None, }) @@ -95,22 +116,19 @@ pub fn compile(grid: &Grid) -> Program { if matches!(op, Op::Env) { has_env = true; } - let input = if row > 0 && occupied(grid.get(col, row - 1)) { - Some((row - 1) * W + col) - } else { - None - }; + let input = + if row > 0 && occupied(grid.get(col, row - 1)) { Some((row - 1) * W + col) } else { None }; + let lsig = if col > 0 { Some(row * W + col - 1) } else { None }; let left = if col > 0 { param_of(grid.get(col - 1, row)) } else { Param::None }; let right = if col + 1 < W { param_of(grid.get(col + 1, row)) } else { Param::None }; let this_st = st; st = (st + ssize).min(MAX_STATE - 4); - nodes.push(Node { op, out: row * W + col, input, left, right, st: this_st }); + nodes.push(Node { op, out: row * W + col, input, lsig, left, right, st: this_st }); } } Program { nodes, has_env } } -/// Per-voice mutable state consumed by the evaluator. pub struct Voice { pub active: bool, pub note: u8, @@ -141,12 +159,41 @@ impl Voice { #[inline] fn cutoff_hz(v: f32) -> f32 { - // 0..=9 or a 0..1 CC value -> ~40 Hz .. ~16 kHz, exponential. let n = if v > 1.0 { v / 9.0 } else { v }; 40.0 * 400f32.powf(n.clamp(0.0, 1.0)) } -/// Evaluate one output sample for one voice. Returns the summed `!` outputs. +#[inline] +fn phasor(ph: &mut f32, inc: f32) -> f32 { + let p = *ph; + *ph += inc; + if *ph >= 1.0 { + *ph -= 1.0; + } + p +} + +/// TPT state-variable filter step. Returns (low, band, high). +#[inline] +fn svf(inp: f32, fc: f32, k: f32, sr: f32, ic1: &mut f32, ic2: &mut f32) -> (f32, f32, f32) { + let g = (PI * fc / sr).tan(); + let a1 = 1.0 / (1.0 + g * (g + k)); + let a2 = g * a1; + let a3 = g * a2; + let v3 = inp - *ic2; + let v1 = a1 * *ic1 + a2 * v3; + let v2 = *ic2 + a2 * *ic1 + a3 * v3; + *ic1 = 2.0 * v1 - *ic1; + *ic2 = 2.0 * v2 - *ic2; + (v2, v1, inp - k * v1 - v2) +} + +#[inline] +fn res_k(right: Option) -> f32 { + // higher right glyph -> more resonance (lower k = higher Q) + (1.5 - right.unwrap_or(3.0) * 0.15).clamp(0.15, 1.5) +} + #[inline] pub fn eval_voice(v: &mut Voice, p: &Program, regs: &[f32], sr: f32) -> f32 { let mut s = [0f32; SLOTS]; @@ -159,76 +206,85 @@ pub fn eval_voice(v: &mut Voice, p: &Program, regs: &[f32], sr: f32) -> f32 { let y = match n.op { Op::Sine => { let ratio = l.filter(|&x| x > 0.0).unwrap_or(1.0); - let ph = &mut v.state[n.st]; - let y = (*ph * TAU).sin(); - *ph += v.freq * ratio / sr; - if *ph >= 1.0 { - *ph -= 1.0; - } - y + (phasor(&mut v.state[n.st], v.freq * ratio / sr) * TAU).sin() } Op::Saw => { let ratio = l.filter(|&x| x > 0.0).unwrap_or(1.0); - let ph = &mut v.state[n.st]; - let y = 2.0 * *ph - 1.0; - *ph += v.freq * ratio / sr; - if *ph >= 1.0 { - *ph -= 1.0; - } - y + 2.0 * phasor(&mut v.state[n.st], v.freq * ratio / sr) - 1.0 } Op::Pulse => { - let width = l.map(|x| (x / 9.0).clamp(0.05, 0.95)).unwrap_or(0.5); - let ph = &mut v.state[n.st]; - let y = if *ph < width { 1.0 } else { -1.0 }; - *ph += v.freq / sr; - if *ph >= 1.0 { - *ph -= 1.0; - } - y - } - Op::Tri => { - let ph = &mut v.state[n.st]; - let y = 4.0 * (*ph - 0.5).abs() - 1.0; - *ph += v.freq / sr; - if *ph >= 1.0 { - *ph -= 1.0; - } - y + let w = l.map(|x| (x / 9.0).clamp(0.05, 0.95)).unwrap_or(0.5); + if phasor(&mut v.state[n.st], v.freq / sr) < w { 1.0 } else { -1.0 } } + Op::Tri => 4.0 * (phasor(&mut v.state[n.st], v.freq / sr) - 0.5).abs() - 1.0, Op::Noise => { - // xorshift32 v.rng ^= v.rng << 13; v.rng ^= v.rng >> 17; v.rng ^= v.rng << 5; (v.rng as f32 / u32::MAX as f32) * 2.0 - 1.0 } - Op::Lpf => { + Op::Low | Op::High | Op::Band => { let fc = cutoff_hz(l.unwrap_or(9.0)); - let a = (1.0 - (-TAU * fc / sr).exp()).clamp(0.0, 1.0); - let z = &mut v.state[n.st]; - *z += a * (inp - *z); - let _res = r; // reserved for resonance later - *z + let k = res_k(r); + let (ic1, ic2) = { + let (a, b) = v.state.split_at_mut(n.st + 1); + (&mut a[n.st], &mut b[0]) + }; + let (lo, ba, hi) = svf(inp, fc, k, sr, ic1, ic2); + match n.op { + Op::High => hi, + Op::Band => ba, + _ => lo, + } } Op::Env => { - // Attack/Release, sustain = 1. l = attack, r = release (0..9). let atk = l.map(|x| 0.002 + x * 0.05).unwrap_or(0.01); let rel = r.map(|x| 0.01 + x * 0.15).unwrap_or(0.15); let level = &mut v.state[n.st]; let target = if v.gate > 0.5 { 1.0 } else { 0.0 }; let rate = if v.gate > 0.5 { atk } else { rel }; - let step = (1.0 / (rate * sr)).min(1.0); - *level += (target - *level) * step; + *level += (target - *level) * (1.0 / (rate * sr)).min(1.0); if *level > env_max { env_max = *level; } inp * *level * v.vel } - Op::Shape => { + Op::Soft => { let drive = l.filter(|&x| x > 0.0).unwrap_or(1.0); (inp * drive).tanh() } + Op::Clip => { + let t = l.map(|x| (x / 9.0).clamp(0.05, 1.0)).unwrap_or(0.5); + inp.clamp(-t, t) + } + Op::Fold => { + let amt = 1.0 + l.unwrap_or(1.0); + (inp * amt * PI * 0.5).sin() + } + Op::Decimate => { + let factor = 1.0 + l.unwrap_or(4.0) * 3.0; + let held = &mut v.state[n.st]; + let held_val = *held; + let ph = &mut v.state[n.st + 1]; + *ph += 1.0; + if *ph >= factor { + *ph = 0.0; + v.state[n.st] = inp; + inp + } else { + held_val + } + } + Op::Gain => { + let g = l.map(|x| if x > 1.0 { x / 4.5 } else { x * 2.0 }).unwrap_or(1.0); + inp * g + } + Op::Ring => { + let ratio = l.filter(|&x| x > 0.0).unwrap_or(1.0); + let m = (phasor(&mut v.state[n.st], v.freq * ratio / sr) * TAU).sin(); + inp * m + } + Op::Mix => inp + n.lsig.map(|i| s[i]).unwrap_or(0.0), Op::Out => { out += inp; inp @@ -236,8 +292,6 @@ pub fn eval_voice(v: &mut Voice, p: &Program, regs: &[f32], sr: f32) -> f32 { }; s[n.out] = y; } - // With no envelope, gate directly gates the voice so notes don't sustain - // forever. if !p.has_env { out *= v.gate * v.vel; env_max = v.gate; diff --git a/src/main.rs b/src/main.rs index 48bb83a..523cc01 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,10 +32,14 @@ Options: creating the virtual 'galdr' port -h, --help Print this message -Grid glyphs (signal flows down; left/right neighbours are params 0-9 or CC a-z): - ~ sine ^ saw _ pulse v tri * noise - f lowpass(cutoff<-left) e envelope(atk<-left,rel<-right) s shaper(drive<-left) - ! output to master +Grid glyphs are UPPERCASE operators; digits 0-9 are constants; lowercase a-z +read MIDI CC (a = CC 0). Signal flows down (input = the cell above); the left +and right neighbours are params. + sources O sine A saw U pulse V tri N noise (ratio/width <- left) + filters L lowpass H highpass B bandpass (cutoff <- left, resonance <- right) + shapers S soft/tanh(drive<-left) C clip(<-left) F fold(<-left) D decimate(<-left) + amp/mod E envelope(atk<-left, rel<-right) G gain(<-left) R ringmod(ratio<-left) + routing M mix (adds the cell to its left) ! output to master "; fn main() -> anyhow::Result<()> { @@ -66,22 +70,21 @@ fn main() -> anyhow::Result<()> { // A default patch so the first run makes sound: a plain sine->env voice and // a filtered saw voice. let mut grid = Grid::new(); - grid.set(0, 0, '~'); - grid.set(0, 1, 'e'); - grid.set(0, 2, '!'); - grid.set(2, 0, '^'); - grid.set(1, 1, '5'); - grid.set(2, 1, 'f'); - grid.set(2, 2, 'e'); - grid.set(2, 3, '!'); + grid.set(0, 0, 'O'); // sine + grid.set(0, 1, 'E'); // -> envelope + grid.set(0, 2, '!'); // -> out + grid.set(2, 0, 'A'); // saw + grid.set(1, 1, '5'); // cutoff param for the filter + grid.set(2, 1, 'L'); // -> lowpass + grid.set(2, 2, 'E'); // -> envelope + grid.set(2, 3, '!'); // -> out let prog = Arc::new(ArcSwap::from_pointee(compile(&grid))); let voices = Arc::new(AtomicU32::new(0)); let (prod_ev, cons_ev) = rtrb::RingBuffer::new(1024); - let (prod_sc, cons_sc) = rtrb::RingBuffer::new(16384); - audio::spawn(prog.clone(), cons_ev, prod_sc, voices.clone(), rate, channels, latency, poly) + audio::spawn(prog.clone(), cons_ev, voices.clone(), rate, channels, latency, poly) .map_err(|e| anyhow::anyhow!("audio: {e}"))?; // Keep the MIDI connection alive for the program's lifetime. @@ -93,6 +96,6 @@ fn main() -> anyhow::Result<()> { } }; - tui::run(prog, cons_sc, voices, grid, latency)?; + tui::run(prog, voices, grid, latency)?; Ok(()) } diff --git a/src/tui.rs b/src/tui.rs index f8a935c..9289a33 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1,149 +1,145 @@ -//! 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 (same +//! centered-canvas + per-glyph colour approach); no embedded scope - run a +//! spectrum analyzer (e.g. spectrust) on the side if you want one. +use std::io::{self, Write}; use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::Arc; +use std::time::Duration; use arc_swap::ArcSwap; -use ratatui::crossterm::event::{self, Event as CEvent, KeyCode, KeyEventKind, KeyModifiers}; -use ratatui::prelude::*; -use ratatui::widgets::{Block, Borders, Paragraph}; -use rtrb::Consumer; -use rustfft::{num_complex::Complex, FftPlanner}; +use crossterm::cursor::{Hide, MoveTo, Show}; +use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; +use crossterm::style::{Attribute, Color, Print, SetAttribute, SetForegroundColor}; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, BeginSynchronizedUpdate, Clear, ClearType, + EndSynchronizedUpdate, EnterAlternateScreen, LeaveAlternateScreen, +}; +use crossterm::{execute, queue, terminal}; use crate::dsp::{compile, Program}; use crate::grid::{Grid, H, W}; -const FFT: usize = 1024; +// Foreground colour by glyph category. (orca-rs uses one Normal colour; galdr +// tints by role so a patch reads at a glance.) +fn glyph_color(c: char) -> Color { + match c { + 'O' | 'A' | 'U' | 'V' | 'N' => Color::Cyan, // sources + 'L' | 'H' | 'B' => Color::Blue, // filters + 'S' | 'C' | 'F' | 'D' => Color::Yellow, // shapers + 'E' | 'G' | 'R' | 'M' => Color::Green, // amp / mod + '!' => Color::Magenta, // output + '0'..='9' => Color::Grey, // constants + 'a'..='z' => Color::DarkYellow, // CC registers + '.' => Color::DarkGrey, // empty + _ => Color::White, + } +} 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(); +) -> io::Result<()> { + enable_raw_mode()?; + let mut out = io::stdout(); + execute!(out, EnterAlternateScreen, Hide)?; let (mut cx, mut cy) = (0usize, 0usize); + let res = main_loop(&mut out, &prog, &voices, &mut grid, latency, &mut cx, &mut cy); + + execute!(out, Show, LeaveAlternateScreen)?; + disable_raw_mode()?; + res +} + +fn main_loop( + out: &mut io::Stdout, + prog: &Arc>, + voices: &Arc, + grid: &mut Grid, + latency: u32, + cx: &mut usize, + cy: &mut usize, +) -> io::Result<()> { 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(); + draw(out, grid, *cx, *cy, voices.load(Ordering::Relaxed), latency)?; - 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 event::poll(Duration::from_millis(33))? { + if let Event::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; + return Ok(()); } 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::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, '.'); + grid.set(*cx, *cy, '.'); changed = true; } KeyCode::Char(c) => { - grid.set(cx, cy, if c == ' ' { '.' } else { c }); + grid.set(*cx, *cy, if c == ' ' { '.' } else { c }); changed = true; + *cx = (*cx + 1).min(W - 1); } _ => {} } if changed { - prog.store(Arc::new(compile(&grid))); + 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]); +fn draw( + out: &mut io::Stdout, + grid: &Grid, + cx: usize, + cy: usize, + voices: u32, + latency: u32, +) -> io::Result<()> { + let (tw, th) = terminal::size()?; + let off_x = (tw as usize).saturating_sub(W) / 2; + let off_y = (th as usize).saturating_sub(H + 1) / 2; - // Grid pane. - let mut lines = Vec::with_capacity(H); + queue!(out, BeginSynchronizedUpdate, Clear(ClearType::All))?; for row in 0..H { - let mut spans = Vec::with_capacity(W); + queue!(out, MoveTo(off_x as u16, (off_y + row) as u16))?; for col in 0..W { - let ch = grid.get(col, row); - let mut st = match ch { - '~' | '^' | '_' | 'v' | '*' => Style::default().fg(Color::Cyan), - 'f' | 's' => Style::default().fg(Color::Yellow), - 'e' => Style::default().fg(Color::Green), - '!' => Style::default().fg(Color::Magenta), - '.' => Style::default().fg(Color::DarkGray), - _ => Style::default().fg(Color::White), - }; + let c = grid.get(col, row); + queue!(out, SetAttribute(Attribute::Reset))?; 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 { ' ' }); + queue!(out, SetAttribute(Attribute::Reverse))?; + } else { + queue!(out, SetForegroundColor(glyph_color(c)))?; } + queue!(out, Print(c))?; } } - 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. + // Status line beneath the grid. 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], + "voices {voices} latency {latency}f O~ A^ U pulse V tri N noise | L H B filt | S C F D shape | E env G gain R ring M mix | ! out Esc quit" ); + let s: String = status.chars().take(tw as usize).collect(); + queue!( + out, + MoveTo(0, th.saturating_sub(1)), + SetAttribute(Attribute::Reset), + SetForegroundColor(Color::DarkGrey), + Print(s) + )?; + queue!(out, EndSynchronizedUpdate)?; + out.flush() }