From b0d685a1101a329e73045bbecba80dbb388fb0b4 Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Sun, 2 Aug 2026 17:54:50 +0200 Subject: [PATCH] Initial release: real-time voice changer in pure Rust Phase-vocoder pitch/formant shifting, 12 presets, effect chain, noise gate, WAV recording, full-screen TUI (arrow/mouse control) and a built-in PipeWire/PulseAudio virtual mic (vois.rs). GPL-3.0-or-later. --- .gitignore | 1 + Cargo.lock | 1613 ++++++++++++++++++++++++++++++++++ Cargo.toml | 22 + LICENSE | 674 ++++++++++++++ README.md | 213 +++++ examples/latency_probe.rs | 217 +++++ scripts/setup_virtual_mic.sh | 31 + src/audio/capture.rs | 69 ++ src/audio/devices.rs | 18 + src/audio/mod.rs | 191 ++++ src/audio/playback.rs | 75 ++ src/audio/record.rs | 40 + src/audio/virtual_mic.rs | 197 +++++ src/config.rs | 64 ++ src/dsp/chain.rs | 430 +++++++++ src/dsp/effects.rs | 605 +++++++++++++ src/dsp/formant.rs | 12 + src/dsp/gate.rs | 64 ++ src/dsp/mod.rs | 7 + src/dsp/pitch.rs | 416 +++++++++ src/dsp/presets.rs | 298 +++++++ src/dsp/stats.rs | 35 + src/main.rs | 113 +++ src/ui/mod.rs | 12 + src/ui/tui.rs | 826 +++++++++++++++++ 25 files changed, 6243 insertions(+) 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 examples/latency_probe.rs create mode 100644 scripts/setup_virtual_mic.sh create mode 100644 src/audio/capture.rs create mode 100644 src/audio/devices.rs create mode 100644 src/audio/mod.rs create mode 100644 src/audio/playback.rs create mode 100644 src/audio/record.rs create mode 100644 src/audio/virtual_mic.rs create mode 100644 src/config.rs create mode 100644 src/dsp/chain.rs create mode 100644 src/dsp/effects.rs create mode 100644 src/dsp/formant.rs create mode 100644 src/dsp/gate.rs create mode 100644 src/dsp/mod.rs create mode 100644 src/dsp/pitch.rs create mode 100644 src/dsp/presets.rs create mode 100644 src/dsp/stats.rs create mode 100644 src/main.rs create mode 100644 src/ui/mod.rs create mode 100644 src/ui/tui.rs 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..b1abf0d --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1613 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +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.1", + "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 = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[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 = [ + "bitflags 2.13.1", + "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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[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 = "clap" +version = "4.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301b56658598e48f3648647ac6fc887be7e7108eddfa4e9b63fcf3ec58c0cadf" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94a65403d1a1bd28f7dc68eb8506e8874808ee5eecb59298de588e2e1407a078" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[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 = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "coreaudio-rs" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" +dependencies = [ + "bitflags 1.3.2", + "core-foundation-sys", + "coreaudio-sys", +] + +[[package]] +name = "coreaudio-sys" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9b4739a805a62757a83e5654fa3faabec0442666b263bb2287d5a8185bfd953" +dependencies = [ + "bindgen", +] + +[[package]] +name = "cpal" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +dependencies = [ + "alsa", + "core-foundation-sys", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk", + "ndk-context", + "oboe", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.13.1", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix", + "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.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[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.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[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.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[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 = "hound" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +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.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[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 = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +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 = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[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.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[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 = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "thiserror", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys 0.3.1", +] + +[[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 = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +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 = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "oboe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" +dependencies = [ + "jni", + "ndk", + "ndk-context", + "num-derive", + "num-traits", + "oboe-sys", +] + +[[package]] +name = "oboe-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" +dependencies = [ + "cc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[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 = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[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-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[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 = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "ratatui" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d" +dependencies = [ + "bitflags 2.13.1", + "cassowary", + "compact_str", + "crossterm", + "instability", + "itertools", + "lru", + "paste", + "strum", + "strum_macros", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[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.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +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 = "ringbuf" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe47b720588c8702e34b5979cb3271a8b1842c7cb6f57408efa70c779363488c" +dependencies = [ + "crossbeam-utils", + "portable-atomic", + "portable-atomic-util", +] + +[[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.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[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 = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[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.3", +] + +[[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.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[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 = "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", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vois" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "cpal", + "crossterm", + "hound", + "ratatui", + "ringbuf", + "rustfft", + "signal-hook", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[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.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +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-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[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.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result", + "windows-targets 0.52.6", +] + +[[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 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[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" +dependencies = [ + "memchr", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..be162fe --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "vois" +version = "0.1.0" +edition = "2021" +description = "Real-time voice changer written in pure Rust" +license = "GPL-3.0-or-later" + +[dependencies] +anyhow = "1" +clap = { version = "4", features = ["derive"] } +cpal = "0.15" +crossterm = "0.28" +hound = "3" +ratatui = "0.28" +ringbuf = "0.4" +rustfft = "6" +signal-hook = "0.3" + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0def213 --- /dev/null +++ b/README.md @@ -0,0 +1,213 @@ +
+ +# vois.rs + +Real-time voice changer for your mic: pitch/formant shifting, 12 effect presets and a built-in virtual microphone — written in pure Rust. + +![Rust](https://img.shields.io/badge/Rust-1.74+-orange?style=flat-square&logo=rust&logoColor=white) +![Platform](https://img.shields.io/badge/Platform-Linux%20%7C%20macOS%20%7C%20Windows-blue?style=flat-square) +![PipeWire](https://img.shields.io/badge/PipeWire-supported-purple?style=flat-square) +![License](https://img.shields.io/badge/license-GPL--3.0--or--later-red?style=flat-square&logo=gnu&logoColor=white) +![version](https://img.shields.io/badge/version-0.1.0-green?style=flat-square) + +[English](#english) | [Русский](#russian) + +
+ +--- + + + +## English + +### Overview + +vois.rs turns your microphone into a voice changer in real time: shift pitch and formants with a phase-vocoder DSP chain, apply game/voice-change style presets (Robot, 8-bit, Demon, Girl, Alien, Ghost...), and expose the processed voice as a **virtual microphone** that Discord, Zoom, OBS and games can use — no external tools required. + +> **Heads up:** this project was hacked together very quickly on a "vibe" and may contain bugs, rough edges and half-finished bits. Use at your own risk and report issues! + +### Features + +| Feature | Description | +|---------|-------------| +| Real-time pitch shift | Phase-vocoder, −12…+12 semitones, formants preserved | +| Formant shift | Change the timbre (girl / deep / bright) independently of pitch | +| 12 presets | Clean, Girl/Anime, Boy, Manly/Deep, Demon, Robot, 8-bit, Alien, Radio, Megaphone, Ghost, Cyborg | +| Effects | Vocoder, bitcrush, distortion, reverb, chorus, ring-mod, bandpass, noise, compressor, noise gate | +| Virtual microphone | Built-in `vois.rs` mic (PipeWire/PulseAudio) — no VB-CABLE needed on Linux | +| TUI | Full-screen terminal UI with arrow-key + mouse control and live meters | +| WAV recording | Record the processed audio with the `R` hotkey or `--record` | +| Test tone | `--tone 220` lets you hear what a preset does without a mic | +| Cross-platform | Linux (ALSA/PipeWire), macOS (CoreAudio), Windows (WASAPI) | + +### Presets + +| Preset | Pitch | Formant | Effects | +|--------|-------|---------|---------| +| Clean | 0 st | 1.00× | passthrough (A/B compare) | +| Girl / Anime | +5 st | 1.25× | — | +| Boy | +3 st | 1.12× | — | +| Manly / Deep | −4 st | 0.78× | compressor | +| Demon | −9 st | 0.85× | distortion + reverb | +| Robot | 0 st | 1.00× | channel vocoder + bitcrush | +| 8-bit | 0 st | 1.00× | bitcrush (5-bit, decimate ×4, auto-level) | +| Alien | −2 st | 1.15× | ring-mod + chorus | +| Radio | 0 st | 1.00× | bandpass + noise + compressor | +| Megaphone | 0 st | 1.00× | distortion + narrow EQ + compressor | +| Ghost | −3 st | 1.05× | big reverb + chorus | +| Cyborg | +2 st | 1.00× | vocoder + bitcrush | + +### Usage + +```bash +cargo build --release +./target/release/vois # virtual mic is ON by default +./target/release/vois -p "Girl / Anime" # pick a preset +./target/release/vois --no-virtual-mic # just output to your speakers +./target/release/vois --tone 220 # hear presets without a mic +./target/release/vois --list # list audio devices +./target/release/vois --list-presets # list presets +``` + +In Discord / Zoom / OBS select **`vois.rs`** as your microphone. + +### TUI controls + +| Key / Mouse | Action | +|-------------|--------| +| `↑` / `↓` / click | switch preset (live preview) | +| `←` / `→` | change pitch (main) / change setting (settings) | +| `S` / `Tab` | open settings screen | +| `H` | help | +| `Q` / `Ctrl+C` | quit | + +Settings screen: `↑↓` select, `←→` / `Enter` change, `Esc` back — preset, pitch, formant, gate threshold (dB), gain (dB), mute, LIVE/PASSTHROUGH mode, record. + +### Dependencies + +- `cpal` — audio capture/playback (ALSA / WASAPI / CoreAudio) +- `rustfft` — phase-vocoder FFT +- `ratatui` + `crossterm` — terminal UI +- `ringbuf` — lock-free sample buffers +- `hound` — WAV recording +- Linux virtual mic requires `pactl` (PipeWire/PulseAudio) + +### Installation + +1. `cargo build --release` +2. Run `./target/release/vois` +3. Select **`vois.rs`** as your microphone in your voice app + +### Roadmap / ideas + +- Windows/macOS virtual mic (VB-CABLE / BlackHole) as output device +- Latency meter and lower-latency FFT modes +- Pitch auto-correction / karaoke-style smoothing +- More presets and user-defined chains +- Web UI / tray icon +- Playback of a soundboard through the virtual mic + +--- + + + +## Русский + +### Обзор + +vois.rs превращает твой микрофон в войс-ченджер в реальном времени: сдвиг высоты и формант на фазовом вокодере, 12 пресетов в стиле войс-модов (Robot, 8-bit, Demon, Girl, Alien, Ghost...) и **виртуальный микрофон**, который видят Discord, Zoom, OBS и игры — без внешних программ. + +> **Важно:** проект был написан очень быстро, «на вайбе», поэтому могут быть баги, недоделки и шероховатости. Пользуйся на свой страх и риск, баги репорть! + +### Возможности + +| Возможность | Описание | +|-------------|----------| +| Сдвиг высоты | Фазовый вокодер, −12…+12 полутонов, форманты сохраняются | +| Сдвиг формант | Меняет тембр (девушка / глубокий / звонкий) независимо от высоты | +| 12 пресетов | Clean, Girl/Anime, Boy, Manly/Deep, Demon, Robot, 8-bit, Alien, Radio, Megaphone, Ghost, Cyborg | +| Эффекты | Вокодер, биткраш, дисторшн, реверб, хорус, кольцевая модуляция, полосовой фильтр, шум, компрессор, шумоподавитель | +| Виртуальный микрофон | Встроенный `vois.rs` (PipeWire/PulseAudio) — на Linux не нужен VB-CABLE | +| TUI | Полноэкранный интерфейс с управлением стрелками и мышью, живые уровни | +| Запись в WAV | Клавиша `R` или `--record` | +| Тестовый тон | `--tone 220` — послушать пресет без микрофона | +| Кроссплатформенность | Linux (ALSA/PipeWire), macOS (CoreAudio), Windows (WASAPI) | + +### Пресеты + +| Пресет | Высота | Форманты | Эффекты | +|--------|--------|----------|---------| +| Clean | 0 пт | 1.00× | passthrough (сравнение A/B) | +| Girl / Anime | +5 пт | 1.25× | — | +| Boy | +3 пт | 1.12× | — | +| Manly / Deep | −4 пт | 0.78× | компрессор | +| Demon | −9 пт | 0.85× | дисторшн + реверб | +| Robot | 0 пт | 1.00× | вокодер + биткраш | +| 8-bit | 0 пт | 1.00× | биткраш (5 бит, децимация ×4, автолевел) | +| Alien | −2 пт | 1.15× | кольцевая модуляция + хорус | +| Radio | 0 пт | 1.00× | полосовой фильтр + шум + компрессор | +| Megaphone | 0 пт | 1.00× | дисторшн + узкий EQ + компрессор | +| Ghost | −3 пт | 1.05× | большой реверб + хорус | +| Cyborg | +2 пт | 1.00× | вокодер + биткраш | + +### Запуск + +```bash +cargo build --release +./target/release/vois # виртуальный микрофон включён по умолчанию +./target/release/vois -p "Girl / Anime" # выбрать пресет +./target/release/vois --no-virtual-mic # просто вывод в колонки/наушники +./target/release/vois --tone 220 # послушать пресеты без микрофона +./target/release/vois --list # список устройств +./target/release/vois --list-presets # список пресетов +``` + +В Discord / Zoom / OBS выбери микрофон **`vois.rs`**. + +### Управление в TUI + +| Клавиша / мышь | Действие | +|----------------|----------| +| `↑` / `↓` / клик | переключение пресета (живой предпросмотр) | +| `←` / `→` | pitch (главный экран) / изменение параметра (настройки) | +| `S` / `Tab` | экран настроек | +| `H` | справка | +| `Q` / `Ctrl+C` | выход | + +Экран настроек: `↑↓` выбор, `←→` / `Enter` изменение, `Esc` назад — пресет, pitch, formant, порог шумоподавителя (дБ), усиление (дБ), mute, режим LIVE/PASSTHROUGH, запись. + +### Зависимости + +- `cpal` — захват/вывод звука (ALSA / WASAPI / CoreAudio) +- `rustfft` — FFT для фазового вокодера +- `ratatui` + `crossterm` — терминальный интерфейс +- `ringbuf` — lock-free буферы сэмплов +- `hound` — запись WAV +- Виртуальный микрофон на Linux требует `pactl` (PipeWire/PulseAudio) + +### Установка + +1. `cargo build --release` +2. Запусти `./target/release/vois` +3. В голосовом приложении выбери микрофон **`vois.rs`** + +### Планы / идеи + +- Виртуальный микрофон на Windows/macOS (VB-CABLE / BlackHole) как устройство вывода +- Замер задержки и режимы FFT с меньшей латентностью +- Автокоррекция высоты / сглаживание в стиле караоке +- Больше пресетов и пользовательские цепочки эффектов +- Web-UI / трей-иконка +- Звуковая плата (soundboard) через виртуальный микрофон + +--- + +### Links + +- [Releases](../../releases) +- [Issues](../../issues) +- [License](LICENSE) + +### License + +GNU General Public License v3.0 (or later) diff --git a/examples/latency_probe.rs b/examples/latency_probe.rs new file mode 100644 index 0000000..147af5b --- /dev/null +++ b/examples/latency_probe.rs @@ -0,0 +1,217 @@ +//! Latency + throughput probe for the vocoder DSP. +//! +//! This is a dev-only example, not part of the app. `vois` is a binary-only +//! crate (DSP lives behind private `mod dsp` in `main.rs`), so we pull in the +//! real DSP sources with `#[path]` and compile them into this example. No new +//! dependencies are needed: `rustfft` and `anyhow` are already in Cargo.toml. + +// The example only exercises `Processor` + `PitchShifter`; the rest of the +// worker/Control surface compiled in from `dsp::chain` is intentionally unused +// here, so silence dead-code warnings for this example target only. +#![allow(dead_code)] + +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +use anyhow::Result; + +/// Minimal stand-in for `crate::audio::record` so `dsp::chain` compiles here. +mod audio { + pub mod record { + use std::sync::mpsc; + use std::thread; + + pub fn spawn_writer( + _path: String, + _fs: u32, + ) -> (mpsc::Sender>, thread::JoinHandle<()>) { + let (tx, rx) = mpsc::channel(); + let handle = thread::spawn(move || while rx.recv().is_ok() {}); + (tx, handle) + } + } +} + +#[path = "../src/dsp/mod.rs"] +mod dsp; + +use dsp::chain::{Control, Processor}; +use dsp::pitch::PitchShifter; +use dsp::stats::Stats; + +const FS: u32 = 48000; + +/// Harmonic-rich saw (like the unit tests use): narrow pitch lines + broad +/// envelope, closer to a voice than a pure sine. +fn saw(freq: f32, fs: u32, samples: usize) -> Vec { + (0..samples) + .map(|i| { + let t = freq * i as f32 / fs as f32; + let mut s = 0.0; + for h in 1..=12 { + s += ((2.0 * std::f32::consts::PI * h as f32 * t).sin()) / h as f32; + } + s + }) + .collect() +} + +/// Feed `input` through the processor in the same fixed-size blocks the DSP +/// worker uses, collecting the output. +fn feed_blocks(p: &mut Processor, input: &[f32], block: usize) -> Vec { + let mut out_all = Vec::with_capacity(input.len()); + for chunk in input.chunks(block) { + let mut b = chunk.to_vec(); + b.resize(block, 0.0); + p.process_block(&mut b); + out_all.extend_from_slice(&b[..chunk.len()]); + } + out_all +} + +/// CPU time needed to process one second of audio (in ms), measured over 10 s +/// of a 220 Hz saw fed in `block`-sized chunks. Preset "Girl / Anime" (pitch +/// +5 st, formant x1.25) exercises the heaviest vocoder path. +fn throughput(block: usize) -> f64 { + let control = Arc::new(Mutex::new(Control { + preset_idx: 1, + ..Control::default() + })); + let mut p = Processor::new(FS, control, Arc::new(Mutex::new(Stats::default()))); + let audio = saw(220.0, FS, 10 * FS as usize); + let t0 = Instant::now(); + let out = feed_blocks(&mut p, &audio, block); + let dt = t0.elapsed(); + assert_eq!(out.len(), audio.len()); + dt.as_secs_f64() / 10.0 * 1000.0 +} + +/// Pitch-shifter group delay: feed a sharp transient, report the first output +/// sample above `thresh_frac` of the output peak. A unit impulse at sample 0 +/// is annihilated by the Hann window (w[0] == 0 exactly), so the transient is +/// placed mid-frame where the window is at its peak. +fn group_delay(n: usize, hop: usize, impulse_at: usize, thresh_frac: f32) -> Option<(usize, f32)> { + let mut sh = PitchShifter::new(n, hop, FS); + sh.pitch_ratio = 1.0; + let mut input = vec![0.0f32; 2 * n]; + input[impulse_at] = 1.0; + let mut out = Vec::new(); + sh.process(&input, &mut out); + let peak = out.iter().fold(0.0f32, |a, &v| a.max(v.abs())); + let thr = peak * thresh_frac; + out.iter().position(|&v| v.abs() > thr).map(|i| (i, peak)) +} + +/// Practical onset delay through the whole chain (what a user would hear): +/// feed a sine burst that starts after a quiet lead-in and measure the gap +/// between the input onset and the first output sample above a threshold. +fn chain_onset_delay(block: usize, n: usize) -> Option { + let control = Arc::new(Mutex::new(Control { + preset_idx: 0, + pitch_delta: 5.0, + ..Control::default() + })); + let mut p = Processor::new(FS, control, Arc::new(Mutex::new(Stats::default()))); + + let lead = 2 * n; + let burst_len = 2048; + let mut input = vec![0.0f32; lead + burst_len]; + for (i, s) in input.iter_mut().enumerate().skip(lead) { + let t = 220.0 * (i - lead) as f32 / FS as f32; + *s = (2.0 * std::f32::consts::PI * t).sin(); + } + + let out = feed_blocks(&mut p, &input, block); + let peak = out[lead..].iter().fold(0.0f32, |a, &v| a.max(v.abs())); + let thr = peak * 1e-2; + out.iter().position(|&v| v.abs() > thr).map(|i| i - lead) +} + +/// Sanity check: feeding the same audio at block=1024 vs block=256 must +/// produce (nearly) identical output — the block-size change only reduces +/// buffering latency, not audio content. +fn block_size_identity() { + let saw2 = saw(220.0, FS, 2 * FS as usize); + let mk = || { + Arc::new(Mutex::new(Control { + preset_idx: 1, + ..Control::default() + })) + }; + let mut a = Processor::new(FS, mk(), Arc::new(Mutex::new(Stats::default()))); + let mut b = Processor::new(FS, mk(), Arc::new(Mutex::new(Stats::default()))); + let oa = feed_blocks(&mut a, &saw2, 1024); + let ob = feed_blocks(&mut b, &saw2, 256); + let max_diff = oa + .iter() + .zip(ob.iter()) + .map(|(x, y)| (x - y).abs()) + .fold(0.0f32, f32::max); + println!("block 1024 vs 256 output: max |diff| = {max_diff:.3e}"); +} + +fn ms(samples: usize) -> f64 { + samples as f64 / FS as f64 * 1000.0 +} + +fn main() -> Result<()> { + let audio_seconds = 10.0; + let (n, hop) = (1024usize, 256usize); + + println!("=== vois DSP latency probe (fs = {FS} Hz) ==="); + + // a) Throughput. + for block in [1024usize, 256usize] { + let cpu_ms = throughput(block); + println!( + "throughput block={block:>4}: {cpu_ms:7.2} ms CPU per 1s audio \ + ({:5.1}% of one core, {:.1}x realtime)", + cpu_ms / 10.0, + audio_seconds / (cpu_ms / 1000.0) + ); + } + + // b) Pitch-shifter group delay (impulse mid-frame, ratio = 1.0). + let (gd, peak) = group_delay(n, hop, n / 2, 0.1).expect("no impulse response found"); + println!( + "shifter group delay (n={n}, hop={hop}, impulse@{}, >10% of peak {:.2e}): \ + {gd} samples = {:.2} ms", + n / 2, + peak, + ms(gd) + ); + + // Frame-fill latency: the shifter needs `n` samples before it can emit its + // first hop, i.e. output lags input by n - hop in the stream. + println!( + " streaming frame-fill latency n - hop = {} samples = {:.2} ms", + n - hop, + ms(n - hop) + ); + + // c) End-to-end estimate = measured group delay + fixed prime + block + // buffering, for the current settings. + let prime = n - hop; + for block in [1024usize, 256usize] { + let total = gd + prime + block; + println!( + "end-to-end (block={block:>4}): group delay {gd} + prime {prime} + buffering {block} \ + = {total} samples = {ms:.2} ms", + ms = ms(total) + ); + } + + // Chain onset delay is the number users would actually feel. + for block in [1024usize, 256usize] { + if let Some(d) = chain_onset_delay(block, n) { + println!( + "chain onset delay (block={block:>4}): {d} samples = {:.2} ms after burst start", + ms(d) + ); + } + } + + block_size_identity(); + + Ok(()) +} diff --git a/scripts/setup_virtual_mic.sh b/scripts/setup_virtual_mic.sh new file mode 100644 index 0000000..12670da --- /dev/null +++ b/scripts/setup_virtual_mic.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Create the "vois" null sink and expose its monitor as a virtual mic. +# The default sink is left untouched: vois routes only its own playback +# stream to the "vois" sink, so other apps keep playing to your real output. +set -eu + +SINK_NAME="vois.rs" +SINK_DESC="vois.rs virtual mic" +REMAP_NAME="vois.rs" + +# Idempotency check: field 2 of `pactl list short ` is the object name. +if ! pactl list short sinks | awk -v n="${SINK_NAME}" '$2==n' | grep -q .; then + pactl load-module module-null-sink \ + sink_name="${SINK_NAME}" \ + sink_properties="device.description=${SINK_DESC}" + echo "created sink: ${SINK_NAME}" +else + echo "sink already present: ${SINK_NAME}" +fi + +if ! pactl list short sources | awk -v n="${REMAP_NAME}" '$2==n' | grep -q .; then + pactl load-module module-remap-source \ + source_name="${REMAP_NAME}" \ + master="${SINK_NAME}.monitor" + echo "created remapped source: ${REMAP_NAME}" +else + echo "remapped source already present: ${REMAP_NAME}" +fi + +echo "virtual mic source name(s): ${SINK_NAME}.monitor and ${REMAP_NAME}" +echo 'use "vois.rs" (or "vois.rs.monitor") as your microphone in apps' diff --git a/src/audio/capture.rs b/src/audio/capture.rs new file mode 100644 index 0000000..2c80802 --- /dev/null +++ b/src/audio/capture.rs @@ -0,0 +1,69 @@ +use anyhow::{Context, Result}; +use cpal::traits::{DeviceTrait, HostTrait}; +use cpal::{ + Device, FromSample, Host, Sample, SampleFormat, SizedSample, Stream, StreamConfig, + SupportedStreamConfig, +}; +use ringbuf::traits::Producer; + +pub fn find_input(host: &Host, name: Option<&str>) -> Result { + match name { + Some(name) => host + .input_devices() + .context("failed to enumerate input devices")? + .find(|d| d.name().map(|n| n == name).unwrap_or(false)) + .with_context(|| format!("input device {name:?} not found")), + None => host + .default_input_device() + .context("no default input device found"), + } +} + +/// Open the input stream. Captured samples are converted to `f32` (interleaved +/// frames) and pushed into `tx`. +pub fn start

(device: &Device, supported: &SupportedStreamConfig, tx: P) -> Result +where + P: Producer + Send + 'static, +{ + let config: StreamConfig = supported.config(); + match supported.sample_format() { + SampleFormat::F32 => build::(device, &config, tx), + SampleFormat::F64 => build::(device, &config, tx), + SampleFormat::I8 => build::(device, &config, tx), + SampleFormat::I16 => build::(device, &config, tx), + SampleFormat::I32 => build::(device, &config, tx), + SampleFormat::I64 => build::(device, &config, tx), + SampleFormat::U8 => build::(device, &config, tx), + SampleFormat::U16 => build::(device, &config, tx), + SampleFormat::U32 => build::(device, &config, tx), + SampleFormat::U64 => build::(device, &config, tx), + format => anyhow::bail!("unsupported input sample format: {format:?}"), + } +} + +fn build(device: &Device, config: &StreamConfig, mut tx: P) -> Result +where + T: SizedSample, + f32: FromSample, + P: Producer + Send + 'static, +{ + let channels = config.channels as usize; + let mut frame = vec![0f32; channels]; + let err_fn = |err| eprintln!("capture stream error: {err}"); + + device + .build_input_stream( + config, + move |data: &[T], _: &cpal::InputCallbackInfo| { + for chunk in data.chunks(channels) { + for (slot, sample) in frame.iter_mut().zip(chunk) { + *slot = f32::from_sample(*sample); + } + let _ = tx.push_slice(&frame); + } + }, + err_fn, + None, + ) + .map_err(|e| anyhow::anyhow!("failed to build input stream: {e}")) +} diff --git a/src/audio/devices.rs b/src/audio/devices.rs new file mode 100644 index 0000000..7481740 --- /dev/null +++ b/src/audio/devices.rs @@ -0,0 +1,18 @@ +use anyhow::Result; +use cpal::traits::{DeviceTrait, HostTrait}; + +pub fn print_all() -> Result<()> { + let host = cpal::default_host(); + + println!("Input devices:"); + for device in host.input_devices()? { + println!(" {}", device.name().unwrap_or_else(|_| "".into())); + } + + println!("Output devices:"); + for device in host.output_devices()? { + println!(" {}", device.name().unwrap_or_else(|_| "".into())); + } + + Ok(()) +} diff --git a/src/audio/mod.rs b/src/audio/mod.rs new file mode 100644 index 0000000..ef4f0ce --- /dev/null +++ b/src/audio/mod.rs @@ -0,0 +1,191 @@ +pub mod capture; +pub mod devices; +pub mod playback; +pub mod record; +pub mod virtual_mic; + +use anyhow::{Context, Result}; +use cpal::traits::{DeviceTrait, StreamTrait}; +use cpal::{SampleRate, StreamConfig, SupportedStreamConfig, SupportedStreamConfigRange}; +use ringbuf::traits::Split; +use ringbuf::HeapRb; +use std::sync::{Arc, Mutex}; +use std::thread; + +use crate::config::AppConfig; +use crate::dsp; +use crate::ui; + +/// Higher = better quality for our purposes. +fn format_rank(format: cpal::SampleFormat) -> u8 { + match format { + cpal::SampleFormat::F32 => 10, + cpal::SampleFormat::I16 => 9, + cpal::SampleFormat::I32 => 8, + cpal::SampleFormat::F64 => 7, + cpal::SampleFormat::U16 => 6, + cpal::SampleFormat::I8 => 5, + cpal::SampleFormat::U8 => 4, + _ => 0, + } +} + +/// Find the best supported config with the exact sample rate (same channel +/// count as `channels`). This is how we force both devices onto one sample +/// rate while preferring a high-quality sample format. +fn pick_config( + ranges: impl Iterator, + channels: u16, + rate: SampleRate, +) -> Option { + ranges + .filter(|r| r.channels() == channels && r.try_with_sample_rate(rate).is_some()) + .max_by_key(|r| format_rank(r.sample_format())) + .map(|r| r.with_sample_rate(rate)) +} + +pub fn run(cfg: &AppConfig) -> Result<()> { + let host = cpal::default_host(); + + let input_device = capture::find_input(&host, cfg.input.as_deref())?; + let output_device = playback::find_output(&host, cfg.output.as_deref())?; + + let in_supported = input_device + .default_input_config() + .context("failed to read default input config")?; + + let rate = cfg + .sample_rate + .map(SampleRate) + .unwrap_or_else(|| in_supported.sample_rate()); + + let in_supported = if cfg.sample_rate.is_some() { + pick_config( + input_device.supported_input_configs()?, + in_supported.channels(), + rate, + ) + .with_context(|| format!("input device does not support sample rate {} Hz", rate.0))? + } else { + in_supported + }; + + let out_default = output_device + .default_output_config() + .context("failed to read default output config")?; + let out_supported = pick_config( + output_device.supported_output_configs()?, + out_default.channels(), + rate, + ) + .with_context(|| { + format!( + "output device does not support sample rate {} Hz; try `--sample-rate`", + rate.0 + ) + })?; + + let in_cfg: StreamConfig = in_supported.config(); + let out_cfg: StreamConfig = out_supported.config(); + + println!( + "input : {} @ {} Hz, {:?}, {} ch", + input_device.name().unwrap_or_else(|_| "".into()), + in_cfg.sample_rate.0, + in_supported.sample_format(), + in_cfg.channels, + ); + println!( + "output : {} @ {} Hz, {:?}, {} ch", + output_device.name().unwrap_or_else(|_| "".into()), + out_cfg.sample_rate.0, + out_supported.sample_format(), + out_cfg.channels, + ); + + let ring_cap = (rate.0 as usize * 2).max(8192); + let (in_prod, in_cons) = HeapRb::::new(ring_cap).split(); + let (out_prod, out_cons) = HeapRb::::new(ring_cap).split(); + + // Virtual mic: keep `vois.rs` as the default sink for the whole run so the + // app's output lands in the virtual mic. A helper thread parks other + // apps' streams back on the real sink so their audio doesn't leak into the + // mic. The default sink is restored on exit. + let prev_sink = if cfg.virtual_mic { + virtual_mic::VirtualMic::setup()?; + virtual_mic::VirtualMic::get_default_sink() + } else { + None + }; + if prev_sink.is_some() { + virtual_mic::VirtualMic::set_default_sink("vois.rs"); + } + + let input_stream = capture::start(&input_device, &in_supported, in_prod)?; + let output_stream = playback::start(&output_device, &out_supported, out_cons)?; + + let in_ch = in_cfg.channels as usize; + let out_ch = out_cfg.channels as usize; + + let control = Arc::new(Mutex::new(cfg.to_control())); + let stats = Arc::new(Mutex::new(dsp::stats::Stats::default())); + let worker_control = Arc::clone(&control); + let worker_stats = Arc::clone(&stats); + let worker = thread::spawn(move || { + dsp::chain::run_worker( + in_cons, + out_prod, + in_ch, + out_ch, + rate.0, + worker_control, + worker_stats, + ) + }); + + input_stream + .play() + .context("failed to start input stream")?; + output_stream + .play() + .context("failed to start output stream")?; + let cleanup = if let Some(prev) = prev_sink { + let stop = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let handle = virtual_mic::VirtualMic::keep_others_off(Arc::clone(&stop), prev.clone()); + let _sig = virtual_mic::VirtualMic::restore_on_signal(prev.clone()); + Some((stop, handle, prev)) + } else { + None + }; + + let info = ui::SessionInfo { + input: input_device.name().unwrap_or_else(|_| "".into()), + output: output_device.name().unwrap_or_else(|_| "".into()), + sample_rate: rate.0, + in_ch, + out_ch, + format_in: format!("{:?}", in_supported.sample_format()), + format_out: format!("{:?}", out_supported.sample_format()), + }; + + // Keep the streams alive while the UI blocks. + let _streams = (input_stream, output_stream); + + ui::tui::run(control, stats, info); + + if let Some((stop, handle, prev)) = cleanup { + stop.store(true, std::sync::atomic::Ordering::Relaxed); + let _ = handle.join(); + virtual_mic::VirtualMic::set_default_sink(&prev); + } + + println!("waiting for DSP worker to finish..."); + let _ = worker.join(); + + // cpal's ALSA teardown can panic on PipeWire when the stream drop races + // with the worker thread closing its self-pipe. The process is about to + // exit anyway, so leak the streams instead of dropping them. + std::mem::forget(_streams); + println!("done."); + Ok(()) +} diff --git a/src/audio/playback.rs b/src/audio/playback.rs new file mode 100644 index 0000000..fa0231a --- /dev/null +++ b/src/audio/playback.rs @@ -0,0 +1,75 @@ +use anyhow::{Context, Result}; +use cpal::traits::{DeviceTrait, HostTrait}; +use cpal::{ + Device, FromSample, Host, SampleFormat, SizedSample, Stream, StreamConfig, + SupportedStreamConfig, +}; +use ringbuf::traits::Consumer; + +pub fn find_output(host: &Host, name: Option<&str>) -> Result { + match name { + Some(name) => host + .output_devices() + .context("failed to enumerate output devices")? + .find(|d| d.name().map(|n| n == name).unwrap_or(false)) + .with_context(|| format!("output device {name:?} not found")), + None => host + .default_output_device() + .context("no default output device found"), + } +} + +/// Open the output stream. Pulls interleaved `f32` frames from `rx`; fills +/// with silence when the DSP worker has nothing ready yet. +pub fn start(device: &Device, supported: &SupportedStreamConfig, rx: C) -> Result +where + C: Consumer + Send + 'static, +{ + let config: StreamConfig = supported.config(); + match supported.sample_format() { + SampleFormat::F32 => build::(device, &config, rx), + SampleFormat::F64 => build::(device, &config, rx), + SampleFormat::I8 => build::(device, &config, rx), + SampleFormat::I16 => build::(device, &config, rx), + SampleFormat::I32 => build::(device, &config, rx), + SampleFormat::I64 => build::(device, &config, rx), + SampleFormat::U8 => build::(device, &config, rx), + SampleFormat::U16 => build::(device, &config, rx), + SampleFormat::U32 => build::(device, &config, rx), + SampleFormat::U64 => build::(device, &config, rx), + format => anyhow::bail!("unsupported output sample format: {format:?}"), + } +} + +fn build(device: &Device, config: &StreamConfig, mut rx: C) -> Result +where + T: SizedSample + FromSample, + C: Consumer + Send + 'static, +{ + let mut scratch = vec![0f32; 2048]; + let err_fn = |err| eprintln!("playback stream error: {err}"); + + device + .build_output_stream( + config, + move |data: &mut [T], _: &cpal::OutputCallbackInfo| { + let mut written = 0usize; + while written < data.len() { + let want = (data.len() - written).min(scratch.len()); + let n = rx.pop_slice(&mut scratch[..want]); + if n == 0 { + data[written..].fill(T::from_sample(0.0f32)); + break; + } + let out = &mut data[written..written + n]; + for (out_sample, in_sample) in out.iter_mut().zip(&scratch[..n]) { + *out_sample = T::from_sample(*in_sample); + } + written += n; + } + }, + err_fn, + None, + ) + .map_err(|e| anyhow::anyhow!("failed to build output stream: {e}")) +} diff --git a/src/audio/record.rs b/src/audio/record.rs new file mode 100644 index 0000000..1ff3000 --- /dev/null +++ b/src/audio/record.rs @@ -0,0 +1,40 @@ +//! WAV recording of the processed mono stream via a dedicated writer thread. + +use hound::{SampleFormat, WavSpec, WavWriter}; +use std::sync::mpsc; +use std::sync::mpsc::Sender; +use std::thread; +use std::thread::JoinHandle; + +pub fn spawn_writer(path: String, sample_rate: u32) -> (Sender>, JoinHandle<()>) { + let (tx, rx) = mpsc::channel::>(); + let handle = thread::spawn(move || { + let spec = WavSpec { + channels: 1, + sample_rate, + bits_per_sample: 16, + sample_format: SampleFormat::Int, + }; + let mut writer = match WavWriter::create(&path, spec) { + Ok(w) => w, + Err(e) => { + eprintln!("record: cannot create {path}: {e}"); + return; + } + }; + while let Ok(block) = rx.recv() { + for &s in &block { + let v = (s.clamp(-1.0, 1.0) * i16::MAX as f32) as i16; + if writer.write_sample(v).is_err() { + break; + } + } + } + if let Err(e) = writer.finalize() { + eprintln!("record: {e}"); + } else { + println!("recorded to {path}"); + } + }); + (tx, handle) +} diff --git a/src/audio/virtual_mic.rs b/src/audio/virtual_mic.rs new file mode 100644 index 0000000..0d4472c --- /dev/null +++ b/src/audio/virtual_mic.rs @@ -0,0 +1,197 @@ +// Virtual microphone setup for PipeWire/PulseAudio. +// +// Creates a "vois.rs" null sink and remaps its monitor source to "vois.rs". +// The app does NOT hijack the default sink permanently: it briefly points the +// default sink at `vois.rs` while its own output stream is created (so the +// processed voice lands in the virtual mic) and restores it right after. +// Other apps' streams are not moved, so their audio never leaks into the mic. +use anyhow::{bail, Context, Result}; +use std::process::Command; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::thread; +use std::time::Duration; + +pub struct VirtualMic; + +impl VirtualMic { + pub fn setup() -> Result> { + let pactl = "pactl"; + + let sinks = Command::new(pactl) + .args(["list", "short", "sinks"]) + .output() + .context("failed to run `pactl list short sinks`")?; + if !sinks.status.success() { + bail!("`pactl list short sinks` failed"); + } + let sinks_out = String::from_utf8_lossy(&sinks.stdout); + + if !has_entry(&sinks_out, "vois.rs") { + let out = Command::new(pactl) + .args([ + "load-module", + "module-null-sink", + "sink_name=vois.rs", + "sink_properties=device.description=vois virtual mic", + ]) + .output() + .context("failed to create the vois.rs null sink")?; + if !out.status.success() { + bail!( + "`pactl load-module module-null-sink sink_name=vois.rs` failed: {}", + String::from_utf8_lossy(&out.stderr).trim() + ); + } + } + + let sources = Command::new(pactl) + .args(["list", "short", "sources"]) + .output() + .context("failed to run `pactl list short sources`")?; + if !sources.status.success() { + bail!("`pactl list short sources` failed"); + } + let sources_out = String::from_utf8_lossy(&sources.stdout); + + if !has_entry(&sources_out, "vois.rs") { + let out = Command::new(pactl) + .args([ + "load-module", + "module-remap-source", + "source_name=vois.rs", + "master=vois.rs.monitor", + ]) + .output() + .context("failed to create the vois.rs remap source")?; + if !out.status.success() { + bail!( + "`pactl load-module module-remap-source source_name=vois.rs` failed: {}", + String::from_utf8_lossy(&out.stderr).trim() + ); + } + } + + Ok(Some("vois.rs".to_string())) + } + + /// Current default sink name, if any. + pub fn get_default_sink() -> Option { + let out = Command::new("pactl") + .arg("get-default-sink") + .output() + .ok()?; + let name = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if name.is_empty() { + None + } else { + Some(name) + } + } + + pub fn set_default_sink(name: &str) { + let _ = Command::new("pactl") + .args(["set-default-sink", name]) + .output(); + } + + /// Keep every sink-input that is NOT this app's (client name "vois") away + /// from the `vois.rs` sink, moving it to `prev_sink`. Runs until `stop`. + /// + /// WirePlumber routes ALSA streams to the default sink, so while the app + /// runs with `vois.rs` as default, other apps' audio would leak into the + /// virtual mic. Moving their streams explicitly sticks, so this thread + /// parks them on the real output. + pub fn keep_others_off(stop: Arc, prev_sink: String) -> thread::JoinHandle<()> { + thread::spawn(move || { + while !stop.load(Ordering::Relaxed) { + let target_sink_id = sink_id_named("vois.rs"); + let prev_id = sink_id_named(&prev_sink); + if let (Some(lv), Some(pv)) = (target_sink_id, prev_id) { + if let (Ok(names), Ok(inputs)) = (client_names(), sink_inputs()) { + for (id, sink, client) in &inputs { + let ours = names.get(client).map(|n| n == "vois").unwrap_or(false); + if sink == &lv && !ours { + let _ = Command::new("pactl") + .args(["move-sink-input", id, &pv]) + .output(); + } + } + } + } + thread::sleep(Duration::from_millis(500)); + } + }) + } + + /// Restore the default sink if the process is killed (SIGINT/SIGTERM), + /// so the user's audio isn't left pointing at the silent null sink. + pub fn restore_on_signal(prev: String) -> thread::JoinHandle<()> { + thread::spawn(move || { + let mut signals = match signal_hook::iterator::Signals::new([ + signal_hook::consts::signal::SIGINT, + signal_hook::consts::signal::SIGTERM, + ]) { + Ok(s) => s, + Err(_) => return, + }; + if let Some(_sig) = signals.forever().next() { + Self::set_default_sink(&prev); + std::process::exit(130); + } + }) + } +} + +fn sink_id_named(name: &str) -> Option { + let out = Command::new("pactl") + .args(["list", "short", "sinks"]) + .output() + .ok()?; + for line in String::from_utf8_lossy(&out.stdout).lines() { + let fields: Vec<&str> = line.split_whitespace().collect(); + if fields.len() >= 2 && fields[1] == name { + return Some(fields[0].to_string()); + } + } + None +} + +fn client_names() -> Result> { + let out = Command::new("pactl") + .args(["list", "short", "clients"]) + .output() + .context("failed to run `pactl list short clients`")?; + let mut map = std::collections::HashMap::new(); + for line in String::from_utf8_lossy(&out.stdout).lines() { + let fields: Vec<&str> = line.split_whitespace().collect(); + if fields.len() >= 3 { + map.insert(fields[0].to_string(), fields[2].to_string()); + } + } + Ok(map) +} + +fn sink_inputs() -> Result> { + let out = Command::new("pactl") + .args(["list", "short", "sink-inputs"]) + .output() + .context("failed to run `pactl list short sink-inputs`")?; + let mut inputs = Vec::new(); + for line in String::from_utf8_lossy(&out.stdout).lines() { + let fields: Vec<&str> = line.split_whitespace().collect(); + if fields.len() >= 3 { + inputs.push(( + fields[0].to_string(), + fields[1].to_string(), + fields[2].to_string(), + )); + } + } + Ok(inputs) +} + +fn has_entry(list: &str, name: &str) -> bool { + list.lines() + .any(|l| l.split_whitespace().nth(1).is_some_and(|n| n == name)) +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..d3fc12e --- /dev/null +++ b/src/config.rs @@ -0,0 +1,64 @@ +use crate::dsp::chain::Control; +use crate::dsp::presets::PRESETS; + +pub struct AppConfig { + pub input: Option, + pub output: Option, + pub sample_rate: Option, + pub preset: String, + pub record: Option, + pub pitch: f32, + pub formant: f32, + pub gate: f32, + pub gain: f32, + pub run_seconds: Option, + pub tone: f32, + pub virtual_mic: bool, +} + +impl AppConfig { + pub fn from_cli(cli: &crate::Cli) -> Self { + Self { + input: cli.input.clone(), + output: cli.output.clone(), + sample_rate: cli.sample_rate, + preset: cli.preset.clone(), + record: cli.record.clone(), + pitch: cli.pitch, + formant: cli.formant, + gate: cli.gate, + gain: cli.gain, + run_seconds: cli.run_seconds, + tone: cli.tone, + virtual_mic: !cli.no_virtual_mic, + } + } + + /// Resolve the preset name into an index, defaulting to "Clean" on error. + fn preset_idx(&self) -> usize { + crate::dsp::presets::Preset::find(&self.preset).unwrap_or(0) + } + + pub fn to_control(&self) -> Control { + Control { + preset_idx: self.preset_idx(), + pitch_delta: self.pitch, + formant_delta: self.formant, + gate_threshold_db: self.gate, + gain: self.gain, + record: self.record.is_some(), + record_path: self.record.clone(), + run_seconds: self.run_seconds, + tone_hz: self.tone, + ..Control::default() + } + } + + pub fn preset_names() -> String { + PRESETS + .iter() + .map(|p| p.name) + .collect::>() + .join(", ") + } +} diff --git a/src/dsp/chain.rs b/src/dsp/chain.rs new file mode 100644 index 0000000..c05bd0f --- /dev/null +++ b/src/dsp/chain.rs @@ -0,0 +1,430 @@ +use crate::audio::record; +use crate::dsp::effects::Effect; +use crate::dsp::formant::ratio_from_semitones; +use crate::dsp::gate::NoiseGate; +use crate::dsp::pitch::PitchShifter; +use crate::dsp::presets::{build_effects, PRESETS}; +use crate::dsp::stats::Stats; +use ringbuf::traits::{Consumer, Producer}; +use std::sync::mpsc::Sender; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +/// Processing mode, switchable at runtime (A/B compare). +#[derive(Clone, Copy, PartialEq)] +pub enum Mode { + /// Normal processing through the DSP chain. + Live, + /// Raw input straight to output (bypass) for A/B comparison. + Passthrough, +} + +impl Mode { + pub fn toggle(&mut self) { + *self = match self { + Mode::Live => Mode::Passthrough, + Mode::Passthrough => Mode::Live, + }; + } +} + +/// Live-adjustable state, shared between the UI and the DSP worker. +pub struct Control { + pub preset_idx: usize, + /// Manual pitch adjustment added to the preset value (semitones). + pub pitch_delta: f32, + /// Manual formant adjustment added to the preset value (semitones). + pub formant_delta: f32, + pub gate_threshold_db: f32, + pub gain: f32, + pub mute: bool, + pub record: bool, + pub record_path: Option, + pub run_seconds: Option, + /// Test tone frequency (Hz) mixed into the input; 0 = off. + pub tone_hz: f32, + pub mode: Mode, + pub quit: bool, +} + +impl Default for Control { + fn default() -> Self { + Self { + preset_idx: 0, + pitch_delta: 0.0, + formant_delta: 0.0, + gate_threshold_db: -100.0, + gain: 1.0, + mute: false, + record: false, + record_path: None, + run_seconds: None, + tone_hz: 0.0, + mode: Mode::Live, + quit: false, + } + } +} + +pub struct Processor { + fs: u32, + control: Arc>, + stats: Arc>, + shifter: Option, + shift_pending: Vec, + gate: NoiseGate, + effects: Vec>, + last_preset: usize, + recorder_tx: Option>>, + recorder_handle: Option>, +} + +impl Processor { + pub fn new(fs: u32, control: Arc>, stats: Arc>) -> Self { + Self { + fs, + control, + stats, + shifter: None, + shift_pending: Vec::new(), + gate: NoiseGate::new(-60.0), + effects: Vec::new(), + last_preset: usize::MAX, + recorder_tx: None, + recorder_handle: None, + } + } + + /// Stop recording and wait for the WAV writer to finalize. + fn stop_recording(&mut self) { + self.recorder_tx = None; + if let Some(handle) = self.recorder_handle.take() { + let _ = handle.join(); + } + } + + /// Process one fixed-size mono block through the whole chain. + pub fn process_block(&mut self, mono: &mut [f32]) { + if mono.is_empty() { + return; + } + + let t0 = Instant::now(); + + // Input level (pre-processing). + let mut in_sum = 0.0; + for &s in mono.iter() { + in_sum += s * s; + } + let in_rms = (in_sum / mono.len() as f32).sqrt(); + + let (preset_idx, pitch, formant, gate_db, gain, mute, record, mode, record_path) = { + let ctrl = match self.control.lock() { + Ok(c) => c, + Err(p) => p.into_inner(), + }; + let preset = &PRESETS[ctrl.preset_idx.min(PRESETS.len() - 1)]; + ( + ctrl.preset_idx.min(PRESETS.len() - 1), + preset.pitch_semitones + ctrl.pitch_delta, + preset.formant_ratio * ratio_from_semitones(ctrl.formant_delta), + ctrl.gate_threshold_db, + ctrl.gain, + ctrl.mute, + ctrl.record, + ctrl.mode, + ctrl.record_path.clone(), + ) + }; + + if preset_idx != self.last_preset { + self.effects = build_effects(PRESETS[preset_idx].effects, self.fs); + self.last_preset = preset_idx; + } + + let shifter_active = (pitch - 0.0).abs() > 0.05 || (formant - 1.0).abs() > 0.01; + + if mute { + mono.fill(0.0); + } else if mode == Mode::Passthrough { + // A/B compare: raw input, only gain. + for s in mono.iter_mut() { + *s *= gain; + } + } else { + self.gate.set_threshold_db(gate_db); + self.gate.process(mono); + + if shifter_active { + if self.shifter.is_none() { + self.shifter = Some(PitchShifter::new(1024, 256, self.fs)); + // Prime the extraction buffer with the vocoder's fixed latency + // (n - hop) so every input block yields a full output block. + self.shift_pending = vec![0.0; 1024 - 256]; + } + let shifter = self.shifter.as_mut().unwrap(); + shifter.pitch_ratio = 2f32.powf(pitch / 12.0); + shifter.formant_ratio = formant; + let mut out = Vec::with_capacity(mono.len()); + shifter.process(mono, &mut out); + + // The vocoder is a streaming delay line: per call it may emit a + // different number of samples than it consumed. Keep the excess + // in `shift_pending` and pull exactly `mono.len()` samples out, + // so the rest of the chain sees constant block sizes. + self.shift_pending.extend(out); + let take = mono.len().min(self.shift_pending.len()); + mono[..take].copy_from_slice(&self.shift_pending[..take]); + mono[take..].fill(0.0); + self.shift_pending.drain(..take); + } + + for effect in &mut self.effects { + effect.process(mono); + } + + for s in mono.iter_mut() { + *s *= gain; + } + } + + if record { + if self.recorder_tx.is_none() { + if let Some(path) = record_path { + let (tx, handle) = record::spawn_writer(path, self.fs); + self.recorder_tx = Some(tx); + self.recorder_handle = Some(handle); + } + } + if let Some(tx) = &self.recorder_tx { + let _ = tx.send(mono.to_vec()); + } + } else if self.recorder_tx.is_some() { + self.stop_recording(); + } + + // Output level + runtime stats. + let mut out_sum = 0.0; + for &s in mono.iter() { + out_sum += s * s; + } + let out_rms = (out_sum / mono.len() as f32).sqrt(); + let elapsed = t0.elapsed().as_secs_f32(); + let block_dur = mono.len() as f32 / self.fs as f32; + let cpu = if block_dur > 0.0 { + elapsed / block_dur * 100.0 + } else { + 0.0 + }; + + let mut st = match self.stats.lock() { + Ok(s) => s, + Err(p) => p.into_inner(), + }; + st.input_rms = st.input_rms * 0.85 + in_rms * 0.15; + st.output_rms = st.output_rms * 0.85 + out_rms * 0.15; + st.peak_in = st.peak_in.max(in_rms); + st.peak_out = st.peak_out.max(out_rms); + st.blocks += 1; + st.samples += mono.len() as u64; + st.cpu_pct = st.cpu_pct * 0.9 + cpu * 0.1; + // DSP latency estimate: block buffering + vocoder frame-fill when active. + let latency_samples = if shifter_active { 768 } else { 0 } + 256; + st.dsp_latency_ms = latency_samples as f32 / self.fs as f32 * 1000.0; + } +} + +/// DSP worker thread: pulls captured audio, runs the chain, pushes the result +/// to the output. +pub fn run_worker( + mut input: I, + mut output: O, + in_ch: usize, + out_ch: usize, + fs: u32, + control: Arc>, + stats: Arc>, +) where + I: Consumer + Send + 'static, + O: Producer + Send + 'static, +{ + let mut processor = Processor::new(fs, control, stats); + let mut scratch = vec![0f32; 8192]; + let mut acc = Vec::with_capacity(8192); + let mut block = vec![0f32; 256]; + let mut out = Vec::with_capacity(8192); + let mut total_blocks: u64 = 0; + let mut total_samples: u64 = 0; + let mut tone_phase: f32 = 0.0; + + loop { + if processor.control.lock().map(|c| c.quit).unwrap_or(false) { + break; + } + + let n = input.pop_slice(&mut scratch); + if n == 0 { + thread::sleep(Duration::from_millis(1)); + continue; + } + + total_samples += n as u64; + + let frames = n / in_ch.max(1); + for f in 0..frames { + let mut mix = 0.0; + for c in 0..in_ch { + mix += scratch[f * in_ch + c]; + } + acc.push(mix / in_ch as f32); + } + + // Optional test tone injected before the DSP chain. + let tone_hz = processor.control.lock().map(|c| c.tone_hz).unwrap_or(0.0); + if tone_hz > 0.0 { + let start = acc.len().saturating_sub(frames); + for v in acc.iter_mut().skip(start) { + *v += 0.5 * (2.0 * std::f32::consts::PI * tone_phase).sin(); + tone_phase += tone_hz / fs as f32; + if tone_phase >= 1.0 { + tone_phase -= 1.0; + } + } + } + + // Process in fixed-size blocks (multiple of the vocoder hop) so the + // pitch shifter always yields a full block. Smaller blocks cut the + // worst-case buffering latency; the shifter stream is unchanged. + while acc.len() >= 256 { + block.copy_from_slice(&acc[..256]); + acc.drain(..256); + total_blocks += 1; + + processor.process_block(&mut block); + + out.clear(); + for v in &block { + for _ in 0..out_ch { + out.push(*v); + } + } + let _ = output.push_slice(&out); + } + } + + eprintln!("dsp: processed {total_blocks} blocks / {total_samples} samples"); + processor.stop_recording(); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rms(x: &[f32]) -> f32 { + let s: f32 = x.iter().map(|v| v * v).sum(); + (s / x.len().max(1) as f32).sqrt() + } + + fn dominant_hz(x: &[f32], fs: u32) -> f32 { + use rustfft::num_complex::Complex; + use rustfft::FftPlanner; + let n = 4096; + if x.len() < n { + return 0.0; + } + let mut planner = FftPlanner::::new(); + let fft = planner.plan_fft_forward(n); + let mut buf: Vec> = x[..n].iter().map(|&s| Complex::new(s, 0.0)).collect(); + fft.process(&mut buf); + let bins = n / 2; + let mags: Vec = (0..bins).map(|b| buf[b].norm()).collect(); + let mut best = (0usize, 0.0f32); + for b in 20..(bins / 4) { + let mut s = 0.0; + for h in 1..=4 { + if b * h < bins { + s += mags[b * h]; + } + } + if s > best.1 { + best = (b, s); + } + } + fs as f32 * best.0 as f32 / n as f32 + } + + fn preset_control(idx: usize) -> Arc> { + Arc::new(Mutex::new(Control { + preset_idx: idx, + ..Control::default() + })) + } + + #[test] + fn girl_preset_produces_energy_on_sine() { + // Clean preset + manual pitch delta = pitch-only (no formant shift). + let control = Arc::new(Mutex::new(Control { + preset_idx: 0, + pitch_delta: 5.0, + ..Control::default() + })); + let mut p = Processor::new(48000, control, Arc::new(Mutex::new(Stats::default()))); + // Harmonic-rich tone (closer to voice than a pure sine). + let input: Vec = (0..96000) + .map(|i| { + let t = 220.0 * i as f32 / 48000.0; + let mut s = 0.0; + for h in 1..=12 { + s += (2.0 * std::f32::consts::PI * h as f32 * t).sin() / h as f32; + } + s + }) + .collect(); + let out = feed_blocks(&mut p, &input); + let r = rms(&out[4000..]); + assert!(r.is_finite() && r > 0.05, "girl sine rms too low: {r}"); + + // Pitch must actually shift: 220 Hz * 2^(5/12) ~= 293.7 Hz. + let est = dominant_hz(&out[20000..96000], 48000); + assert!( + (est - 293.7).abs() < 12.0, + "girl pitch expected ~293.7 Hz, got {est:.1} Hz" + ); + } + + #[test] + fn girl_preset_produces_energy_on_noise() { + let control = preset_control(1); + let mut p = Processor::new(48000, control, Arc::new(Mutex::new(Stats::default()))); + let mut state = 0x12345678u32; + let mut input = Vec::new(); + for _ in 0..96000 { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + input.push(state as f32 / u32::MAX as f32 * 2.0 - 1.0); + } + // Scale to ~mic-hiss level. + for v in input.iter_mut() { + *v *= 0.01; + } + let out = feed_blocks(&mut p, &input); + let r = rms(&out[4000..]); + assert!(r.is_finite() && r > 0.0005, "girl noise rms too low: {r}"); + } + + /// Feed input through the processor in the same fixed-size blocks the + /// real worker uses, collecting the output. + fn feed_blocks(p: &mut Processor, input: &[f32]) -> Vec { + let mut out_all = Vec::with_capacity(input.len()); + for chunk in input.chunks(256) { + let mut block = chunk.to_vec(); + block.resize(256, 0.0); + p.process_block(&mut block); + out_all.extend_from_slice(&block[..chunk.len()]); + } + out_all + } +} diff --git a/src/dsp/effects.rs b/src/dsp/effects.rs new file mode 100644 index 0000000..2678a06 --- /dev/null +++ b/src/dsp/effects.rs @@ -0,0 +1,605 @@ +//! Effects chain: small processing units applied to the mono stream after +//! pitch/formant shifting. Each effect mutates the block in place. + +use std::f32::consts::PI; + +pub trait Effect: Send { + fn process(&mut self, mono: &mut [f32]); +} + +// --------------------------------------------------------------------------- +// Distortion (waveshaper) +// --------------------------------------------------------------------------- + +pub struct Distortion { + drive: f32, + output: f32, +} + +impl Distortion { + pub fn new(drive: f32, output: f32) -> Self { + Self { drive, output } + } +} + +impl Effect for Distortion { + fn process(&mut self, mono: &mut [f32]) { + for s in mono.iter_mut() { + *s = (*s * self.drive).tanh() * self.output; + } + } +} + +// --------------------------------------------------------------------------- +// Bitcrusher (bit depth reduction + sample rate reduction) +// --------------------------------------------------------------------------- + +pub struct Bitcrush { + bits: u8, + sample_rate_div: u32, + /// Max boost applied to quiet input (auto-level normalization). + max_gain: f32, + /// Target peak level after normalization. + target: f32, + gain_smooth: f32, + hold: f32, + count: u32, +} + +impl Bitcrush { + /// `max_gain`/`target` normalize each block before quantization so the + /// effect works on quiet mics without clipping loud ones. + pub fn new(bits: u8, sample_rate_div: u32, max_gain: f32, target: f32) -> Self { + Self { + bits: bits.max(2), + sample_rate_div: sample_rate_div.max(1), + max_gain: max_gain.max(1.0), + target: target.clamp(0.1, 1.0), + gain_smooth: 1.0, + hold: 0.0, + count: 0, + } + } +} + +impl Effect for Bitcrush { + fn process(&mut self, mono: &mut [f32]) { + if mono.is_empty() { + return; + } + let levels = (1i32 << (self.bits - 1)) as f32; + + // Auto-level: bring the block peak up to `target` (with smoothing). + let peak = mono.iter().fold(0.0f32, |m, s| m.max(s.abs())); + let gain = if peak > 1e-6 { + (self.target / peak).clamp(1.0, self.max_gain) + } else { + self.max_gain + }; + self.gain_smooth += (gain - self.gain_smooth) * 0.3; + let g = self.gain_smooth; + + for s in mono.iter_mut() { + self.count = (self.count + 1) % self.sample_rate_div; + if self.count == 0 { + self.hold = *s; + } + let q = (self.hold * g * levels).round(); + *s = (q / levels).clamp(-1.0, 1.0); + } + } +} + +// --------------------------------------------------------------------------- +// Freeverb-style reverb +// --------------------------------------------------------------------------- + +struct Comb { + buf: Vec, + idx: usize, + filter: f32, + feedback: f32, + damp1: f32, + damp2: f32, +} + +impl Comb { + fn new(len: usize) -> Self { + Self { + buf: vec![0.0; len], + idx: 0, + filter: 0.0, + feedback: 0.84, + damp1: 0.5, + damp2: 0.5, + } + } + + #[inline] + fn process(&mut self, input: f32) -> f32 { + let output = self.buf[self.idx]; + self.filter = output * self.damp2 + self.filter * self.damp1; + self.buf[self.idx] = input + self.filter * self.feedback; + self.idx += 1; + if self.idx >= self.buf.len() { + self.idx = 0; + } + output + } +} + +struct Allpass { + buf: Vec, + idx: usize, + feedback: f32, +} + +impl Allpass { + fn new(len: usize) -> Self { + Self { + buf: vec![0.0; len], + idx: 0, + feedback: 0.5, + } + } + + #[inline] + fn process(&mut self, input: f32) -> f32 { + let bufout = self.buf[self.idx]; + self.buf[self.idx] = input + bufout * self.feedback; + self.idx += 1; + if self.idx >= self.buf.len() { + self.idx = 0; + } + bufout - input + } +} + +pub struct Reverb { + combs: Vec, + allpasses: Vec, + wet: f32, + room: f32, + damp: f32, +} + +impl Reverb { + pub fn new(sample_rate: u32, room: f32, damp: f32, wet: f32) -> Self { + let scale = sample_rate as f32 / 44100.0; + let comb_lens = [1116, 1188, 1277, 1356, 1422, 1491, 1557, 1617]; + let allpass_lens = [556, 441, 341, 225]; + + let combs: Vec = comb_lens + .iter() + .map(|&l| Comb::new((l as f32 * scale) as usize)) + .collect(); + let allpasses: Vec = allpass_lens + .iter() + .map(|&l| Allpass::new((l as f32 * scale) as usize)) + .collect(); + + let mut reverb = Self { + combs, + allpasses, + wet: wet.clamp(0.0, 1.0), + room: room.clamp(0.0, 1.0), + damp: damp.clamp(0.0, 1.0), + }; + reverb.apply_params(); + reverb + } + + fn apply_params(&mut self) { + // Map room size 0..1 to feedback 0..~0.98. + let feedback = self.room * 0.88 + 0.10; + for comb in &mut self.combs { + comb.feedback = feedback; + comb.damp1 = self.damp; + comb.damp2 = 1.0 - self.damp; + } + } +} + +impl Effect for Reverb { + fn process(&mut self, mono: &mut [f32]) { + let wet = self.wet; + let dry = 1.0 - wet; + for s in mono.iter_mut() { + let input = *s; + let mut acc = 0.0; + for comb in &mut self.combs { + acc += comb.process(input); + } + acc /= self.combs.len() as f32; + for ap in &mut self.allpasses { + acc = ap.process(acc); + } + *s = input * dry + acc * wet * 1.5; + } + } +} + +// --------------------------------------------------------------------------- +// Chorus +// --------------------------------------------------------------------------- + +pub struct Chorus { + delay: Vec, + idx: usize, + cap: usize, + depth: f32, + rate: f32, + wet: f32, + phase: f32, + fs: f32, +} + +impl Chorus { + pub fn new(sample_rate: u32, depth_ms: f32, rate_hz: f32, wet: f32) -> Self { + let depth = depth_ms / 1000.0 * sample_rate as f32; + let cap = (sample_rate as f32 * 0.1) as usize + (depth * 4.0) as usize; + Self { + delay: vec![0.0; cap], + idx: 0, + cap, + depth: depth.max(1.0), + rate: rate_hz, + wet: wet.clamp(0.0, 1.0), + phase: 0.0, + fs: sample_rate as f32, + } + } + + #[inline] + fn read(&self, offset: f32) -> f32 { + let mut pos = self.idx as f32 - offset; + if pos < 0.0 { + pos += self.cap as f32; + } + let i = pos.floor() as usize; + let j = (i + 1) % self.cap; + let t = pos - i as f32; + self.delay[i] * (1.0 - t) + self.delay[j] * t + } +} + +impl Effect for Chorus { + fn process(&mut self, mono: &mut [f32]) { + let depth = self.depth; + let rate = self.rate; + let wet = self.wet; + let dry = 1.0 - wet; + let cap = self.cap as f32; + + for s in mono.iter_mut() { + self.delay[self.idx] = *s; + let lfo = (2.0 * PI * self.phase).sin(); + + // Two slightly dephased voices. + let d1 = depth * (0.35 + 0.35 * lfo); + let d2 = depth * (0.35 - 0.35 * lfo); + let v1 = self.read(d1); + let v2 = self.read(d2); + + *s = *s * dry + (v1 + v2) * 0.5 * wet; + + self.idx += 1; + if self.idx as f32 >= cap { + self.idx = 0; + } + self.phase += rate / self.fs; + if self.phase >= 1.0 { + self.phase -= 1.0; + } + } + } +} + +// --------------------------------------------------------------------------- +// Ring modulator +// --------------------------------------------------------------------------- + +pub struct RingMod { + freq: f32, + depth: f32, + phase: f32, + fs: f32, +} + +impl RingMod { + pub fn new(sample_rate: u32, freq_hz: f32, depth: f32) -> Self { + Self { + freq: freq_hz, + depth: depth.clamp(0.0, 1.0), + phase: 0.0, + fs: sample_rate as f32, + } + } +} + +impl Effect for RingMod { + fn process(&mut self, mono: &mut [f32]) { + let depth = self.depth; + for s in mono.iter_mut() { + let m = 1.0 - depth + depth * (2.0 * PI * self.phase).sin(); + *s *= m; + self.phase += self.freq / self.fs; + if self.phase >= 1.0 { + self.phase -= 1.0; + } + } + } +} + +// --------------------------------------------------------------------------- +// Biquad filters (RBJ cookbook) for bandpass / narrow EQ +// --------------------------------------------------------------------------- + +pub struct Biquad { + b0: f32, + b1: f32, + b2: f32, + a1: f32, + a2: f32, + z1: f32, + z2: f32, +} + +impl Biquad { + /// Constant-skirt bandpass. + pub fn bandpass(sample_rate: u32, f0: f32, q: f32) -> Self { + let w0 = 2.0 * PI * f0 / sample_rate as f32; + let alpha = w0.sin() / (2.0 * q); + let b0 = alpha; + let b1 = 0.0; + let b2 = -alpha; + let a0 = 1.0 + alpha; + let a1 = -2.0 * w0.cos(); + let a2 = 1.0 - alpha; + Self::normalized(b0, b1, b2, a1, a2, a0) + } + + fn normalized(b0: f32, b1: f32, b2: f32, a1: f32, a2: f32, a0: f32) -> Self { + Self { + b0: b0 / a0, + b1: b1 / a0, + b2: b2 / a0, + a1: a1 / a0, + a2: a2 / a0, + z1: 0.0, + z2: 0.0, + } + } + + #[inline] + pub fn tick(&mut self, x: f32) -> f32 { + let y = self.b0 * x + self.z1; + self.z1 = self.b1 * x - self.a1 * y + self.z2; + self.z2 = self.b2 * x - self.a2 * y; + y + } +} + +pub struct Bandpass { + filter: Biquad, + mix: f32, +} + +impl Bandpass { + pub fn new(sample_rate: u32, low_hz: f32, high_hz: f32, mix: f32) -> Self { + let f0 = (low_hz * high_hz).sqrt(); + let q = f0 / (high_hz - low_hz).max(1.0); + Self { + filter: Biquad::bandpass(sample_rate, f0, q), + mix: mix.clamp(0.0, 1.0), + } + } +} + +impl Effect for Bandpass { + fn process(&mut self, mono: &mut [f32]) { + let mix = self.mix; + for s in mono.iter_mut() { + let y = self.filter.tick(*s); + *s = *s * (1.0 - mix) + y * mix; + } + } +} + +// --------------------------------------------------------------------------- +// White noise generator +// --------------------------------------------------------------------------- + +pub struct Noise { + gain: f32, + state: u32, +} + +impl Noise { + pub fn new(gain: f32) -> Self { + Self { + gain, + state: 0x9E3779B9, + } + } + + #[inline] + fn next(&mut self) -> f32 { + let mut x = self.state; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + self.state = x; + x as f32 / u32::MAX as f32 * 2.0 - 1.0 + } +} + +impl Effect for Noise { + fn process(&mut self, mono: &mut [f32]) { + for s in mono.iter_mut() { + *s += self.next() * self.gain; + } + } +} + +// --------------------------------------------------------------------------- +// Compressor +// --------------------------------------------------------------------------- + +pub struct Compressor { + threshold_db: f32, + ratio: f32, + makeup_db: f32, + env: f32, + gain: f32, + fs: f32, +} + +impl Compressor { + pub fn new(sample_rate: u32, threshold_db: f32, ratio: f32, makeup_db: f32) -> Self { + Self { + threshold_db, + ratio: ratio.max(1.0), + makeup_db, + env: 0.0, + gain: 1.0, + fs: sample_rate as f32, + } + } +} + +impl Effect for Compressor { + fn process(&mut self, mono: &mut [f32]) { + let threshold = 10f32.powf(self.threshold_db / 20.0); + let makeup = 10f32.powf(self.makeup_db / 20.0); + let release = 1.0 / (self.fs * 0.1).max(1.0); // ~100 ms + let attack = 1.0 / (self.fs * 0.002).max(1.0); // ~2 ms + let inv_ratio = 1.0 - 1.0 / self.ratio; + + for s in mono.iter_mut() { + let peak = s.abs(); + self.env *= 1.0 - release; + if peak > self.env { + self.env = self.env + (peak - self.env) * attack; + } + + let over_db = 20.0 * (self.env / threshold.max(1e-9)).log10(); + let target_gain = if over_db > 0.0 { + let g_db = -over_db * inv_ratio; + 10f32.powf(g_db / 20.0) + } else { + 1.0 + }; + self.gain += (target_gain - self.gain) * attack; + *s *= self.gain * makeup; + } + } +} + +// --------------------------------------------------------------------------- +// Channel vocoder (classic robot voice) +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy, PartialEq, Debug)] +pub enum CarrierKind { + Noise, + Saw, +} + +pub struct ChannelVocoder { + bands: Vec, + carrier: CarrierKind, + bands_count: usize, + wet: f32, + phase: f32, + noise_state: u32, + fs: f32, +} + +struct VocoderBand { + analysis: Biquad, + synthesis: Biquad, + env: f32, +} + +impl ChannelVocoder { + pub fn new(sample_rate: u32, bands_count: usize, carrier: CarrierKind, wet: f32) -> Self { + let fs = sample_rate as f32; + let low: f32 = 150.0; + let high: f32 = 4500.0; + let n = bands_count.max(2); + let mut bands = Vec::with_capacity(n); + for i in 0..n { + let f = low * (high / low).powf(i as f32 / (n - 1) as f32); + let f0: f32 = f; + let q = 8.0; + let analysis = Biquad::bandpass(sample_rate, f0, q); + let synthesis = Biquad::bandpass(sample_rate, f0, q); + bands.push(VocoderBand { + analysis, + synthesis, + env: 0.0, + }); + } + Self { + bands, + carrier, + bands_count: n, + wet: wet.clamp(0.0, 1.0), + phase: 0.0, + noise_state: 0x2545F491, + fs, + } + } + + #[inline] + fn carrier_sample(&mut self) -> f32 { + match self.carrier { + CarrierKind::Noise => { + let mut x = self.noise_state; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + self.noise_state = x; + x as f32 / u32::MAX as f32 * 2.0 - 1.0 + } + CarrierKind::Saw => { + let s = self.phase * 2.0 - 1.0; + self.phase += 1.0 / self.fs * 120.0; + if self.phase >= 1.0 { + self.phase -= 1.0; + } + s + } + } + } +} + +impl Effect for ChannelVocoder { + fn process(&mut self, mono: &mut [f32]) { + let wet = self.wet; + let dry = 1.0 - wet; + let attack = 0.06; + let release = 0.0015; + let inv_n = 1.0 / self.bands_count as f32; + + for s in mono.iter_mut() { + let input = *s; + let carrier = self.carrier_sample(); + + let mut acc = 0.0; + for band in &mut self.bands { + let a = band.analysis.tick(input); + let a_env = a.abs(); + if a_env > band.env { + band.env += (a_env - band.env) * attack; + } else { + band.env += (a_env - band.env) * release; + } + acc += band.synthesis.tick(carrier) * band.env; + } + + *s = input * dry + acc * wet * inv_n * 4.0; + } + } +} diff --git a/src/dsp/formant.rs b/src/dsp/formant.rs new file mode 100644 index 0000000..d370f1f --- /dev/null +++ b/src/dsp/formant.rs @@ -0,0 +1,12 @@ +//! Formant shifting helpers. +//! +//! Formant shifting (changing the vocal-tract resonances without changing +//! pitch) is performed by the phase vocoder in [`crate::dsp::pitch`] via its +//! `formant_ratio` parameter. This module holds the tiny helper used to convert +//! a manual formant adjustment in semitones into the ratio the vocoder expects. + +/// Convert a formant adjustment in semitones into a ratio factor +/// (1.0 = unchanged, >1 = higher/brighter). +pub fn ratio_from_semitones(semitones: f32) -> f32 { + 2f32.powf(semitones / 12.0) +} diff --git a/src/dsp/gate.rs b/src/dsp/gate.rs new file mode 100644 index 0000000..50b3b87 --- /dev/null +++ b/src/dsp/gate.rs @@ -0,0 +1,64 @@ +//! Noise gate: suppresses quiet segments below the threshold with a short +//! attack / longer release and a 6 dB hysteresis to avoid chatter. + +/// Convert dB to linear amplitude. +pub fn db_to_lin(db: f32) -> f32 { + 10f32.powf(db / 20.0) +} + +pub struct NoiseGate { + pub threshold_db: f32, + level: f32, + gain: f32, + open: bool, +} + +impl NoiseGate { + pub fn new(threshold_db: f32) -> Self { + Self { + threshold_db, + level: 0.0, + gain: 0.0, + open: false, + } + } + + pub fn set_threshold_db(&mut self, db: f32) { + self.threshold_db = db; + } + + pub fn process(&mut self, block: &mut [f32]) { + if block.is_empty() { + return; + } + + let mut sum = 0.0; + for &s in block.iter() { + sum += s * s; + } + let rms = (sum / block.len() as f32).sqrt(); + + // Envelope follower: fast attack, slow release. + if rms > self.level { + self.level += (rms - self.level) * 0.5; + } else { + self.level += (rms - self.level) * 0.02; + } + + let threshold = db_to_lin(self.threshold_db); + + // Hysteresis: open above threshold, close 6 dB below it. + if self.level > threshold { + self.open = true; + } else if self.level < threshold * 0.5 { + self.open = false; + } + + let target = if self.open { 1.0 } else { 0.0 }; + for s in block.iter_mut() { + // Per-sample smoothing so the gate never clicks. + self.gain += (target - self.gain) * 0.15; + *s *= self.gain; + } + } +} diff --git a/src/dsp/mod.rs b/src/dsp/mod.rs new file mode 100644 index 0000000..a1346e8 --- /dev/null +++ b/src/dsp/mod.rs @@ -0,0 +1,7 @@ +pub mod chain; +pub mod effects; +pub mod formant; +pub mod gate; +pub mod pitch; +pub mod presets; +pub mod stats; diff --git a/src/dsp/pitch.rs b/src/dsp/pitch.rs new file mode 100644 index 0000000..0521123 --- /dev/null +++ b/src/dsp/pitch.rs @@ -0,0 +1,416 @@ +//! Real-time pitch shifting via phase vocoder. +//! +//! The phase vocoder resamples the spectral *residual* (fine structure) to +//! change pitch while keeping the spectral envelope (formants) intact, and +//! independently resamples the envelope to change formants. This gives us a +//! single FFT-based stage that drives both the pitch and formant controls. +//! +//! Latency is one FFT frame (default 1024 samples ≈ 21 ms @ 48 kHz). +//! Hann window with 75% overlap (hop = N/4) is a valid COLA window, so the +//! overlap-add reconstruction is lossless in the bypass case. + +use rustfft::num_complex::Complex; +use rustfft::{Fft, FftPlanner}; +use std::sync::Arc; + +/// Linear interpolation over `arr` at fractional position `pos`. +fn interpolate(arr: &[f32], pos: f32) -> f32 { + let max = arr.len() as f32 - 1.0; + let pos = pos.clamp(0.0, max); + let i = pos.floor() as usize; + let j = (i + 1).min(arr.len() - 1); + let t = pos - i as f32; + arr[i] * (1.0 - t) + arr[j] * t +} + +/// Wrap a phase difference into (-PI, PI]. +fn princarg(x: f32) -> f32 { + x - 2.0 * std::f32::consts::PI * (x / (2.0 * std::f32::consts::PI)).round() +} + +pub struct PitchShifter { + n: usize, + hop: usize, + + window: Vec, + norm: f32, + + fft: Arc>, + ifft: Arc>, + env_ifft: Arc>, + env_fft: Arc>, + + in_buf: Vec, + out_buf: Vec, + + spec: Vec>, + mag: Vec, + phase: Vec, + logmag: Vec, + env: Vec, + cep: Vec>, + prev_phase: Vec, + out_phase: Vec, + + /// Quefrency bins kept by the cepstral lifter (spectral envelope cutoff). + lifter_len: usize, + + /// Pitch factor: output pitch = input pitch * ratio (1.0 = unchanged). + pub pitch_ratio: f32, + /// Formant factor: output formants = input formants * ratio (1.0 = unchanged). + pub formant_ratio: f32, +} + +impl PitchShifter { + pub fn new(fft_size: usize, hop: usize, sample_rate: u32) -> Self { + debug_assert!(fft_size.is_multiple_of(hop)); + let frames = fft_size / hop; + + let mut window = vec![0.0; fft_size]; + for (i, w) in window.iter_mut().enumerate() { + *w = + 0.5 - 0.5 * (2.0 * std::f32::consts::PI * i as f32 / (fft_size as f32 - 1.0)).cos(); + } + + // Steady-state OLA normalization: every output sample is covered by + // `frames` overlapping windows; sum of w^2 over those offsets. + let mut norm = 0.0; + for i in 0..frames { + let idx = i * hop; + norm += window[idx] * window[idx]; + } + debug_assert!(norm > 0.0); + + let mut planner = FftPlanner::::new(); + let fft = planner.plan_fft_forward(fft_size); + let ifft = planner.plan_fft_inverse(fft_size); + let env_ifft = planner.plan_fft_inverse(fft_size); + let env_fft = planner.plan_fft_forward(fft_size); + + let _ = sample_rate; + + Self { + n: fft_size, + hop, + window, + norm, + fft, + ifft, + env_ifft, + env_fft, + in_buf: Vec::new(), + out_buf: vec![0.0; fft_size], + spec: vec![Complex::new(0.0, 0.0); fft_size], + mag: vec![0.0; fft_size / 2 + 1], + phase: vec![0.0; fft_size / 2 + 1], + logmag: vec![0.0; fft_size / 2 + 1], + + env: vec![0.0; fft_size / 2 + 1], + cep: vec![Complex::new(0.0, 0.0); fft_size], + prev_phase: vec![0.0; fft_size / 2 + 1], + out_phase: vec![0.0; fft_size / 2 + 1], + lifter_len: fft_size / 8, + pitch_ratio: 1.0, + formant_ratio: 1.0, + } + } + + /// Stream `input` through the shifter, appending the same number of + /// processed samples to `output` (length is preserved). + pub fn process(&mut self, input: &[f32], output: &mut Vec) { + for &sample in input { + self.in_buf.push(sample); + if self.in_buf.len() == self.n { + self.process_frame(); + self.in_buf.drain(..self.hop); + output.extend_from_slice(&self.out_buf[..self.hop]); + self.out_buf.drain(..self.hop); + self.out_buf.resize(self.n, 0.0); + } + } + } + + fn process_frame(&mut self) { + let n2 = self.n / 2; + + // Analysis: window + forward FFT. + for (i, c) in self.spec.iter_mut().enumerate() { + *c = Complex::new(self.in_buf[i] * self.window[i], 0.0); + } + self.fft.process(&mut self.spec); + + for i in 0..=n2 { + let c = self.spec[i]; + let m = c.norm(); + self.mag[i] = m; + self.phase[i] = c.arg(); + self.logmag[i] = (m + 1e-12).ln(); + } + + // Cepstral spectral-envelope estimation (used for formant control). + self.compute_envelope(); + + let pr = self.pitch_ratio; + let fr = self.formant_ratio; + let p_active = (pr - 1.0).abs() > 1e-4; + let f_active = (fr - 1.0).abs() > 1e-4; + + if p_active || f_active { + // Phase propagation (Laroche & Dolson): track the instantaneous + // frequency of each input bin and advance the output phase so the + // reconstructed components stay coherent. + let hop = self.hop as f32; + let nf = self.n as f32; + let mut new_mag = vec![0.0; n2 + 1]; + let mut new_phase = vec![0.0; n2 + 1]; + for (b, nm) in new_mag.iter_mut().enumerate() { + let bf = b as f32; + + // Source input bin for output bin b under the pitch shift. + let src = if p_active { bf / pr } else { bf }; + let base = interpolate(&self.mag, src); + + // Formants: re-weight the envelope so its shape is shifted by + // `fr` independent of pitch. After the pitch resample the + // envelope sits at E[bf/pr]; we move it to E[bf/fr]. + if f_active { + let num = interpolate(&self.env, bf / fr); + let den = interpolate(&self.env, bf / pr); + let ratio = (num / den.max(1e-9)).clamp(0.3, 3.3); + *nm = base * ratio; + } else { + *nm = base; + } + + // Instantaneous frequency of the source bin (rad per frame). + let si = src.round().clamp(0.0, n2 as f32) as usize; + let omega_src = 2.0 * std::f32::consts::PI * si as f32 / nf; + let diff = princarg(self.phase[si] - self.prev_phase[si] - omega_src * hop); + let inst = omega_src + diff / hop; + + // Output phase advances by the (pitch-shifted) source frequency. + let advance = if p_active { + inst * hop * pr + } else { + inst * hop + }; + self.out_phase[b] += advance; + new_phase[b] = self.out_phase[b]; + + self.prev_phase[si] = self.phase[si]; + } + + // Rebuild the conjugate-symmetric spectrum. + for (b, (&nm, &ph)) in new_mag.iter().zip(new_phase.iter()).enumerate() { + self.spec[b] = Complex::from_polar(nm, ph); + if b > 0 && b < n2 { + self.spec[self.n - b] = Complex::from_polar(nm, -ph); + } + } + } + + // Synthesis: inverse FFT + window + overlap-add. + self.ifft.process(&mut self.spec); + let scale = 1.0 / (self.n as f32 * self.norm); + for i in 0..self.n { + let y = self.spec[i].re * self.window[i] * scale; + self.out_buf[i] += y; + } + } + + /// Spectral envelope via homomorphic (cepstral) filtering. + fn compute_envelope(&mut self) { + let n2 = self.n / 2; + + // Conjugate-symmetric spectrum of log-magnitude -> real cepstrum. + for i in 0..=n2 { + let l = self.logmag[i]; + self.cep[i] = Complex::new(l, 0.0); + if i > 0 && i < n2 { + self.cep[self.n - i] = Complex::new(l, 0.0); + } + } + self.env_ifft.process(&mut self.cep); + let inv = 1.0 / self.n as f32; + + // Lifter: keep low quefrencies (smooth envelope), zero the rest. + for (i, c) in self.cep.iter_mut().enumerate() { + let keep = if i <= self.lifter_len { 1.0 } else { 0.0 }; + c.re *= keep * inv; + c.im = 0.0; + } + self.env_fft.process(&mut self.cep); + + for i in 0..=n2 { + self.env[i] = self.cep[i].re.exp().max(1e-6); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sine(freq: f32, fs: u32, samples: usize) -> Vec { + (0..samples) + .map(|i| (2.0 * std::f32::consts::PI * freq * i as f32 / fs as f32).sin()) + .collect() + } + + /// Harmonic-rich tone (sawtooth): narrow pitch lines + broad envelope, + /// closer to a real voice than a pure sine. + fn saw(freq: f32, fs: u32, samples: usize) -> Vec { + (0..samples) + .map(|i| { + let t = freq * i as f32 / fs as f32; + let mut s = 0.0; + for h in 1..=12 { + s += ((2.0 * std::f32::consts::PI * h as f32 * t).sin()) / h as f32; + } + s + }) + .collect() + } + + fn rms(x: &[f32]) -> f32 { + let s: f32 = x.iter().map(|v| v * v).sum(); + (s / x.len().max(1) as f32).sqrt() + } + + fn dominant_hz(x: &[f32], fs: u32) -> f32 { + use rustfft::FftPlanner; + let n = 4096; + if x.len() < n { + return 0.0; + } + let mut planner = FftPlanner::::new(); + let fft = planner.plan_fft_forward(n); + let mut buf: Vec> = x[..n].iter().map(|&s| Complex::new(s, 0.0)).collect(); + fft.process(&mut buf); + let bins = n / 2; + let mags: Vec = (0..bins).map(|b| buf[b].norm()).collect(); + // Harmonic product sum: find the fundamental whose harmonics add up. + let mut best = (0usize, 0.0f32); + for b in 20..(bins / 4) { + let mut s = 0.0; + for h in 1..=4 { + if b * h < bins { + s += mags[b * h]; + } + } + if s > best.1 { + best = (b, s); + } + } + fs as f32 * best.0 as f32 / n as f32 + } + + #[test] + fn bypass_preserves_signal() { + let mut sh = PitchShifter::new(1024, 256, 48000); + let input = saw(220.0, 48000, 48000); + let mut out = Vec::new(); + sh.process(&input, &mut out); + assert!(out.len() >= 47000, "out.len={}", out.len()); + assert!(rms(&out[1000..]).is_finite()); + assert!( + rms(&out[1000..]) > 0.05, + "bypass rms too low: {}", + rms(&out[1000..]) + ); + + use rustfft::FftPlanner; + let n = 4096; + let seg = &out[2000..2000 + n]; + let mut planner = FftPlanner::::new(); + let fft = planner.plan_fft_forward(n); + let mut buf: Vec> = seg.iter().map(|&s| Complex::new(s, 0.0)).collect(); + fft.process(&mut buf); + let mut peaks: Vec<(usize, f32)> = (0..n / 2).map(|b| (b, buf[b].norm())).collect(); + peaks.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap()); + let top: Vec = peaks + .iter() + .take(5) + .map(|(b, m)| format!("{:.0}Hz({m:.0})", b * 48000 / n)) + .collect(); + eprintln!("bypass peaks: {}", top.join(", ")); + } + + #[test] + fn pitch_shift_produces_energy() { + let mut sh = PitchShifter::new(1024, 256, 48000); + sh.pitch_ratio = 2f32.powf(5.0 / 12.0); + let input = saw(220.0, 48000, 48000); + let mut out = Vec::new(); + sh.process(&input, &mut out); + assert!( + out.iter().take(20000).all(|v| v.is_finite()), + "non-finite output" + ); + let r = rms(&out[1000..20000]); + assert!(r > 0.05, "pitch-shifted rms too low: {r}"); + + let est = dominant_hz(&out[1000..20000], 48000); + assert!( + (est - 293.7).abs() < 8.0, + "expected ~293.7 Hz for 220*2^(5/12), got {est:.1} Hz" + ); + } + + #[test] + fn block_feed_matches_chain() { + // Mimics dsp::chain's block-by-block feeding with a pending buffer. + let mut sh = PitchShifter::new(1024, 256, 48000); + sh.pitch_ratio = 2f32.powf(5.0 / 12.0); + let input = sine(220.0, 48000, 48000); + let block = 2560; + let mut pending = Vec::new(); + let mut out_all = Vec::new(); + for chunk in input.chunks(block) { + let mut out = Vec::new(); + sh.process(chunk, &mut out); + pending.extend(out); + let take = chunk.len().min(pending.len()); + let mut mono = vec![0.0; chunk.len()]; + mono[..take].copy_from_slice(&pending[..take]); + out_all.extend(mono); + pending.drain(..take); + } + assert!(out_all.iter().all(|v| v.is_finite()), "non-finite"); + let r = rms(&out_all[4000..40000]); + assert!(r > 0.05, "block-feed rms too low: {r}"); + } + + #[test] + fn one_shot_vs_block_fed() { + let mut a = PitchShifter::new(1024, 256, 48000); + a.pitch_ratio = 2f32.powf(5.0 / 12.0); + let input = saw(220.0, 48000, 48000); + let mut out_a = Vec::new(); + a.process(&input, &mut out_a); + + // Block-fed WITHOUT prime/pending, just concatenating shifter output. + let mut b = PitchShifter::new(1024, 256, 48000); + b.pitch_ratio = 2f32.powf(5.0 / 12.0); + let mut out_b = Vec::new(); + for chunk in input.chunks(1024) { + b.process(chunk, &mut out_b); + } + let n = out_a.len().min(out_b.len()); + assert!(n > 10000, "n={n}"); + let mut max_diff = 0.0f32; + let mut idx = 0usize; + for i in 3000..n { + let d = (out_a[i] - out_b[i]).abs(); + if d > max_diff { + max_diff = d; + idx = i; + } + } + eprintln!("max diff {max_diff} at {idx} of {n}"); + assert!( + max_diff < 1e-3, + "block-fed differs from one-shot: max_diff={max_diff}" + ); + } +} diff --git a/src/dsp/presets.rs b/src/dsp/presets.rs new file mode 100644 index 0000000..6536d91 --- /dev/null +++ b/src/dsp/presets.rs @@ -0,0 +1,298 @@ +//! Preset voice settings: pitch, formant and an effect chain. + +use crate::dsp::effects::{self, CarrierKind, Effect}; + +pub struct Preset { + pub name: &'static str, + /// Pitch shift in semitones (positive = higher). + pub pitch_semitones: f32, + /// Formant ratio (1.0 = unchanged, >1 = higher/brighter). + pub formant_ratio: f32, + /// Effects applied in order after pitch/formant. + pub effects: &'static [EffectSpec], +} + +#[derive(Clone, Copy, Debug)] +pub enum EffectSpec { + Distortion { + drive: f32, + output: f32, + }, + Bitcrush { + bits: u8, + sample_rate_div: u32, + max_gain: f32, + target: f32, + }, + Reverb { + room: f32, + damp: f32, + wet: f32, + }, + Chorus { + depth_ms: f32, + rate_hz: f32, + wet: f32, + }, + RingMod { + freq_hz: f32, + depth: f32, + }, + Bandpass { + low_hz: f32, + high_hz: f32, + mix: f32, + }, + Noise { + gain: f32, + }, + Compressor { + threshold_db: f32, + ratio: f32, + makeup_db: f32, + }, + Vocoder { + bands: usize, + carrier: CarrierKind, + wet: f32, + }, +} + +pub const PRESETS: &[Preset] = &[ + Preset { + name: "Clean", + pitch_semitones: 0.0, + formant_ratio: 1.0, + effects: &[], + }, + Preset { + name: "Girl / Anime", + pitch_semitones: 5.0, + formant_ratio: 1.25, + effects: &[], + }, + Preset { + name: "Boy", + pitch_semitones: 3.0, + formant_ratio: 1.12, + effects: &[], + }, + Preset { + name: "Manly / Deep", + pitch_semitones: -4.0, + formant_ratio: 0.78, + effects: &[EffectSpec::Compressor { + threshold_db: -20.0, + ratio: 3.0, + makeup_db: 2.0, + }], + }, + Preset { + name: "Demon", + pitch_semitones: -9.0, + formant_ratio: 0.85, + effects: &[ + EffectSpec::Distortion { + drive: 6.0, + output: 0.7, + }, + EffectSpec::Reverb { + room: 0.6, + damp: 0.4, + wet: 0.4, + }, + ], + }, + Preset { + name: "Robot", + pitch_semitones: 0.0, + formant_ratio: 1.0, + effects: &[ + EffectSpec::Vocoder { + bands: 16, + carrier: CarrierKind::Noise, + wet: 1.0, + }, + EffectSpec::Bitcrush { + bits: 8, + sample_rate_div: 2, + max_gain: 8.0, + target: 0.6, + }, + ], + }, + Preset { + name: "8-bit", + pitch_semitones: 0.0, + formant_ratio: 1.0, + effects: &[EffectSpec::Bitcrush { + bits: 5, + sample_rate_div: 4, + max_gain: 32.0, + target: 0.6, + }], + }, + Preset { + name: "Alien", + pitch_semitones: -2.0, + formant_ratio: 1.15, + effects: &[ + EffectSpec::RingMod { + freq_hz: 55.0, + depth: 0.5, + }, + EffectSpec::Chorus { + depth_ms: 6.0, + rate_hz: 0.5, + wet: 0.4, + }, + ], + }, + Preset { + name: "Radio", + pitch_semitones: 0.0, + formant_ratio: 1.0, + effects: &[ + EffectSpec::Bandpass { + low_hz: 300.0, + high_hz: 3000.0, + mix: 0.9, + }, + EffectSpec::Noise { gain: 0.04 }, + EffectSpec::Compressor { + threshold_db: -24.0, + ratio: 4.0, + makeup_db: 6.0, + }, + ], + }, + Preset { + name: "Megaphone", + pitch_semitones: 0.0, + formant_ratio: 1.0, + effects: &[ + EffectSpec::Distortion { + drive: 2.5, + output: 0.9, + }, + EffectSpec::Bandpass { + low_hz: 800.0, + high_hz: 2600.0, + mix: 1.0, + }, + EffectSpec::Compressor { + threshold_db: -18.0, + ratio: 5.0, + makeup_db: 4.0, + }, + ], + }, + Preset { + name: "Ghost", + pitch_semitones: -3.0, + formant_ratio: 1.05, + effects: &[ + EffectSpec::Reverb { + room: 0.95, + damp: 0.2, + wet: 0.8, + }, + EffectSpec::Chorus { + depth_ms: 8.0, + rate_hz: 0.4, + wet: 0.5, + }, + ], + }, + Preset { + name: "Cyborg", + pitch_semitones: 2.0, + formant_ratio: 1.0, + effects: &[ + EffectSpec::Vocoder { + bands: 16, + carrier: CarrierKind::Saw, + wet: 0.9, + }, + EffectSpec::Bitcrush { + bits: 8, + sample_rate_div: 2, + max_gain: 8.0, + target: 0.6, + }, + ], + }, +]; + +impl Preset { + pub fn find(name: &str) -> Option { + PRESETS.iter().position(|p| { + p.name.eq_ignore_ascii_case(name) + || p.name + .split('/') + .next() + .map(|s| s.trim().eq_ignore_ascii_case(name)) + .unwrap_or(false) + }) + } +} + +pub fn build_effects(specs: &[EffectSpec], sample_rate: u32) -> Vec> { + specs.iter().map(|s| build_effect(s, sample_rate)).collect() +} + +fn build_effect(spec: &EffectSpec, sample_rate: u32) -> Box { + match spec { + EffectSpec::Distortion { drive, output } => { + Box::new(effects::Distortion::new(*drive, *output)) + } + EffectSpec::Bitcrush { + bits, + sample_rate_div, + max_gain, + target, + } => Box::new(effects::Bitcrush::new( + *bits, + *sample_rate_div, + *max_gain, + *target, + )), + EffectSpec::Reverb { room, damp, wet } => { + Box::new(effects::Reverb::new(sample_rate, *room, *damp, *wet)) + } + EffectSpec::Chorus { + depth_ms, + rate_hz, + wet, + } => Box::new(effects::Chorus::new(sample_rate, *depth_ms, *rate_hz, *wet)), + EffectSpec::RingMod { freq_hz, depth } => { + Box::new(effects::RingMod::new(sample_rate, *freq_hz, *depth)) + } + EffectSpec::Bandpass { + low_hz, + high_hz, + mix, + } => Box::new(effects::Bandpass::new(sample_rate, *low_hz, *high_hz, *mix)), + EffectSpec::Noise { gain } => Box::new(effects::Noise::new(*gain)), + EffectSpec::Compressor { + threshold_db, + ratio, + makeup_db, + } => Box::new(effects::Compressor::new( + sample_rate, + *threshold_db, + *ratio, + *makeup_db, + )), + EffectSpec::Vocoder { + bands, + carrier, + wet, + } => Box::new(effects::ChannelVocoder::new( + sample_rate, + *bands, + *carrier, + *wet, + )), + } +} diff --git a/src/dsp/stats.rs b/src/dsp/stats.rs new file mode 100644 index 0000000..6eb7fc8 --- /dev/null +++ b/src/dsp/stats.rs @@ -0,0 +1,35 @@ +//! Runtime statistics produced by the DSP worker and shown in the TUI. + +use std::time::Instant; + +pub struct Stats { + /// Smoothed input level (RMS, linear). + pub input_rms: f32, + /// Smoothed output level (RMS, linear). + pub output_rms: f32, + pub peak_in: f32, + pub peak_out: f32, + pub blocks: u64, + pub samples: u64, + /// Smoothed DSP load in percent of one core. + pub cpu_pct: f32, + /// Estimated DSP latency in milliseconds. + pub dsp_latency_ms: f32, + pub started: Instant, +} + +impl Default for Stats { + fn default() -> Self { + Self { + input_rms: 0.0, + output_rms: 0.0, + peak_in: 0.0, + peak_out: 0.0, + blocks: 0, + samples: 0, + cpu_pct: 0.0, + dsp_latency_ms: 0.0, + started: Instant::now(), + } + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..904025f --- /dev/null +++ b/src/main.rs @@ -0,0 +1,113 @@ +mod audio; +mod config; +mod dsp; +mod ui; + +use anyhow::Result; +use clap::Parser; + +#[derive(Parser, Debug)] +#[command( + name = "vois", + version, + about = "Real-time voice changer written in pure Rust" +)] +pub struct Cli { + #[arg(short = 'i', long, help = "Input (microphone) device name")] + pub input: Option, + + #[arg(short = 'o', long, help = "Output device name")] + pub output: Option, + + #[arg( + short = 's', + long, + help = "Sample rate in Hz (must be supported by both devices)" + )] + pub sample_rate: Option, + + #[arg(long, help = "List available audio devices and exit")] + pub list: bool, + + #[arg( + short = 'p', + long, + default_value = "Clean", + help = "Starting preset name" + )] + pub preset: String, + + #[arg( + long, + help = "Record processed audio to a WAV file (also toggle with R in the UI)" + )] + pub record: Option, + + #[arg(long, default_value_t = 0.0, help = "Initial pitch shift in semitones")] + pub pitch: f32, + + #[arg( + long, + default_value_t = 0.0, + help = "Initial formant shift in semitones" + )] + pub formant: f32, + + #[arg( + long, + default_value_t = -100.0, + help = "Noise gate threshold in dB (default -100 = off)" + )] + pub gate: f32, + + #[arg(long, default_value_t = 1.0, help = "Output gain (1.0 = unity)")] + pub gain: f32, + + #[arg(long, help = "Exit automatically after this many seconds")] + pub run_seconds: Option, + + #[arg( + long, + default_value_t = 0.0, + help = "Mix a test tone (Hz) into the input; 0 = off. Useful to hear presets without a mic" + )] + pub tone: f32, + + #[arg(long, help = "List available presets and exit")] + pub list_presets: bool, + + #[arg( + long, + help = "Disable the built-in virtual mic (vois.rs); on by default" + )] + pub no_virtual_mic: bool, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + + if cli.list { + audio::devices::print_all()?; + return Ok(()); + } + + if cli.list_presets { + println!("Available presets: {}", config::AppConfig::preset_names()); + return Ok(()); + } + + if !cli.no_virtual_mic { + match audio::virtual_mic::VirtualMic::setup() { + Ok(Some(src)) => { + println!( + "virtual mic ready: use \"{src}\" (or \"vois.rs.monitor\") as your microphone" + ) + } + Ok(None) => println!("virtual mic setup failed: no source reported"), + Err(e) => println!("warning: virtual mic setup failed: {e:#}"), + } + } + + let cfg = config::AppConfig::from_cli(&cli); + audio::run(&cfg) +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..cbc86dd --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,12 @@ +pub mod tui; + +/// Static information about the audio session, shown in the TUI. +pub struct SessionInfo { + pub input: String, + pub output: String, + pub sample_rate: u32, + pub in_ch: usize, + pub out_ch: usize, + pub format_in: String, + pub format_out: String, +} diff --git a/src/ui/tui.rs b/src/ui/tui.rs new file mode 100644 index 0000000..15a1ddf --- /dev/null +++ b/src/ui/tui.rs @@ -0,0 +1,826 @@ +//! Full-screen interactive TUI built on ratatui: splash, dashboard with +//! presets / parameters / levels / stats, and a settings screen with arrow-key +//! and mouse control. + +use crate::dsp::chain::{Control, Mode}; +use crate::dsp::presets::PRESETS; +use crate::dsp::stats::Stats; +use crate::ui::SessionInfo; +use crossterm::event::{ + self, Event, KeyCode, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, +}; +use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Gauge, Paragraph}; +use ratatui::{Frame, Terminal}; +use std::io::{stdout, IsTerminal, Write}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +const LOGO: [&str; 7] = [ + " ██╗ ██╗ ██████╗ ██╗███████╗ ", + " ██║ ██║██╔═══██╗██║██╔════╝ ", + " ██║ ██║██║ ██║██║███████╗ ", + " ╚██╗ ██╔╝██║ ██║██║╚════██║ ", + " ╚████╔╝ ╚██████╔╝██║███████║ ", + " ╚═══╝ ╚═════╝ ╚═╝╚══════╝ ", + " real-time voice changer ", +]; + +const SETTING_INFO: [&str; 8] = [ + "voice character, previewed live", + "pitch: higher = girl/kid, lower = deep", + "formant: brightness / timbre of the voice", + "noise gate: audio below this dB is cut", + "output loudness, in dB", + "silence the output", + "LIVE = processed, PASSTHROUGH = raw input (A/B)", + "write processed audio to a WAV file", +]; + +const HELP_TEXT: &str = "\ + KEY / MOUSE ACTION + ─────────────────────────────────────────────── + ↑ ↓ (or mouse) select preset / settings row + ← → change value + Enter toggle / apply + S / Tab toggle SETTINGS screen + Esc back to main screen + H toggle this help screen + Q / Ctrl+C quit + 0 - 9 jump to preset + M mute R record + B A/B passthrough mode +"; + +const METER_FLOOR_DB: f32 = -60.0; + +fn db(rms: f32) -> f32 { + if rms <= 0.0 { + METER_FLOOR_DB + } else { + (20.0 * rms.log10()).clamp(METER_FLOOR_DB, 6.0) + } +} + +fn meter_ratio(db: f32) -> f64 { + ((db - METER_FLOOR_DB) / -METER_FLOOR_DB).clamp(0.0, 1.0) as f64 +} + +fn meter_style(level_db: f32) -> Style { + if level_db >= -6.0 { + Style::default().fg(Color::Red) + } else if level_db >= -20.0 { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::Green) + } +} + +#[derive(Clone, Copy, PartialEq)] +enum View { + Main, + Settings, + Help, +} + +struct App { + control: Arc>, + stats: Arc>, + info: SessionInfo, + view: View, + settings_cursor: usize, + splash_until: Instant, + started: Instant, + running: bool, +} + +impl App { + /// (preset_idx, preset_name, pitch_st, formant_ratio, gate_db, gain_lin, + /// mute, record, tone, mode) + fn snapshot(&self) -> (usize, String, f32, f32, f32, f32, bool, bool, bool, Mode) { + self.control + .lock() + .map(|c| { + let preset = &PRESETS[c.preset_idx.min(PRESETS.len() - 1)]; + ( + c.preset_idx.min(PRESETS.len() - 1), + preset.name.to_string(), + preset.pitch_semitones + c.pitch_delta, + preset.formant_ratio * 2f32.powf(c.formant_delta / 12.0), + c.gate_threshold_db, + c.gain, + c.mute, + c.record, + c.tone_hz > 0.0, + c.mode, + ) + }) + .unwrap_or(( + 0, + "Clean".into(), + 0.0, + 1.0, + -100.0, + 1.0, + false, + false, + false, + Mode::Live, + )) + } + + fn setting_value(&self, idx: usize) -> String { + let (_, _name, pitch, formant, gate, gain, mute, record, _tone, mode) = self.snapshot(); + match idx { + 0 => PRESETS[self + .control + .lock() + .map(|c| c.preset_idx.min(PRESETS.len() - 1)) + .unwrap_or(0)] + .name + .to_string(), + 1 => format!("{pitch:+.1} st"), + 2 => format!("{formant:.2}×"), + 3 => { + if gate <= -90.0 { + "off".to_string() + } else { + format!("{gate:.0} dB") + } + } + 4 => format!("{:+.1} dB", 20.0 * gain.max(1e-6).log10()), + 5 => if mute { "ON" } else { "off" }.to_string(), + 6 => match mode { + Mode::Live => "LIVE".to_string(), + Mode::Passthrough => "PASSTHROUGH".to_string(), + }, + 7 => if record { "recording" } else { "off" }.to_string(), + _ => String::new(), + } + } + + /// Adjust a settings row. `step` is negative/positive; toggles ignore it. + fn adjust(&self, idx: usize, step: i32) { + self.with_ctrl(|c| match idx { + 0 => { + let n = PRESETS.len() as i32; + c.preset_idx = ((c.preset_idx as i32 + step).rem_euclid(n)) as usize; + } + 1 => c.pitch_delta += step as f32, + 2 => c.formant_delta += step as f32, + 3 => { + c.gate_threshold_db = + (c.gate_threshold_db + step as f32 * 2.0).clamp(-100.0, -20.0); + } + 4 => { + let db = 20.0 * c.gain.max(1e-6).log10() + step as f32; + c.gain = 10f32.powf(db / 20.0).clamp(0.0316, 4.0); // -30 .. +12 dB + } + 5 => c.mute = !c.mute, + 6 => c.mode.toggle(), + 7 => c.record = !c.record, + _ => {} + }); + } + + fn on_key(&mut self, key: crossterm::event::KeyEvent) { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + let quit = key.code == KeyCode::Char('q') + || (ctrl && (key.code == KeyCode::Char('c') || key.code == KeyCode::Char('q'))); + + // Any key dismisses the splash. + if Instant::now() < self.splash_until { + self.splash_until = Instant::now(); + if quit { + self.set_quit(); + } + return; + } + + if quit { + self.set_quit(); + return; + } + + match self.view { + View::Help => { + if key.code == KeyCode::Esc + || key.code == KeyCode::Char('h') + || key.code == KeyCode::Char('H') + { + self.view = View::Main; + } + } + View::Main => self.on_key_main(key), + View::Settings => self.on_key_settings(key), + } + } + + fn on_key_main(&mut self, key: crossterm::event::KeyEvent) { + match key.code { + KeyCode::Up => self + .with_ctrl(|c| c.preset_idx = (c.preset_idx + PRESETS.len() - 1) % PRESETS.len()), + KeyCode::Down => self.with_ctrl(|c| c.preset_idx = (c.preset_idx + 1) % PRESETS.len()), + KeyCode::Left => self.adjust(1, -1), + KeyCode::Right => self.adjust(1, 1), + KeyCode::Char('s') | KeyCode::Char('S') | KeyCode::Tab => self.view = View::Settings, + KeyCode::Char('h') | KeyCode::Char('H') => self.view = View::Help, + KeyCode::Char('p') | KeyCode::Char('P') => { + self.with_ctrl(|c| c.preset_idx = (c.preset_idx + 1) % PRESETS.len()) + } + KeyCode::Char(d) if d.is_ascii_digit() => self.with_ctrl(|c| { + let n = d.to_digit(10).unwrap() as usize; + c.preset_idx = if n == 0 { + PRESETS.len() - 1 + } else { + (n - 1).min(PRESETS.len() - 1) + }; + }), + KeyCode::Char('[') => self.adjust(1, -1), + KeyCode::Char(']') => self.adjust(1, 1), + KeyCode::Char('{') => self.adjust(2, -1), + KeyCode::Char('}') => self.adjust(2, 1), + KeyCode::Char('g') | KeyCode::Char('G') => self.adjust(3, -1), + KeyCode::Char('b') | KeyCode::Char('B') => self.adjust(6, 1), + KeyCode::Char('m') | KeyCode::Char('M') => self.adjust(5, 1), + KeyCode::Char('r') | KeyCode::Char('R') => self.adjust(7, 1), + KeyCode::Char('+') | KeyCode::Char('=') => self.adjust(4, 1), + KeyCode::Char('-') => self.adjust(4, -1), + _ => {} + } + } + + fn on_key_settings(&mut self, key: crossterm::event::KeyEvent) { + match key.code { + KeyCode::Up => self.settings_cursor = self.settings_cursor.saturating_sub(1), + KeyCode::Down => self.settings_cursor = (self.settings_cursor + 1).min(7), + KeyCode::Left => self.adjust(self.settings_cursor, -1), + KeyCode::Right => self.adjust(self.settings_cursor, 1), + KeyCode::Enter => self.adjust(self.settings_cursor, 1), + KeyCode::Esc | KeyCode::Char('s') | KeyCode::Char('S') | KeyCode::Tab => { + self.view = View::Main + } + KeyCode::Char('h') | KeyCode::Char('H') => self.view = View::Help, + KeyCode::Char('q') => {} + _ => {} + } + } + + fn on_mouse(&mut self, mouse: MouseEvent) { + if Instant::now() < self.splash_until { + self.splash_until = Instant::now(); + return; + } + if let MouseEventKind::Down(MouseButton::Left) = mouse.kind { + let area = self.terminal_size(); + match self.view { + View::Main => { + let rects = main_layout(area); + let presets_rect = rects.0; + if mouse.row >= presets_rect.y + && mouse.row < presets_rect.y + presets_rect.height + { + let idx = (mouse.row - presets_rect.y) as usize; + if idx < PRESETS.len() { + self.with_ctrl(|c| c.preset_idx = idx); + } + } + } + View::Settings => { + let rects = settings_layout(area); + let list_rect = rects.0; + if mouse.row >= list_rect.y && mouse.row < list_rect.y + list_rect.height { + let idx = (mouse.row - list_rect.y) as usize; + if idx < 8 { + self.settings_cursor = idx; + } + } + } + View::Help => self.view = View::Main, + } + } + } + + fn terminal_size(&self) -> Rect { + crossterm::terminal::size() + .map(|(w, h)| Rect::new(0, 0, w, h)) + .unwrap_or(Rect::new(0, 0, 100, 30)) + } + + fn set_quit(&mut self) { + self.with_ctrl(|c| c.quit = true); + self.running = false; + } + + fn with_ctrl(&self, f: impl FnOnce(&mut Control)) { + if let Ok(mut c) = self.control.lock() { + f(&mut c); + } + } + + fn render(&mut self, f: &mut Frame) { + if Instant::now() < self.splash_until { + self.render_splash(f); + return; + } + match self.view { + View::Main => self.render_main(f), + View::Settings => self.render_settings(f), + View::Help => self.render_help(f), + } + } + + fn render_splash(&self, f: &mut Frame) { + let area = f.area(); + let mut lines: Vec = Vec::new(); + for (i, l) in LOGO.iter().enumerate() { + let color = match i { + 0 | 1 => Color::Cyan, + 2 | 3 => Color::Magenta, + 4 | 5 => Color::Blue, + _ => Color::Gray, + }; + lines.push(Line::styled( + l.to_string(), + Style::default().fg(color).add_modifier(Modifier::BOLD), + )); + } + lines.push(Line::from("")); + lines.push(Line::from( + " press any key, click, or use the arrow keys ...", + )); + lines.push(Line::styled( + " audio is already running", + Style::default().fg(Color::DarkGray), + )); + let para = Paragraph::new(lines).alignment(ratatui::layout::Alignment::Center); + // Center in the full area, but never taller than the terminal. + let rect = centered_rect(70, 55, area); + f.render_widget(para, rect); + } + + fn render_help(&self, f: &mut Frame) { + let para = Paragraph::new(HELP_TEXT) + .style(Style::default().fg(Color::White)) + .block(block(" vois · help ")); + f.render_widget(para, f.area()); + } + + fn render_header(&self, f: &mut Frame, area: Rect) { + let (_, _name, _p, _f, _g, _gain, mute, record, tone, mode) = self.snapshot(); + let mode_txt = match mode { + Mode::Live => "LIVE", + Mode::Passthrough => "A/B PASSTHROUGH", + }; + let rec_txt = if record { "● REC" } else { "" }; + let tone_txt = if tone { "● TONE" } else { "" }; + let mute_txt = if mute { "MUTED" } else { "" }; + let header = Line::from(vec![ + Span::styled( + " VOIS ", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + "real-time voice changer", + Style::default() + .fg(Color::Gray) + .add_modifier(Modifier::ITALIC), + ), + Span::raw(" · "), + Span::styled( + mode_txt, + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled( + rec_txt, + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled(tone_txt, Style::default().fg(Color::Blue)), + Span::raw(" "), + Span::styled( + mute_txt, + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ), + ]); + f.render_widget(Paragraph::new(header), area); + } + + fn render_footer(&self, f: &mut Frame, area: Rect, hint: &str) { + let footer = Line::from(Span::styled(hint, Style::default().fg(Color::DarkGray))); + f.render_widget(Paragraph::new(footer), area); + } + + fn render_main(&mut self, f: &mut Frame) { + let area = f.area(); + let rects = main_layout(area); + let (head, body, foot) = (rects.0, rects.1, rects.2); + + self.render_header(f, head); + + let (preset_idx, _name, pitch, formant, gate, gain, mute, _record, _tone, _mode) = + self.snapshot(); + + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(38), + Constraint::Percentage(28), + Constraint::Percentage(34), + ]) + .split(body); + + // Presets (navigable, live preview). + let mut plines = Vec::new(); + for (i, p) in PRESETS.iter().enumerate() { + let selected = i == preset_idx; + let label = format!( + "{}{} {}", + if selected { "▶" } else { " " }, + if selected { "" } else { " " }, + p.name + ); + let style = if selected { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Gray) + }; + plines.push(Line::styled(label, style)); + } + let para = Paragraph::new(plines).block(block(" presets · ↑↓ to switch ")); + f.render_widget(para, cols[0]); + + // Parameters + info. + let gate_txt = if gate <= -90.0 { + "off".to_string() + } else { + format!("{gate:.0} dB") + }; + let gain_db = 20.0 * gain.max(1e-6).log10(); + let param_lines = vec![ + p_row("preset", &_name, Color::Yellow, "voice character"), + p_row( + "pitch", + &format!("{pitch:+.1} st"), + Color::Cyan, + "higher=girl, lower=deep", + ), + p_row( + "formant", + &format!("{formant:.2}×"), + Color::Magenta, + "brightness/timbre", + ), + p_row("gate", &gate_txt, Color::Blue, "noise below this dB is cut"), + p_row( + "gain", + &format!("{gain_db:+.1} dB"), + Color::Green, + "output loudness", + ), + p_row( + "mute", + if mute { "ON" } else { "off" }, + if mute { Color::Red } else { Color::DarkGray }, + "silence output", + ), + ]; + let para = Paragraph::new(param_lines).block(block(" parameters · info ")); + f.render_widget(para, cols[1]); + + self.render_stats(f, cols[2]); + + self.render_footer( + f, + foot, + " ↑↓ preset | ←→ pitch | S settings | H help | Q quit ", + ); + } + + fn render_settings(&mut self, f: &mut Frame) { + let area = f.area(); + let rects = settings_layout(area); + let (head, list_rect, foot) = (rects.0, rects.1, rects.2); + + self.render_header(f, head); + + let labels = [ + "Preset", + "Pitch", + "Formant", + "Gate (min)", + "Gain", + "Mute", + "Mode", + "Record", + ]; + let mut lines = Vec::new(); + for (i, label) in labels.iter().enumerate() { + let value = self.setting_value(i); + let selected = i == self.settings_cursor; + let style = if selected { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Gray) + }; + let info_style = Style::default().fg(Color::DarkGray); + lines.push(Line::from(vec![ + Span::styled( + format!( + "{}{} {:<12}", + if selected { "▶" } else { " " }, + if selected { "" } else { " " }, + label + ), + style, + ), + Span::styled(format!("{:<16}", value), style), + Span::styled(SETTING_INFO[i], info_style), + ])); + } + lines.push(Line::from("")); + lines.push(Line::styled( + " use ↑ ↓ to select, ← → (or Enter) to change, Esc to go back", + Style::default().fg(Color::DarkGray), + )); + let para = Paragraph::new(lines).block(block(" settings ")); + f.render_widget(para, list_rect); + + self.render_footer( + f, + foot, + " ↑↓ select | ←→ change | Esc back | H help | Q quit ", + ); + } + + fn render_stats(&self, f: &mut Frame, area: Rect) { + let s = self + .stats + .lock() + .map(|s| { + ( + s.input_rms, + s.output_rms, + s.peak_in, + s.peak_out, + s.blocks, + s.samples, + s.cpu_pct, + s.dsp_latency_ms, + s.started, + ) + }) + .unwrap_or((0.0, 0.0, 0.0, 0.0, 0, 0, 0.0, 0.0, Instant::now())); + let (in_rms, out_rms, peak_in, peak_out, blocks, samples, cpu, latency, started) = s; + + let in_db = db(in_rms); + let out_db = db(out_rms); + let peak_in_db = db(peak_in); + let peak_out_db = db(peak_out); + + let uptime = started.elapsed().as_secs(); + let up = format!( + "{:02}:{:02}:{:02}", + uptime / 3600, + (uptime / 60) % 60, + uptime % 60 + ); + let secs = started.elapsed().as_secs_f32().max(0.1); + let hz = samples as f32 / secs; + + let lines = vec![ + Line::from(""), + Line::from(vec![ + Span::styled(" peak in ", Style::default().fg(Color::DarkGray)), + Span::styled(format!("{peak_in_db:+.0} dB"), meter_style(peak_in_db)), + Span::styled(" peak out ", Style::default().fg(Color::DarkGray)), + Span::styled(format!("{peak_out_db:+.0} dB"), meter_style(peak_out_db)), + ]), + Line::from(""), + Line::from(vec![ + Span::styled(" dsp load ", Style::default().fg(Color::DarkGray)), + Span::styled( + format!("{cpu:.1} %"), + if cpu > 50.0 { + Style::default().fg(Color::Red) + } else { + Style::default().fg(Color::Green) + }, + ), + Span::raw(" "), + Span::styled("latency", Style::default().fg(Color::DarkGray)), + Span::raw(format!(" {latency:.0} ms")), + ]), + Line::from(vec![ + Span::styled(" rate ", Style::default().fg(Color::DarkGray)), + Span::raw(format!( + "{} Hz {} ch → {} ch ({} → {})", + self.info.sample_rate, + self.info.in_ch, + self.info.out_ch, + self.info.format_in, + self.info.format_out + )), + ]), + Line::from(vec![ + Span::styled(" uptime ", Style::default().fg(Color::DarkGray)), + Span::raw(up), + ]), + Line::from(vec![ + Span::styled(" blocks ", Style::default().fg(Color::DarkGray)), + Span::raw(format!("{blocks} ({hz:.0} blk/s)")), + ]), + Line::from(""), + Line::from(Span::styled( + format!(" in: {}", self.info.input), + Style::default().fg(Color::DarkGray), + )), + Line::from(Span::styled( + format!(" out: {}", self.info.output), + Style::default().fg(Color::DarkGray), + )), + ]; + + // Meters on top of the column. + let vert = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(2), + Constraint::Length(1), + Constraint::Length(2), + Constraint::Length(1), + Constraint::Min(0), + ]) + .split(area); + + let in_gauge = Gauge::default() + .ratio(meter_ratio(in_db)) + .gauge_style(meter_style(in_db)); + let out_gauge = Gauge::default() + .ratio(meter_ratio(out_db)) + .gauge_style(meter_style(out_db)); + + f.render_widget( + Paragraph::new(Line::from(vec![ + Span::raw(" in "), + Span::styled(format!("{in_db:+.0} dB"), meter_style(in_db)), + ])), + vert[0], + ); + f.render_widget(in_gauge, vert[1]); + f.render_widget( + Paragraph::new(Line::from(vec![ + Span::raw(" out "), + Span::styled(format!("{out_db:+.0} dB"), meter_style(out_db)), + ])), + vert[2], + ); + f.render_widget(out_gauge, vert[3]); + f.render_widget(Paragraph::new(lines), vert[4]); + } +} + +/// Layout rects for the main screen: (header, body, footer). +fn main_layout(area: Rect) -> (Rect, Rect, Rect) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(0), + Constraint::Length(1), + ]) + .split(area); + (chunks[0], chunks[1], chunks[2]) +} + +/// Layout rects for the settings screen: (header, list, footer). +fn settings_layout(area: Rect) -> (Rect, Rect, Rect) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(0), + Constraint::Length(1), + ]) + .split(area); + (chunks[0], chunks[1], chunks[2]) +} + +fn p_row(label: &str, value: &str, color: Color, info: &str) -> Line<'static> { + Line::from(vec![ + Span::styled( + format!(" {label:<8}"), + Style::default().fg(Color::DarkGray), + ), + Span::styled( + value.to_string(), + Style::default().fg(color).add_modifier(Modifier::BOLD), + ), + Span::styled(format!(" ({info})"), Style::default().fg(Color::DarkGray)), + ]) +} + +fn block(title: &str) -> Block<'static> { + Block::default() + .borders(Borders::ALL) + .title(Span::styled( + title.to_string(), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )) + .border_style(Style::default().fg(Color::DarkGray)) +} + +fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { + let height = ((area.height as u32 * percent_y as u32 / 100) as u16).max(12); + let top = area.y + (area.height.saturating_sub(height) / 2); + let rect = Rect::new(area.x, top, area.width, height); + let width = ((rect.width as u32 * percent_x as u32 / 100) as u16).max(30); + let x = rect.x + (rect.width.saturating_sub(width) / 2); + Rect::new(x, rect.y, width, rect.height) +} + +pub fn run(control: Arc>, stats: Arc>, info: SessionInfo) { + let run_seconds = control.lock().ok().and_then(|c| c.run_seconds).unwrap_or(0); + + if !std::io::stdin().is_terminal() { + let started = Instant::now(); + loop { + let done = control + .lock() + .map(|c| { + c.quit || run_seconds > 0 && started.elapsed().as_secs() >= run_seconds as u64 + }) + .unwrap_or(true); + if done { + if let Ok(mut c) = control.lock() { + c.quit = true; + } + break; + } + std::thread::sleep(Duration::from_millis(50)); + } + return; + } + + enable_raw_mode().expect("failed to enable raw mode"); + let _ = crossterm::execute!(stdout(), crossterm::event::EnableMouseCapture); + let mut terminal = match Terminal::new(CrosstermBackend::new(stdout())) { + Ok(t) => t, + Err(e) => { + disable_raw_mode().ok(); + eprintln!("tui init failed: {e}"); + return; + } + }; + let _ = terminal.clear(); + + let mut app = App { + control, + stats, + info, + view: View::Main, + settings_cursor: 0, + splash_until: Instant::now() + Duration::from_millis(1500), + started: Instant::now(), + running: true, + }; + + while app.running { + let _ = terminal.draw(|f| app.render(f)); + let _ = stdout().flush(); + + if event::poll(Duration::from_millis(50)).unwrap_or(false) { + if let Ok(ev) = event::read() { + match ev { + Event::Key(key) => app.on_key(key), + Event::Mouse(m) => app.on_mouse(m), + _ => {} + } + } + } + + if run_seconds > 0 && app.started.elapsed().as_secs() >= run_seconds as u64 { + app.set_quit(); + } + } + + let _ = terminal.show_cursor(); + let _ = crossterm::execute!(stdout(), crossterm::event::DisableMouseCapture); + disable_raw_mode().ok(); + println!("shutting down..."); +}