From aacaf7d9cf7db203224eefc2042f50eac520157e Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Sun, 2 Aug 2026 18:20:36 +0200 Subject: [PATCH] Run as 'vois': install via make, config file, Auto-Tune, more presets, custom chains - 'vois' installs to PATH (make install) and works with no flags: auto device selection (pipewire/pulse/default) and 48 kHz default - ~/.config/vois/config.toml for defaults + custom preset chains (serde) - Auto-Tune pitch correction (snap to nearest semitone, smoothed) - 5 new presets: Chipmunk, Helium, Vader, Telephone, Growl (17 total) - --pitch-correct flag, --no-virtual-mic to disable the default virtual mic - no dead-code allowances; clippy/fmt/tests clean --- Cargo.lock | 79 +++++++- Cargo.toml | 2 + Makefile | 20 ++ README.md | 86 +++++++-- src/audio/capture.rs | 19 +- src/audio/mod.rs | 49 +++-- src/audio/playback.rs | 19 +- src/config.rs | 103 ++++++++-- src/dsp/chain.rs | 50 ++++- src/dsp/effects.rs | 2 +- src/dsp/pitch.rs | 42 ++++- src/dsp/presets.rs | 428 ++++++++++++++++++++++++++---------------- src/main.rs | 60 +++--- src/ui/tui.rs | 79 ++++++-- 14 files changed, 753 insertions(+), 285 deletions(-) create mode 100644 Makefile diff --git a/Cargo.lock b/Cargo.lock index b1abf0d..a295a7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -904,7 +904,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit", + "toml_edit 0.25.13+spec-1.1.0", ] [[package]] @@ -1061,6 +1061,16 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + [[package]] name = "serde_core" version = "1.0.229" @@ -1081,6 +1091,15 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1218,6 +1237,27 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -1227,6 +1267,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + [[package]] name = "toml_edit" version = "0.25.13+spec-1.1.0" @@ -1234,9 +1288,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.4", ] [[package]] @@ -1245,9 +1299,15 @@ version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ - "winnow", + "winnow 1.0.4", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "transpose" version = "0.2.3" @@ -1305,7 +1365,9 @@ dependencies = [ "ratatui", "ringbuf", "rustfft", + "serde", "signal-hook", + "toml", ] [[package]] @@ -1603,6 +1665,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "1.0.4" diff --git a/Cargo.toml b/Cargo.toml index be162fe..8ca3a85 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,9 @@ hound = "3" ratatui = "0.28" ringbuf = "0.4" rustfft = "6" +serde = { version = "1.0.229", features = ["derive"] } signal-hook = "0.3" +toml = "0.8" [profile.release] opt-level = 3 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7a294eb --- /dev/null +++ b/Makefile @@ -0,0 +1,20 @@ +BINDIR ?= $(HOME)/.local/bin + +.PHONY: build install uninstall test clippy + +build: + cargo build --release + +install: build + install -Dm755 target/release/vois $(BINDIR)/vois + @echo "installed: $(BINDIR)/vois" + @echo "make sure $(BINDIR) is on your PATH, then just run: vois" + +uninstall: + rm -f $(BINDIR)/vois + +test: + cargo test --bin vois + +clippy: + cargo clippy --all-targets -- -D warnings diff --git a/README.md b/README.md index 0def213..a4dbfe5 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ vois.rs turns your microphone into a voice changer in real time: shift pitch and | 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 | +| Auto-Tune | Optional pitch snap to the nearest semitone (smooth, no wobble) | +| Config file | Defaults + custom presets in `~/.config/vois/config.toml` | | 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 | @@ -56,17 +58,22 @@ vois.rs turns your microphone into a voice changer in real time: shift pitch and | Megaphone | 0 st | 1.00× | distortion + narrow EQ + compressor | | Ghost | −3 st | 1.05× | big reverb + chorus | | Cyborg | +2 st | 1.00× | vocoder + bitcrush | +| Chipmunk | +7 st | 1.40× | bitcrush | +| Helium | +10 st | 1.50× | chorus | +| Vader | −10 st | 0.60× | reverb | +| Telephone | 0 st | 1.00× | bandpass + noise + compressor | +| Growl | −5 st | 0.70× | distortion + reverb | ### 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 +make install # installs `vois` to ~/.local/bin (make sure it's on PATH) +vois # just works: auto-picks devices, virtual mic on, 48 kHz +vois -p "Girl / Anime" +vois --no-virtual-mic # output to your speakers instead +vois --pitch-correct # Auto-Tune on +vois --tone 220 # hear presets without a mic +vois --list / --list-presets # list devices / presets ``` In Discord / Zoom / OBS select **`vois.rs`** as your microphone. @@ -81,7 +88,27 @@ In Discord / Zoom / OBS select **`vois.rs`** as your microphone. | `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. +Settings screen: `↑↓` select, `←→` / `Enter` change, `Esc` back — preset, pitch, formant, gate threshold (dB), gain (dB), mute, LIVE/PASSTHROUGH mode, record, Auto-Tune (pitch corr). + +### Config file + +Create `~/.config/vois/config.toml` to set defaults and define your own presets: + +```toml +# ~/.config/vois/config.toml +preset = "Deep Robot" +virtual_mic = true + +[custom_presets] +"Deep Robot" = { pitch = -6, formant = 0.8, effects = [ + { Vocoder = { bands = 16, carrier = "Noise", wet = 1.0 } }, + { Reverb = { room = 0.5, damp = 0.4, wet = 0.3 } }, +] } +``` + +Any CLI option overrides the file. Effects: `Distortion`, `Bitcrush`, `Reverb`, +`Chorus`, `RingMod`, `Bandpass`, `Noise`, `Compressor`, `Vocoder` (carrier: +`Noise` / `Saw`). ### Dependencies @@ -128,6 +155,8 @@ vois.rs превращает твой микрофон в войс-чендже | 12 пресетов | Clean, Girl/Anime, Boy, Manly/Deep, Demon, Robot, 8-bit, Alien, Radio, Megaphone, Ghost, Cyborg | | Эффекты | Вокодер, биткраш, дисторшн, реверб, хорус, кольцевая модуляция, полосовой фильтр, шум, компрессор, шумоподавитель | | Виртуальный микрофон | Встроенный `vois.rs` (PipeWire/PulseAudio) — на Linux не нужен VB-CABLE | +| Автолюн (Auto-Tune) | Снап высоты к ближайшему полутону (плавно, без «плавания») | +| Конфиг-файл | Дефолты и свои пресеты в `~/.config/vois/config.toml` | | TUI | Полноэкранный интерфейс с управлением стрелками и мышью, живые уровни | | Запись в WAV | Клавиша `R` или `--record` | | Тестовый тон | `--tone 220` — послушать пресет без микрофона | @@ -149,17 +178,22 @@ vois.rs превращает твой микрофон в войс-чендже | Megaphone | 0 пт | 1.00× | дисторшн + узкий EQ + компрессор | | Ghost | −3 пт | 1.05× | большой реверб + хорус | | Cyborg | +2 пт | 1.00× | вокодер + биткраш | +| Chipmunk | +7 пт | 1.40× | биткраш | +| Helium | +10 пт | 1.50× | хорус | +| Vader | −10 пт | 0.60× | реверб | +| Telephone | 0 пт | 1.00× | полосовой фильтр + шум + компрессор | +| Growl | −5 пт | 0.70× | дисторшн + реверб | ### Запуск ```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 # список пресетов +make install # ставит `vois` в ~/.local/bin (проверь что он в PATH) +vois # просто работает: авто-устройства, виртуальный микрофон, 48 кГц +vois -p "Girl / Anime" +vois --no-virtual-mic # вывод в колонки/наушники +vois --pitch-correct # автолюн +vois --tone 220 # послушать пресеты без микрофона +vois --list / --list-presets # список устройств / пресетов ``` В Discord / Zoom / OBS выбери микрофон **`vois.rs`**. @@ -174,7 +208,27 @@ cargo build --release | `H` | справка | | `Q` / `Ctrl+C` | выход | -Экран настроек: `↑↓` выбор, `←→` / `Enter` изменение, `Esc` назад — пресет, pitch, formant, порог шумоподавителя (дБ), усиление (дБ), mute, режим LIVE/PASSTHROUGH, запись. +Экран настроек: `↑↓` выбор, `←→` / `Enter` изменение, `Esc` назад — пресет, pitch, formant, порог шумоподавителя (дБ), усиление (дБ), mute, режим LIVE/PASSTHROUGH, запись, автолюн. + +### Конфиг-файл + +Создай `~/.config/vois/config.toml` для дефолтов и своих пресетов: + +```toml +# ~/.config/vois/config.toml +preset = "Deep Robot" +virtual_mic = true + +[custom_presets] +"Deep Robot" = { pitch = -6, formant = 0.8, effects = [ + { Vocoder = { bands = 16, carrier = "Noise", wet = 1.0 } }, + { Reverb = { room = 0.5, damp = 0.4, wet = 0.3 } }, +] } +``` + +Любой CLI-флаг перекрывает файл. Эффекты: `Distortion`, `Bitcrush`, `Reverb`, +`Chorus`, `RingMod`, `Bandpass`, `Noise`, `Compressor`, `Vocoder` (carrier: +`Noise` / `Saw`). ### Зависимости diff --git a/src/audio/capture.rs b/src/audio/capture.rs index 2c80802..4fd818e 100644 --- a/src/audio/capture.rs +++ b/src/audio/capture.rs @@ -6,6 +6,8 @@ use cpal::{ }; use ringbuf::traits::Producer; +const PREFERRED_INPUTS: [&str; 3] = ["pipewire", "pulse", "default"]; + pub fn find_input(host: &Host, name: Option<&str>) -> Result { match name { Some(name) => host @@ -13,9 +15,20 @@ pub fn find_input(host: &Host, name: Option<&str>) -> Result { .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"), + None => { + let mut devices = host + .input_devices() + .context("failed to enumerate input devices")?; + if let Some(device) = devices.find(|d| { + d.name() + .map(|n| PREFERRED_INPUTS.iter().any(|p| n == *p)) + .unwrap_or(false) + }) { + return Ok(device); + } + host.default_input_device() + .context("no default input device found") + } } } diff --git a/src/audio/mod.rs b/src/audio/mod.rs index ef4f0ce..b477ed8 100644 --- a/src/audio/mod.rs +++ b/src/audio/mod.rs @@ -53,29 +53,42 @@ pub fn run(cfg: &AppConfig) -> Result<()> { 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 in_channels = in_supported.channels(); let out_default = output_device .default_output_config() .context("failed to read default output config")?; + let out_channels = out_default.channels(); + + // Negotiate one sample rate both devices support, preferring 48 kHz. + let rate = if let Some(r) = cfg.sample_rate { + SampleRate(r) + } else { + let in_ranges = input_device.supported_input_configs()?; + let out_ranges = output_device.supported_output_configs()?; + let in_ranges: Vec<_> = in_ranges.collect(); + let out_ranges: Vec<_> = out_ranges.collect(); + let mut chosen = in_supported.sample_rate(); + for r in [ + SampleRate(48000), + SampleRate(44100), + in_supported.sample_rate(), + ] { + if pick_config(in_ranges.iter().cloned(), in_channels, r).is_some() + && pick_config(out_ranges.iter().cloned(), out_channels, r).is_some() + { + chosen = r; + break; + } + } + chosen + }; + + let in_supported = pick_config(input_device.supported_input_configs()?, in_channels, rate) + .with_context(|| format!("input device does not support sample rate {} Hz", rate.0))?; + let out_supported = pick_config( output_device.supported_output_configs()?, - out_default.channels(), + out_channels, rate, ) .with_context(|| { diff --git a/src/audio/playback.rs b/src/audio/playback.rs index fa0231a..b14abb8 100644 --- a/src/audio/playback.rs +++ b/src/audio/playback.rs @@ -6,6 +6,8 @@ use cpal::{ }; use ringbuf::traits::Consumer; +const PREFERRED_OUTPUTS: [&str; 3] = ["pipewire", "pulse", "default"]; + pub fn find_output(host: &Host, name: Option<&str>) -> Result { match name { Some(name) => host @@ -13,9 +15,20 @@ pub fn find_output(host: &Host, name: Option<&str>) -> Result { .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"), + None => { + let mut devices = host + .output_devices() + .context("failed to enumerate output devices")?; + if let Some(device) = devices.find(|d| { + d.name() + .map(|n| PREFERRED_OUTPUTS.iter().any(|p| n == *p)) + .unwrap_or(false) + }) { + return Ok(device); + } + host.default_output_device() + .context("no default output device found") + } } } diff --git a/src/config.rs b/src/config.rs index d3fc12e..a4fc31d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,65 @@ use crate::dsp::chain::Control; -use crate::dsp::presets::PRESETS; +use crate::dsp::presets::{presets, CustomPresetCfg, Preset}; +use serde::Deserialize; +use std::collections::HashMap; +use std::path::PathBuf; + +/// Settings loaded from `~/.config/vois/config.toml`. +#[derive(Deserialize, Default)] +pub struct FileConfig { + pub input: Option, + pub output: Option, + pub sample_rate: Option, + pub preset: Option, + pub record: Option, + pub pitch: Option, + pub formant: Option, + pub gate: Option, + pub gain: Option, + pub tone: Option, + pub virtual_mic: Option, + pub pitch_correct: Option, + pub custom_presets: Option>, +} + +impl FileConfig { + pub fn config_path() -> PathBuf { + let base = std::env::var("XDG_CONFIG_HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| { + std::env::var("HOME") + .map(|h| PathBuf::from(h).join(".config")) + .unwrap_or_else(|_| PathBuf::from(".")) + }); + base.join("vois").join("config.toml") + } + + /// Load the config file if it exists; any parse error is ignored (treated + /// as an empty config so the app still starts). + pub fn load() -> Self { + match std::fs::read_to_string(Self::config_path()) { + Ok(text) => toml::from_str(&text).unwrap_or_default(), + Err(_) => Self::default(), + } + } + + /// Convert custom presets from the file into `Preset` objects. + pub fn to_presets(&self) -> Vec { + self.custom_presets + .as_ref() + .map(|map| { + map.iter() + .map(|(name, c)| Preset { + name: name.clone(), + pitch_semitones: c.pitch.unwrap_or(0.0), + formant_ratio: c.formant.unwrap_or(1.0), + effects: c.effects.clone().unwrap_or_default(), + }) + .collect() + }) + .unwrap_or_default() + } +} pub struct AppConfig { pub input: Option, @@ -14,29 +74,41 @@ pub struct AppConfig { pub run_seconds: Option, pub tone: f32, pub virtual_mic: bool, + pub pitch_correct: bool, } impl AppConfig { + /// Merge CLI options over the config file, over the app defaults. pub fn from_cli(cli: &crate::Cli) -> Self { + let file = FileConfig::load(); 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, + input: cli.input.clone().or(file.input), + output: cli.output.clone().or(file.output), + sample_rate: cli.sample_rate.or(file.sample_rate), + preset: cli + .preset + .clone() + .or(file.preset) + .unwrap_or_else(|| "Clean".to_string()), + record: cli.record.clone().or(file.record), + pitch: cli.pitch.or(file.pitch).unwrap_or(0.0), + formant: cli.formant.or(file.formant).unwrap_or(0.0), + gate: cli.gate.or(file.gate).unwrap_or(-100.0), + gain: cli.gain.or(file.gain).unwrap_or(1.0), run_seconds: cli.run_seconds, - tone: cli.tone, - virtual_mic: !cli.no_virtual_mic, + tone: cli.tone.or(file.tone).unwrap_or(0.0), + virtual_mic: if cli.no_virtual_mic { + false + } else { + file.virtual_mic.unwrap_or(true) + }, + pitch_correct: cli.pitch_correct.or(file.pitch_correct).unwrap_or(false), } } /// 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) + Preset::find(&self.preset).unwrap_or(0) } pub fn to_control(&self) -> Control { @@ -50,14 +122,15 @@ impl AppConfig { record_path: self.record.clone(), run_seconds: self.run_seconds, tone_hz: self.tone, + pitch_correct: self.pitch_correct, ..Control::default() } } pub fn preset_names() -> String { - PRESETS + presets() .iter() - .map(|p| p.name) + .map(|p| p.name.clone()) .collect::>() .join(", ") } diff --git a/src/dsp/chain.rs b/src/dsp/chain.rs index c05bd0f..7e5dfe0 100644 --- a/src/dsp/chain.rs +++ b/src/dsp/chain.rs @@ -3,7 +3,7 @@ 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::presets::{build_effects, presets}; use crate::dsp::stats::Stats; use ringbuf::traits::{Consumer, Producer}; use std::sync::mpsc::Sender; @@ -45,6 +45,8 @@ pub struct Control { pub run_seconds: Option, /// Test tone frequency (Hz) mixed into the input; 0 = off. pub tone_hz: f32, + /// Snap the voice to the nearest semitone (Auto-Tune style). + pub pitch_correct: bool, pub mode: Mode, pub quit: bool, } @@ -62,6 +64,7 @@ impl Default for Control { record_path: None, run_seconds: None, tone_hz: 0.0, + pitch_correct: false, mode: Mode::Live, quit: false, } @@ -74,6 +77,8 @@ pub struct Processor { stats: Arc>, shifter: Option, shift_pending: Vec, + /// Smoothed Auto-Tune correction in semitones. + correction: f32, gate: NoiseGate, effects: Vec>, last_preset: usize, @@ -89,6 +94,7 @@ impl Processor { stats, shifter: None, shift_pending: Vec::new(), + correction: 0.0, gate: NoiseGate::new(-60.0), effects: Vec::new(), last_preset: usize::MAX, @@ -120,14 +126,25 @@ impl Processor { } let in_rms = (in_sum / mono.len() as f32).sqrt(); - let (preset_idx, pitch, formant, gate_db, gain, mute, record, mode, record_path) = { + let ( + preset_idx, + pitch, + formant, + gate_db, + gain, + mute, + record, + mode, + record_path, + pitch_correct, + ) = { let ctrl = match self.control.lock() { Ok(c) => c, Err(p) => p.into_inner(), }; - let preset = &PRESETS[ctrl.preset_idx.min(PRESETS.len() - 1)]; + let preset = &presets()[ctrl.preset_idx.min(presets().len() - 1)]; ( - 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, @@ -136,15 +153,17 @@ impl Processor { ctrl.record, ctrl.mode, ctrl.record_path.clone(), + ctrl.pitch_correct, ) }; if preset_idx != self.last_preset { - self.effects = build_effects(PRESETS[preset_idx].effects, self.fs); + 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; + let mut shifter_active = (pitch - 0.0).abs() > 0.05 || (formant - 1.0).abs() > 0.01; + shifter_active |= pitch_correct; if mute { mono.fill(0.0); @@ -165,7 +184,24 @@ impl Processor { self.shift_pending = vec![0.0; 1024 - 256]; } let shifter = self.shifter.as_mut().unwrap(); - shifter.pitch_ratio = 2f32.powf(pitch / 12.0); + + // Auto-Tune: glide the manual pitch toward the nearest semitone + // of the detected input fundamental (smoothed so it doesn't + // wobble). Detection happens on the input frame, so the + // correction applies from the next block onward. + if pitch_correct { + let f0 = shifter.last_f0; + if (70.0..1200.0).contains(&f0) { + let current = 12.0 * (f0 / 440.0).log2(); + let target = current.round() - current; + self.correction += (target - self.correction) * 0.25; + } + } else { + self.correction = 0.0; + } + let effective = pitch + self.correction; + + shifter.pitch_ratio = 2f32.powf(effective / 12.0); shifter.formant_ratio = formant; let mut out = Vec::with_capacity(mono.len()); shifter.process(mono, &mut out); diff --git a/src/dsp/effects.rs b/src/dsp/effects.rs index 2678a06..9a93fff 100644 --- a/src/dsp/effects.rs +++ b/src/dsp/effects.rs @@ -500,7 +500,7 @@ impl Effect for Compressor { // Channel vocoder (classic robot voice) // --------------------------------------------------------------------------- -#[derive(Clone, Copy, PartialEq, Debug)] +#[derive(Clone, Copy, PartialEq, Debug, serde::Serialize, serde::Deserialize)] pub enum CarrierKind { Noise, Saw, diff --git a/src/dsp/pitch.rs b/src/dsp/pitch.rs index 0521123..2f1798d 100644 --- a/src/dsp/pitch.rs +++ b/src/dsp/pitch.rs @@ -59,6 +59,10 @@ pub struct PitchShifter { pub pitch_ratio: f32, /// Formant factor: output formants = input formants * ratio (1.0 = unchanged). pub formant_ratio: f32, + + fs: u32, + /// Detected fundamental of the most recent analysis frame (Hz, 0 = unvoiced). + pub last_f0: f32, } impl PitchShifter { @@ -87,8 +91,6 @@ impl PitchShifter { 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, @@ -112,6 +114,8 @@ impl PitchShifter { lifter_len: fft_size / 8, pitch_ratio: 1.0, formant_ratio: 1.0, + fs: sample_rate, + last_f0: 0.0, } } @@ -150,6 +154,9 @@ impl PitchShifter { // Cepstral spectral-envelope estimation (used for formant control). self.compute_envelope(); + // Fundamental detection (harmonic product sum) for Auto-Tune. + self.detect_f0(); + let pr = self.pitch_ratio; let fr = self.formant_ratio; let p_active = (pr - 1.0).abs() > 1e-4; @@ -218,6 +225,37 @@ impl PitchShifter { } } + /// Fundamental frequency via harmonic product sum over the magnitude + /// spectrum. Sets `last_f0` (Hz), or 0.0 when the frame is unvoiced/weak. + fn detect_f0(&mut self) { + let n2 = self.n / 2; + let bin_hz = self.fs as f32 / self.n as f32; + // Search 70..1000 Hz, up to 5 harmonics. + let b_min = (70.0 / bin_hz).floor() as usize; + let b_max = (1000.0 / bin_hz).floor() as usize; + let mut best_bin = 0usize; + let mut best_score = 0.0f32; + for b in b_min..=b_max.min(n2 / 5) { + let mut score = 0.0; + for h in 1..=5 { + if b * h < n2 { + score += self.mag[b * h]; + } + } + if score > best_score { + best_score = score; + best_bin = b; + } + } + // Voicing threshold: fundamental bin must carry real energy. + let peak = self.mag[..n2].iter().cloned().fold(0.0f32, f32::max); + self.last_f0 = if best_bin > 0 && self.mag[best_bin] > peak * 0.05 && best_score > 0.0 { + best_bin as f32 * bin_hz + } else { + 0.0 + }; + } + /// Spectral envelope via homomorphic (cepstral) filtering. fn compute_envelope(&mut self) { let n2 = self.n / 2; diff --git a/src/dsp/presets.rs b/src/dsp/presets.rs index 6536d91..40fce43 100644 --- a/src/dsp/presets.rs +++ b/src/dsp/presets.rs @@ -1,18 +1,22 @@ //! Preset voice settings: pitch, formant and an effect chain. +//! The list is built at startup from built-in presets plus any custom presets +//! defined in `~/.config/vois/config.toml`. use crate::dsp::effects::{self, CarrierKind, Effect}; +use serde::{Deserialize, Serialize}; +use std::sync::OnceLock; pub struct Preset { - pub name: &'static str, + pub name: String, /// 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], + pub effects: Vec, } -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] pub enum EffectSpec { Distortion { drive: f32, @@ -58,175 +62,267 @@ pub enum EffectSpec { }, } -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 { +/// User-defined preset from the config file. +#[derive(Deserialize)] +pub struct CustomPresetCfg { + pub pitch: Option, + pub formant: Option, + pub effects: Option>, +} + +static PRESETS: OnceLock> = OnceLock::new(); + +fn builtin_presets() -> Vec { + vec![ + Preset { + name: "Clean".into(), + pitch_semitones: 0.0, + formant_ratio: 1.0, + effects: vec![], + }, + Preset { + name: "Girl / Anime".into(), + pitch_semitones: 5.0, + formant_ratio: 1.25, + effects: vec![], + }, + Preset { + name: "Boy".into(), + pitch_semitones: 3.0, + formant_ratio: 1.12, + effects: vec![], + }, + Preset { + name: "Manly / Deep".into(), + pitch_semitones: -4.0, + formant_ratio: 0.78, + effects: vec![EffectSpec::Compressor { + threshold_db: -20.0, + ratio: 3.0, + makeup_db: 2.0, + }], + }, + Preset { + name: "Demon".into(), + pitch_semitones: -9.0, + formant_ratio: 0.85, + effects: vec![ + EffectSpec::Distortion { + drive: 6.0, + output: 0.7, + }, + EffectSpec::Reverb { + room: 0.6, + damp: 0.4, + wet: 0.4, + }, + ], + }, + Preset { + name: "Robot".into(), + pitch_semitones: 0.0, + formant_ratio: 1.0, + effects: vec![ + 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".into(), + pitch_semitones: 0.0, + formant_ratio: 1.0, + effects: vec![EffectSpec::Bitcrush { + bits: 5, + sample_rate_div: 4, + max_gain: 32.0, + target: 0.6, + }], + }, + Preset { + name: "Alien".into(), + pitch_semitones: -2.0, + formant_ratio: 1.15, + effects: vec![ + 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".into(), + pitch_semitones: 0.0, + formant_ratio: 1.0, + effects: vec![ + 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".into(), + pitch_semitones: 0.0, + formant_ratio: 1.0, + effects: vec![ + 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".into(), + pitch_semitones: -3.0, + formant_ratio: 1.05, + effects: vec![ + 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".into(), + pitch_semitones: 2.0, + formant_ratio: 1.0, + effects: vec![ + 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, + }, + ], + }, + Preset { + name: "Chipmunk".into(), + pitch_semitones: 7.0, + formant_ratio: 1.4, + effects: vec![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, - }, - ], - }, -]; + target: 0.5, + }], + }, + Preset { + name: "Helium".into(), + pitch_semitones: 10.0, + formant_ratio: 1.5, + effects: vec![EffectSpec::Chorus { + depth_ms: 5.0, + rate_hz: 0.6, + wet: 0.3, + }], + }, + Preset { + name: "Vader".into(), + pitch_semitones: -10.0, + formant_ratio: 0.6, + effects: vec![EffectSpec::Reverb { + room: 0.7, + damp: 0.3, + wet: 0.35, + }], + }, + Preset { + name: "Telephone".into(), + pitch_semitones: 0.0, + formant_ratio: 1.0, + effects: vec![ + EffectSpec::Bandpass { + low_hz: 300.0, + high_hz: 3400.0, + mix: 1.0, + }, + EffectSpec::Noise { gain: 0.02 }, + EffectSpec::Compressor { + threshold_db: -22.0, + ratio: 4.0, + makeup_db: 8.0, + }, + ], + }, + Preset { + name: "Growl".into(), + pitch_semitones: -5.0, + formant_ratio: 0.7, + effects: vec![ + EffectSpec::Distortion { + drive: 4.0, + output: 0.6, + }, + EffectSpec::Reverb { + room: 0.5, + damp: 0.4, + wet: 0.3, + }, + ], + }, + ] +} + +/// Register custom presets (from the config file). Call once at startup. +pub fn init_custom(customs: Vec) { + let _ = PRESETS.get_or_init(|| { + let mut all = builtin_presets(); + all.extend(customs); + all + }); +} + +/// The full preset list (built-ins + customs). +pub fn presets() -> &'static [Preset] { + init_custom(Vec::new()); + PRESETS.get_or_init(builtin_presets) +} impl Preset { pub fn find(name: &str) -> Option { - PRESETS.iter().position(|p| { + presets().iter().position(|p| { p.name.eq_ignore_ascii_case(name) || p.name .split('/') diff --git a/src/main.rs b/src/main.rs index 904025f..dbb65d3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ mod ui; use anyhow::Result; use clap::Parser; +use config::FileConfig; #[derive(Parser, Debug)] #[command( @@ -29,49 +30,40 @@ pub struct Cli { #[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(short = 'p', long, help = "Preset name (config/default: Clean)")] + pub preset: Option, - #[arg( - long, - help = "Record processed audio to a WAV file (also toggle with R in the UI)" - )] + #[arg(long, help = "Record processed audio to a WAV file")] pub record: Option, - #[arg(long, default_value_t = 0.0, help = "Initial pitch shift in semitones")] - pub pitch: f32, + #[arg(long, help = "Initial pitch shift in semitones")] + pub pitch: Option, + + #[arg(long, help = "Initial formant shift in semitones")] + pub formant: Option, + + #[arg(long, help = "Noise gate threshold in dB (default -100 = off)")] + pub gate: Option, + + #[arg(long, help = "Output gain (1.0 = unity)")] + pub gain: Option, #[arg( long, - default_value_t = 0.0, - help = "Initial formant shift in semitones" + num_args = 0..=1, + default_missing_value = "true", + help = "Snap pitch to the nearest semitone (Auto-Tune)" )] - 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, + pub pitch_correct: Option, #[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, + pub tone: Option, #[arg(long, help = "List available presets and exit")] pub list_presets: bool, @@ -86,6 +78,9 @@ pub struct Cli { fn main() -> Result<()> { let cli = Cli::parse(); + let file = FileConfig::load(); + dsp::presets::init_custom(file.to_presets()); + if cli.list { audio::devices::print_all()?; return Ok(()); @@ -93,10 +88,16 @@ fn main() -> Result<()> { if cli.list_presets { println!("Available presets: {}", config::AppConfig::preset_names()); + println!( + "Config file: {} (create it to set defaults and custom presets)", + FileConfig::config_path().display() + ); return Ok(()); } - if !cli.no_virtual_mic { + let cfg = config::AppConfig::from_cli(&cli); + + if cfg.virtual_mic { match audio::virtual_mic::VirtualMic::setup() { Ok(Some(src)) => { println!( @@ -108,6 +109,5 @@ fn main() -> Result<()> { } } - let cfg = config::AppConfig::from_cli(&cli); audio::run(&cfg) } diff --git a/src/ui/tui.rs b/src/ui/tui.rs index 15a1ddf..81c8fa7 100644 --- a/src/ui/tui.rs +++ b/src/ui/tui.rs @@ -3,7 +3,7 @@ //! and mouse control. use crate::dsp::chain::{Control, Mode}; -use crate::dsp::presets::PRESETS; +use crate::dsp::presets::presets; use crate::dsp::stats::Stats; use crate::ui::SessionInfo; use crossterm::event::{ @@ -30,7 +30,7 @@ const LOGO: [&str; 7] = [ " real-time voice changer ", ]; -const SETTING_INFO: [&str; 8] = [ +const SETTING_INFO: [&str; 9] = [ "voice character, previewed live", "pitch: higher = girl/kid, lower = deep", "formant: brightness / timbre of the voice", @@ -39,6 +39,7 @@ const SETTING_INFO: [&str; 8] = [ "silence the output", "LIVE = processed, PASSTHROUGH = raw input (A/B)", "write processed audio to a WAV file", + "auto-tune: snap pitch to the nearest semitone", ]; const HELP_TEXT: &str = "\ @@ -101,13 +102,29 @@ struct App { 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) { + /// (preset_idx, preset_name, pitch_st, formant_ratio, gate_db, gain_lin, + /// mute, record, tone, pitch_correct, mode) + fn snapshot( + &self, + ) -> ( + usize, + String, + f32, + f32, + f32, + f32, + bool, + bool, + bool, + bool, + Mode, + ) { self.control .lock() .map(|c| { - let preset = &PRESETS[c.preset_idx.min(PRESETS.len() - 1)]; + let preset = &presets()[c.preset_idx.min(presets().len() - 1)]; ( - 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), @@ -116,6 +133,7 @@ impl App { c.mute, c.record, c.tone_hz > 0.0, + c.pitch_correct, c.mode, ) }) @@ -129,17 +147,19 @@ impl App { false, false, false, + false, Mode::Live, )) } fn setting_value(&self, idx: usize) -> String { - let (_, _name, pitch, formant, gate, gain, mute, record, _tone, mode) = self.snapshot(); + let (_, _name, pitch, formant, gate, gain, mute, record, _tone, tune, mode) = + self.snapshot(); match idx { - 0 => PRESETS[self + 0 => presets()[self .control .lock() - .map(|c| c.preset_idx.min(PRESETS.len() - 1)) + .map(|c| c.preset_idx.min(presets().len() - 1)) .unwrap_or(0)] .name .to_string(), @@ -159,6 +179,7 @@ impl App { Mode::Passthrough => "PASSTHROUGH".to_string(), }, 7 => if record { "recording" } else { "off" }.to_string(), + 8 => if tune { "ON" } else { "off" }.to_string(), _ => String::new(), } } @@ -167,7 +188,7 @@ impl App { fn adjust(&self, idx: usize, step: i32) { self.with_ctrl(|c| match idx { 0 => { - let n = PRESETS.len() as i32; + 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, @@ -183,6 +204,7 @@ impl App { 5 => c.mute = !c.mute, 6 => c.mode.toggle(), 7 => c.record = !c.record, + 8 => c.pitch_correct = !c.pitch_correct, _ => {} }); } @@ -222,22 +244,25 @@ impl App { 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::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()) + 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 + presets().len() - 1 } else { - (n - 1).min(PRESETS.len() - 1) + (n - 1).min(presets().len() - 1) }; }), KeyCode::Char('[') => self.adjust(1, -1), @@ -285,7 +310,7 @@ impl App { && mouse.row < presets_rect.y + presets_rect.height { let idx = (mouse.row - presets_rect.y) as usize; - if idx < PRESETS.len() { + if idx < presets().len() { self.with_ctrl(|c| c.preset_idx = idx); } } @@ -295,7 +320,7 @@ impl App { 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 { + if idx < 9 { self.settings_cursor = idx; } } @@ -371,13 +396,14 @@ impl App { } fn render_header(&self, f: &mut Frame, area: Rect) { - let (_, _name, _p, _f, _g, _gain, mute, record, tone, mode) = self.snapshot(); + let (_, _name, _p, _f, _g, _gain, mute, record, tone, tune, 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 tune_txt = if tune { "● TUNE" } else { "" }; let mute_txt = if mute { "MUTED" } else { "" }; let header = Line::from(vec![ Span::styled( @@ -407,6 +433,8 @@ impl App { Span::raw(" "), Span::styled(tone_txt, Style::default().fg(Color::Blue)), Span::raw(" "), + Span::styled(tune_txt, Style::default().fg(Color::Magenta)), + Span::raw(" "), Span::styled( mute_txt, Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), @@ -427,7 +455,7 @@ impl App { self.render_header(f, head); - let (preset_idx, _name, pitch, formant, gate, gain, mute, _record, _tone, _mode) = + let (preset_idx, _name, pitch, formant, gate, gain, mute, _record, _tone, tune, _mode) = self.snapshot(); let cols = Layout::default() @@ -441,7 +469,7 @@ impl App { // Presets (navigable, live preview). let mut plines = Vec::new(); - for (i, p) in PRESETS.iter().enumerate() { + for (i, p) in presets().iter().enumerate() { let selected = i == preset_idx; let label = format!( "{}{} {}", @@ -495,6 +523,16 @@ impl App { if mute { Color::Red } else { Color::DarkGray }, "silence output", ), + p_row( + "tune", + if tune { "ON" } else { "off" }, + if tune { + Color::Magenta + } else { + Color::DarkGray + }, + "auto-tune: snap pitch", + ), ]; let para = Paragraph::new(param_lines).block(block(" parameters · info ")); f.render_widget(para, cols[1]); @@ -524,6 +562,7 @@ impl App { "Mute", "Mode", "Record", + "Pitch corr", ]; let mut lines = Vec::new(); for (i, label) in labels.iter().enumerate() {