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
This commit is contained in:
loki5512344 2026-08-02 18:20:36 +02:00
parent b0d685a110
commit aacaf7d9cf
14 changed files with 753 additions and 285 deletions

79
Cargo.lock generated
View file

@ -904,7 +904,7 @@ version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [ dependencies = [
"toml_edit", "toml_edit 0.25.13+spec-1.1.0",
] ]
[[package]] [[package]]
@ -1061,6 +1061,16 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 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]] [[package]]
name = "serde_core" name = "serde_core"
version = "1.0.229" version = "1.0.229"
@ -1081,6 +1091,15 @@ dependencies = [
"syn 3.0.3", "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]] [[package]]
name = "shlex" name = "shlex"
version = "1.3.0" version = "1.3.0"
@ -1218,6 +1237,27 @@ dependencies = [
"syn 2.0.119", "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]] [[package]]
name = "toml_datetime" name = "toml_datetime"
version = "1.1.1+spec-1.1.0" version = "1.1.1+spec-1.1.0"
@ -1227,6 +1267,20 @@ dependencies = [
"serde_core", "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]] [[package]]
name = "toml_edit" name = "toml_edit"
version = "0.25.13+spec-1.1.0" version = "0.25.13+spec-1.1.0"
@ -1234,9 +1288,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
dependencies = [ dependencies = [
"indexmap", "indexmap",
"toml_datetime", "toml_datetime 1.1.1+spec-1.1.0",
"toml_parser", "toml_parser",
"winnow", "winnow 1.0.4",
] ]
[[package]] [[package]]
@ -1245,9 +1299,15 @@ version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
dependencies = [ 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]] [[package]]
name = "transpose" name = "transpose"
version = "0.2.3" version = "0.2.3"
@ -1305,7 +1365,9 @@ dependencies = [
"ratatui", "ratatui",
"ringbuf", "ringbuf",
"rustfft", "rustfft",
"serde",
"signal-hook", "signal-hook",
"toml",
] ]
[[package]] [[package]]
@ -1603,6 +1665,15 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "winnow" name = "winnow"
version = "1.0.4" version = "1.0.4"

View file

@ -14,7 +14,9 @@ hound = "3"
ratatui = "0.28" ratatui = "0.28"
ringbuf = "0.4" ringbuf = "0.4"
rustfft = "6" rustfft = "6"
serde = { version = "1.0.229", features = ["derive"] }
signal-hook = "0.3" signal-hook = "0.3"
toml = "0.8"
[profile.release] [profile.release]
opt-level = 3 opt-level = 3

20
Makefile Normal file
View file

@ -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

View file

@ -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 | | 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 | | 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 | | 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 | | 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` | | 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 | | 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 | | Megaphone | 0 st | 1.00× | distortion + narrow EQ + compressor |
| Ghost | −3 st | 1.05× | big reverb + chorus | | Ghost | −3 st | 1.05× | big reverb + chorus |
| Cyborg | +2 st | 1.00× | vocoder + bitcrush | | 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 ### Usage
```bash ```bash
cargo build --release make install # installs `vois` to ~/.local/bin (make sure it's on PATH)
./target/release/vois # virtual mic is ON by default vois # just works: auto-picks devices, virtual mic on, 48 kHz
./target/release/vois -p "Girl / Anime" # pick a preset vois -p "Girl / Anime"
./target/release/vois --no-virtual-mic # just output to your speakers vois --no-virtual-mic # output to your speakers instead
./target/release/vois --tone 220 # hear presets without a mic vois --pitch-correct # Auto-Tune on
./target/release/vois --list # list audio devices vois --tone 220 # hear presets without a mic
./target/release/vois --list-presets # list presets vois --list / --list-presets # list devices / presets
``` ```
In Discord / Zoom / OBS select **`vois.rs`** as your microphone. 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 | | `H` | help |
| `Q` / `Ctrl+C` | quit | | `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 ### Dependencies
@ -128,6 +155,8 @@ vois.rs превращает твой микрофон в войс-чендже
| 12 пресетов | Clean, Girl/Anime, Boy, Manly/Deep, Demon, Robot, 8-bit, Alien, Radio, Megaphone, Ghost, Cyborg | | 12 пресетов | Clean, Girl/Anime, Boy, Manly/Deep, Demon, Robot, 8-bit, Alien, Radio, Megaphone, Ghost, Cyborg |
| Эффекты | Вокодер, биткраш, дисторшн, реверб, хорус, кольцевая модуляция, полосовой фильтр, шум, компрессор, шумоподавитель | | Эффекты | Вокодер, биткраш, дисторшн, реверб, хорус, кольцевая модуляция, полосовой фильтр, шум, компрессор, шумоподавитель |
| Виртуальный микрофон | Встроенный `vois.rs` (PipeWire/PulseAudio) — на Linux не нужен VB-CABLE | | Виртуальный микрофон | Встроенный `vois.rs` (PipeWire/PulseAudio) — на Linux не нужен VB-CABLE |
| Автолюн (Auto-Tune) | Снап высоты к ближайшему полутону (плавно, без «плавания») |
| Конфиг-файл | Дефолты и свои пресеты в `~/.config/vois/config.toml` |
| TUI | Полноэкранный интерфейс с управлением стрелками и мышью, живые уровни | | TUI | Полноэкранный интерфейс с управлением стрелками и мышью, живые уровни |
| Запись в WAV | Клавиша `R` или `--record` | | Запись в WAV | Клавиша `R` или `--record` |
| Тестовый тон | `--tone 220` — послушать пресет без микрофона | | Тестовый тон | `--tone 220` — послушать пресет без микрофона |
@ -149,17 +178,22 @@ vois.rs превращает твой микрофон в войс-чендже
| Megaphone | 0 пт | 1.00× | дисторшн + узкий EQ + компрессор | | Megaphone | 0 пт | 1.00× | дисторшн + узкий EQ + компрессор |
| Ghost | −3 пт | 1.05× | большой реверб + хорус | | Ghost | −3 пт | 1.05× | большой реверб + хорус |
| Cyborg | +2 пт | 1.00× | вокодер + биткраш | | 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 ```bash
cargo build --release make install # ставит `vois` в ~/.local/bin (проверь что он в PATH)
./target/release/vois # виртуальный микрофон включён по умолчанию vois # просто работает: авто-устройства, виртуальный микрофон, 48 кГц
./target/release/vois -p "Girl / Anime" # выбрать пресет vois -p "Girl / Anime"
./target/release/vois --no-virtual-mic # просто вывод в колонки/наушники vois --no-virtual-mic # вывод в колонки/наушники
./target/release/vois --tone 220 # послушать пресеты без микрофона vois --pitch-correct # автолюн
./target/release/vois --list # список устройств vois --tone 220 # послушать пресеты без микрофона
./target/release/vois --list-presets # список пресетов vois --list / --list-presets # список устройств / пресетов
``` ```
В Discord / Zoom / OBS выбери микрофон **`vois.rs`**. В Discord / Zoom / OBS выбери микрофон **`vois.rs`**.
@ -174,7 +208,27 @@ cargo build --release
| `H` | справка | | `H` | справка |
| `Q` / `Ctrl+C` | выход | | `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`).
### Зависимости ### Зависимости

View file

@ -6,6 +6,8 @@ use cpal::{
}; };
use ringbuf::traits::Producer; use ringbuf::traits::Producer;
const PREFERRED_INPUTS: [&str; 3] = ["pipewire", "pulse", "default"];
pub fn find_input(host: &Host, name: Option<&str>) -> Result<Device> { pub fn find_input(host: &Host, name: Option<&str>) -> Result<Device> {
match name { match name {
Some(name) => host Some(name) => host
@ -13,9 +15,20 @@ pub fn find_input(host: &Host, name: Option<&str>) -> Result<Device> {
.context("failed to enumerate input devices")? .context("failed to enumerate input devices")?
.find(|d| d.name().map(|n| n == name).unwrap_or(false)) .find(|d| d.name().map(|n| n == name).unwrap_or(false))
.with_context(|| format!("input device {name:?} not found")), .with_context(|| format!("input device {name:?} not found")),
None => host None => {
.default_input_device() let mut devices = host
.context("no default input device found"), .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")
}
} }
} }

View file

@ -53,29 +53,42 @@ pub fn run(cfg: &AppConfig) -> Result<()> {
let in_supported = input_device let in_supported = input_device
.default_input_config() .default_input_config()
.context("failed to read default input config")?; .context("failed to read default input config")?;
let in_channels = in_supported.channels();
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 let out_default = output_device
.default_output_config() .default_output_config()
.context("failed to read 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( let out_supported = pick_config(
output_device.supported_output_configs()?, output_device.supported_output_configs()?,
out_default.channels(), out_channels,
rate, rate,
) )
.with_context(|| { .with_context(|| {

View file

@ -6,6 +6,8 @@ use cpal::{
}; };
use ringbuf::traits::Consumer; use ringbuf::traits::Consumer;
const PREFERRED_OUTPUTS: [&str; 3] = ["pipewire", "pulse", "default"];
pub fn find_output(host: &Host, name: Option<&str>) -> Result<Device> { pub fn find_output(host: &Host, name: Option<&str>) -> Result<Device> {
match name { match name {
Some(name) => host Some(name) => host
@ -13,9 +15,20 @@ pub fn find_output(host: &Host, name: Option<&str>) -> Result<Device> {
.context("failed to enumerate output devices")? .context("failed to enumerate output devices")?
.find(|d| d.name().map(|n| n == name).unwrap_or(false)) .find(|d| d.name().map(|n| n == name).unwrap_or(false))
.with_context(|| format!("output device {name:?} not found")), .with_context(|| format!("output device {name:?} not found")),
None => host None => {
.default_output_device() let mut devices = host
.context("no default output device found"), .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")
}
} }
} }

View file

@ -1,5 +1,65 @@
use crate::dsp::chain::Control; 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<String>,
pub output: Option<String>,
pub sample_rate: Option<u32>,
pub preset: Option<String>,
pub record: Option<String>,
pub pitch: Option<f32>,
pub formant: Option<f32>,
pub gate: Option<f32>,
pub gain: Option<f32>,
pub tone: Option<f32>,
pub virtual_mic: Option<bool>,
pub pitch_correct: Option<bool>,
pub custom_presets: Option<HashMap<String, CustomPresetCfg>>,
}
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<Preset> {
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 struct AppConfig {
pub input: Option<String>, pub input: Option<String>,
@ -14,29 +74,41 @@ pub struct AppConfig {
pub run_seconds: Option<u32>, pub run_seconds: Option<u32>,
pub tone: f32, pub tone: f32,
pub virtual_mic: bool, pub virtual_mic: bool,
pub pitch_correct: bool,
} }
impl AppConfig { impl AppConfig {
/// Merge CLI options over the config file, over the app defaults.
pub fn from_cli(cli: &crate::Cli) -> Self { pub fn from_cli(cli: &crate::Cli) -> Self {
let file = FileConfig::load();
Self { Self {
input: cli.input.clone(), input: cli.input.clone().or(file.input),
output: cli.output.clone(), output: cli.output.clone().or(file.output),
sample_rate: cli.sample_rate, sample_rate: cli.sample_rate.or(file.sample_rate),
preset: cli.preset.clone(), preset: cli
record: cli.record.clone(), .preset
pitch: cli.pitch, .clone()
formant: cli.formant, .or(file.preset)
gate: cli.gate, .unwrap_or_else(|| "Clean".to_string()),
gain: cli.gain, 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, run_seconds: cli.run_seconds,
tone: cli.tone, tone: cli.tone.or(file.tone).unwrap_or(0.0),
virtual_mic: !cli.no_virtual_mic, 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. /// Resolve the preset name into an index, defaulting to "Clean" on error.
fn preset_idx(&self) -> usize { 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 { pub fn to_control(&self) -> Control {
@ -50,14 +122,15 @@ impl AppConfig {
record_path: self.record.clone(), record_path: self.record.clone(),
run_seconds: self.run_seconds, run_seconds: self.run_seconds,
tone_hz: self.tone, tone_hz: self.tone,
pitch_correct: self.pitch_correct,
..Control::default() ..Control::default()
} }
} }
pub fn preset_names() -> String { pub fn preset_names() -> String {
PRESETS presets()
.iter() .iter()
.map(|p| p.name) .map(|p| p.name.clone())
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", ") .join(", ")
} }

View file

@ -3,7 +3,7 @@ use crate::dsp::effects::Effect;
use crate::dsp::formant::ratio_from_semitones; use crate::dsp::formant::ratio_from_semitones;
use crate::dsp::gate::NoiseGate; use crate::dsp::gate::NoiseGate;
use crate::dsp::pitch::PitchShifter; 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 crate::dsp::stats::Stats;
use ringbuf::traits::{Consumer, Producer}; use ringbuf::traits::{Consumer, Producer};
use std::sync::mpsc::Sender; use std::sync::mpsc::Sender;
@ -45,6 +45,8 @@ pub struct Control {
pub run_seconds: Option<u32>, pub run_seconds: Option<u32>,
/// Test tone frequency (Hz) mixed into the input; 0 = off. /// Test tone frequency (Hz) mixed into the input; 0 = off.
pub tone_hz: f32, pub tone_hz: f32,
/// Snap the voice to the nearest semitone (Auto-Tune style).
pub pitch_correct: bool,
pub mode: Mode, pub mode: Mode,
pub quit: bool, pub quit: bool,
} }
@ -62,6 +64,7 @@ impl Default for Control {
record_path: None, record_path: None,
run_seconds: None, run_seconds: None,
tone_hz: 0.0, tone_hz: 0.0,
pitch_correct: false,
mode: Mode::Live, mode: Mode::Live,
quit: false, quit: false,
} }
@ -74,6 +77,8 @@ pub struct Processor {
stats: Arc<Mutex<Stats>>, stats: Arc<Mutex<Stats>>,
shifter: Option<PitchShifter>, shifter: Option<PitchShifter>,
shift_pending: Vec<f32>, shift_pending: Vec<f32>,
/// Smoothed Auto-Tune correction in semitones.
correction: f32,
gate: NoiseGate, gate: NoiseGate,
effects: Vec<Box<dyn Effect>>, effects: Vec<Box<dyn Effect>>,
last_preset: usize, last_preset: usize,
@ -89,6 +94,7 @@ impl Processor {
stats, stats,
shifter: None, shifter: None,
shift_pending: Vec::new(), shift_pending: Vec::new(),
correction: 0.0,
gate: NoiseGate::new(-60.0), gate: NoiseGate::new(-60.0),
effects: Vec::new(), effects: Vec::new(),
last_preset: usize::MAX, last_preset: usize::MAX,
@ -120,14 +126,25 @@ impl Processor {
} }
let in_rms = (in_sum / mono.len() as f32).sqrt(); 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() { let ctrl = match self.control.lock() {
Ok(c) => c, Ok(c) => c,
Err(p) => p.into_inner(), 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.pitch_semitones + ctrl.pitch_delta,
preset.formant_ratio * ratio_from_semitones(ctrl.formant_delta), preset.formant_ratio * ratio_from_semitones(ctrl.formant_delta),
ctrl.gate_threshold_db, ctrl.gate_threshold_db,
@ -136,15 +153,17 @@ impl Processor {
ctrl.record, ctrl.record,
ctrl.mode, ctrl.mode,
ctrl.record_path.clone(), ctrl.record_path.clone(),
ctrl.pitch_correct,
) )
}; };
if preset_idx != self.last_preset { 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; 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 { if mute {
mono.fill(0.0); mono.fill(0.0);
@ -165,7 +184,24 @@ impl Processor {
self.shift_pending = vec![0.0; 1024 - 256]; self.shift_pending = vec![0.0; 1024 - 256];
} }
let shifter = self.shifter.as_mut().unwrap(); 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; shifter.formant_ratio = formant;
let mut out = Vec::with_capacity(mono.len()); let mut out = Vec::with_capacity(mono.len());
shifter.process(mono, &mut out); shifter.process(mono, &mut out);

View file

@ -500,7 +500,7 @@ impl Effect for Compressor {
// Channel vocoder (classic robot voice) // Channel vocoder (classic robot voice)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
#[derive(Clone, Copy, PartialEq, Debug)] #[derive(Clone, Copy, PartialEq, Debug, serde::Serialize, serde::Deserialize)]
pub enum CarrierKind { pub enum CarrierKind {
Noise, Noise,
Saw, Saw,

View file

@ -59,6 +59,10 @@ pub struct PitchShifter {
pub pitch_ratio: f32, pub pitch_ratio: f32,
/// Formant factor: output formants = input formants * ratio (1.0 = unchanged). /// Formant factor: output formants = input formants * ratio (1.0 = unchanged).
pub formant_ratio: f32, pub formant_ratio: f32,
fs: u32,
/// Detected fundamental of the most recent analysis frame (Hz, 0 = unvoiced).
pub last_f0: f32,
} }
impl PitchShifter { impl PitchShifter {
@ -87,8 +91,6 @@ impl PitchShifter {
let env_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 env_fft = planner.plan_fft_forward(fft_size);
let _ = sample_rate;
Self { Self {
n: fft_size, n: fft_size,
hop, hop,
@ -112,6 +114,8 @@ impl PitchShifter {
lifter_len: fft_size / 8, lifter_len: fft_size / 8,
pitch_ratio: 1.0, pitch_ratio: 1.0,
formant_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). // Cepstral spectral-envelope estimation (used for formant control).
self.compute_envelope(); self.compute_envelope();
// Fundamental detection (harmonic product sum) for Auto-Tune.
self.detect_f0();
let pr = self.pitch_ratio; let pr = self.pitch_ratio;
let fr = self.formant_ratio; let fr = self.formant_ratio;
let p_active = (pr - 1.0).abs() > 1e-4; 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. /// Spectral envelope via homomorphic (cepstral) filtering.
fn compute_envelope(&mut self) { fn compute_envelope(&mut self) {
let n2 = self.n / 2; let n2 = self.n / 2;

View file

@ -1,18 +1,22 @@
//! Preset voice settings: pitch, formant and an effect chain. //! 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 crate::dsp::effects::{self, CarrierKind, Effect};
use serde::{Deserialize, Serialize};
use std::sync::OnceLock;
pub struct Preset { pub struct Preset {
pub name: &'static str, pub name: String,
/// Pitch shift in semitones (positive = higher). /// Pitch shift in semitones (positive = higher).
pub pitch_semitones: f32, pub pitch_semitones: f32,
/// Formant ratio (1.0 = unchanged, >1 = higher/brighter). /// Formant ratio (1.0 = unchanged, >1 = higher/brighter).
pub formant_ratio: f32, pub formant_ratio: f32,
/// Effects applied in order after pitch/formant. /// Effects applied in order after pitch/formant.
pub effects: &'static [EffectSpec], pub effects: Vec<EffectSpec>,
} }
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug, Serialize, Deserialize)]
pub enum EffectSpec { pub enum EffectSpec {
Distortion { Distortion {
drive: f32, drive: f32,
@ -58,40 +62,51 @@ pub enum EffectSpec {
}, },
} }
pub const PRESETS: &[Preset] = &[ /// User-defined preset from the config file.
#[derive(Deserialize)]
pub struct CustomPresetCfg {
pub pitch: Option<f32>,
pub formant: Option<f32>,
pub effects: Option<Vec<EffectSpec>>,
}
static PRESETS: OnceLock<Vec<Preset>> = OnceLock::new();
fn builtin_presets() -> Vec<Preset> {
vec![
Preset { Preset {
name: "Clean", name: "Clean".into(),
pitch_semitones: 0.0, pitch_semitones: 0.0,
formant_ratio: 1.0, formant_ratio: 1.0,
effects: &[], effects: vec![],
}, },
Preset { Preset {
name: "Girl / Anime", name: "Girl / Anime".into(),
pitch_semitones: 5.0, pitch_semitones: 5.0,
formant_ratio: 1.25, formant_ratio: 1.25,
effects: &[], effects: vec![],
}, },
Preset { Preset {
name: "Boy", name: "Boy".into(),
pitch_semitones: 3.0, pitch_semitones: 3.0,
formant_ratio: 1.12, formant_ratio: 1.12,
effects: &[], effects: vec![],
}, },
Preset { Preset {
name: "Manly / Deep", name: "Manly / Deep".into(),
pitch_semitones: -4.0, pitch_semitones: -4.0,
formant_ratio: 0.78, formant_ratio: 0.78,
effects: &[EffectSpec::Compressor { effects: vec![EffectSpec::Compressor {
threshold_db: -20.0, threshold_db: -20.0,
ratio: 3.0, ratio: 3.0,
makeup_db: 2.0, makeup_db: 2.0,
}], }],
}, },
Preset { Preset {
name: "Demon", name: "Demon".into(),
pitch_semitones: -9.0, pitch_semitones: -9.0,
formant_ratio: 0.85, formant_ratio: 0.85,
effects: &[ effects: vec![
EffectSpec::Distortion { EffectSpec::Distortion {
drive: 6.0, drive: 6.0,
output: 0.7, output: 0.7,
@ -104,10 +119,10 @@ pub const PRESETS: &[Preset] = &[
], ],
}, },
Preset { Preset {
name: "Robot", name: "Robot".into(),
pitch_semitones: 0.0, pitch_semitones: 0.0,
formant_ratio: 1.0, formant_ratio: 1.0,
effects: &[ effects: vec![
EffectSpec::Vocoder { EffectSpec::Vocoder {
bands: 16, bands: 16,
carrier: CarrierKind::Noise, carrier: CarrierKind::Noise,
@ -122,10 +137,10 @@ pub const PRESETS: &[Preset] = &[
], ],
}, },
Preset { Preset {
name: "8-bit", name: "8-bit".into(),
pitch_semitones: 0.0, pitch_semitones: 0.0,
formant_ratio: 1.0, formant_ratio: 1.0,
effects: &[EffectSpec::Bitcrush { effects: vec![EffectSpec::Bitcrush {
bits: 5, bits: 5,
sample_rate_div: 4, sample_rate_div: 4,
max_gain: 32.0, max_gain: 32.0,
@ -133,10 +148,10 @@ pub const PRESETS: &[Preset] = &[
}], }],
}, },
Preset { Preset {
name: "Alien", name: "Alien".into(),
pitch_semitones: -2.0, pitch_semitones: -2.0,
formant_ratio: 1.15, formant_ratio: 1.15,
effects: &[ effects: vec![
EffectSpec::RingMod { EffectSpec::RingMod {
freq_hz: 55.0, freq_hz: 55.0,
depth: 0.5, depth: 0.5,
@ -149,10 +164,10 @@ pub const PRESETS: &[Preset] = &[
], ],
}, },
Preset { Preset {
name: "Radio", name: "Radio".into(),
pitch_semitones: 0.0, pitch_semitones: 0.0,
formant_ratio: 1.0, formant_ratio: 1.0,
effects: &[ effects: vec![
EffectSpec::Bandpass { EffectSpec::Bandpass {
low_hz: 300.0, low_hz: 300.0,
high_hz: 3000.0, high_hz: 3000.0,
@ -167,10 +182,10 @@ pub const PRESETS: &[Preset] = &[
], ],
}, },
Preset { Preset {
name: "Megaphone", name: "Megaphone".into(),
pitch_semitones: 0.0, pitch_semitones: 0.0,
formant_ratio: 1.0, formant_ratio: 1.0,
effects: &[ effects: vec![
EffectSpec::Distortion { EffectSpec::Distortion {
drive: 2.5, drive: 2.5,
output: 0.9, output: 0.9,
@ -188,10 +203,10 @@ pub const PRESETS: &[Preset] = &[
], ],
}, },
Preset { Preset {
name: "Ghost", name: "Ghost".into(),
pitch_semitones: -3.0, pitch_semitones: -3.0,
formant_ratio: 1.05, formant_ratio: 1.05,
effects: &[ effects: vec![
EffectSpec::Reverb { EffectSpec::Reverb {
room: 0.95, room: 0.95,
damp: 0.2, damp: 0.2,
@ -205,10 +220,10 @@ pub const PRESETS: &[Preset] = &[
], ],
}, },
Preset { Preset {
name: "Cyborg", name: "Cyborg".into(),
pitch_semitones: 2.0, pitch_semitones: 2.0,
formant_ratio: 1.0, formant_ratio: 1.0,
effects: &[ effects: vec![
EffectSpec::Vocoder { EffectSpec::Vocoder {
bands: 16, bands: 16,
carrier: CarrierKind::Saw, carrier: CarrierKind::Saw,
@ -222,11 +237,92 @@ pub const PRESETS: &[Preset] = &[
}, },
], ],
}, },
]; 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.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<Preset>) {
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 { impl Preset {
pub fn find(name: &str) -> Option<usize> { pub fn find(name: &str) -> Option<usize> {
PRESETS.iter().position(|p| { presets().iter().position(|p| {
p.name.eq_ignore_ascii_case(name) p.name.eq_ignore_ascii_case(name)
|| p.name || p.name
.split('/') .split('/')

View file

@ -5,6 +5,7 @@ mod ui;
use anyhow::Result; use anyhow::Result;
use clap::Parser; use clap::Parser;
use config::FileConfig;
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command( #[command(
@ -29,49 +30,40 @@ pub struct Cli {
#[arg(long, help = "List available audio devices and exit")] #[arg(long, help = "List available audio devices and exit")]
pub list: bool, pub list: bool,
#[arg( #[arg(short = 'p', long, help = "Preset name (config/default: Clean)")]
short = 'p', pub preset: Option<String>,
long,
default_value = "Clean",
help = "Starting preset name"
)]
pub preset: String,
#[arg( #[arg(long, help = "Record processed audio to a WAV file")]
long,
help = "Record processed audio to a WAV file (also toggle with R in the UI)"
)]
pub record: Option<String>, pub record: Option<String>,
#[arg(long, default_value_t = 0.0, help = "Initial pitch shift in semitones")] #[arg(long, help = "Initial pitch shift in semitones")]
pub pitch: f32, pub pitch: Option<f32>,
#[arg(long, help = "Initial formant shift in semitones")]
pub formant: Option<f32>,
#[arg(long, help = "Noise gate threshold in dB (default -100 = off)")]
pub gate: Option<f32>,
#[arg(long, help = "Output gain (1.0 = unity)")]
pub gain: Option<f32>,
#[arg( #[arg(
long, long,
default_value_t = 0.0, num_args = 0..=1,
help = "Initial formant shift in semitones" default_missing_value = "true",
help = "Snap pitch to the nearest semitone (Auto-Tune)"
)] )]
pub formant: f32, pub pitch_correct: Option<bool>,
#[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")] #[arg(long, help = "Exit automatically after this many seconds")]
pub run_seconds: Option<u32>, pub run_seconds: Option<u32>,
#[arg( #[arg(
long, long,
default_value_t = 0.0,
help = "Mix a test tone (Hz) into the input; 0 = off. Useful to hear presets without a mic" help = "Mix a test tone (Hz) into the input; 0 = off. Useful to hear presets without a mic"
)] )]
pub tone: f32, pub tone: Option<f32>,
#[arg(long, help = "List available presets and exit")] #[arg(long, help = "List available presets and exit")]
pub list_presets: bool, pub list_presets: bool,
@ -86,6 +78,9 @@ pub struct Cli {
fn main() -> Result<()> { fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
let file = FileConfig::load();
dsp::presets::init_custom(file.to_presets());
if cli.list { if cli.list {
audio::devices::print_all()?; audio::devices::print_all()?;
return Ok(()); return Ok(());
@ -93,10 +88,16 @@ fn main() -> Result<()> {
if cli.list_presets { if cli.list_presets {
println!("Available presets: {}", config::AppConfig::preset_names()); println!("Available presets: {}", config::AppConfig::preset_names());
println!(
"Config file: {} (create it to set defaults and custom presets)",
FileConfig::config_path().display()
);
return Ok(()); return Ok(());
} }
if !cli.no_virtual_mic { let cfg = config::AppConfig::from_cli(&cli);
if cfg.virtual_mic {
match audio::virtual_mic::VirtualMic::setup() { match audio::virtual_mic::VirtualMic::setup() {
Ok(Some(src)) => { Ok(Some(src)) => {
println!( println!(
@ -108,6 +109,5 @@ fn main() -> Result<()> {
} }
} }
let cfg = config::AppConfig::from_cli(&cli);
audio::run(&cfg) audio::run(&cfg)
} }

View file

@ -3,7 +3,7 @@
//! and mouse control. //! and mouse control.
use crate::dsp::chain::{Control, Mode}; use crate::dsp::chain::{Control, Mode};
use crate::dsp::presets::PRESETS; use crate::dsp::presets::presets;
use crate::dsp::stats::Stats; use crate::dsp::stats::Stats;
use crate::ui::SessionInfo; use crate::ui::SessionInfo;
use crossterm::event::{ use crossterm::event::{
@ -30,7 +30,7 @@ const LOGO: [&str; 7] = [
" real-time voice changer ", " real-time voice changer ",
]; ];
const SETTING_INFO: [&str; 8] = [ const SETTING_INFO: [&str; 9] = [
"voice character, previewed live", "voice character, previewed live",
"pitch: higher = girl/kid, lower = deep", "pitch: higher = girl/kid, lower = deep",
"formant: brightness / timbre of the voice", "formant: brightness / timbre of the voice",
@ -39,6 +39,7 @@ const SETTING_INFO: [&str; 8] = [
"silence the output", "silence the output",
"LIVE = processed, PASSTHROUGH = raw input (A/B)", "LIVE = processed, PASSTHROUGH = raw input (A/B)",
"write processed audio to a WAV file", "write processed audio to a WAV file",
"auto-tune: snap pitch to the nearest semitone",
]; ];
const HELP_TEXT: &str = "\ const HELP_TEXT: &str = "\
@ -101,13 +102,29 @@ struct App {
impl App { impl App {
/// (preset_idx, preset_name, pitch_st, formant_ratio, gate_db, gain_lin, /// (preset_idx, preset_name, pitch_st, formant_ratio, gate_db, gain_lin,
/// mute, record, tone, mode) /// 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 self.control
.lock() .lock()
.map(|c| { .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.name.to_string(),
preset.pitch_semitones + c.pitch_delta, preset.pitch_semitones + c.pitch_delta,
preset.formant_ratio * 2f32.powf(c.formant_delta / 12.0), preset.formant_ratio * 2f32.powf(c.formant_delta / 12.0),
@ -116,6 +133,7 @@ impl App {
c.mute, c.mute,
c.record, c.record,
c.tone_hz > 0.0, c.tone_hz > 0.0,
c.pitch_correct,
c.mode, c.mode,
) )
}) })
@ -129,17 +147,19 @@ impl App {
false, false,
false, false,
false, false,
false,
Mode::Live, Mode::Live,
)) ))
} }
fn setting_value(&self, idx: usize) -> String { 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 { match idx {
0 => PRESETS[self 0 => presets()[self
.control .control
.lock() .lock()
.map(|c| c.preset_idx.min(PRESETS.len() - 1)) .map(|c| c.preset_idx.min(presets().len() - 1))
.unwrap_or(0)] .unwrap_or(0)]
.name .name
.to_string(), .to_string(),
@ -159,6 +179,7 @@ impl App {
Mode::Passthrough => "PASSTHROUGH".to_string(), Mode::Passthrough => "PASSTHROUGH".to_string(),
}, },
7 => if record { "recording" } else { "off" }.to_string(), 7 => if record { "recording" } else { "off" }.to_string(),
8 => if tune { "ON" } else { "off" }.to_string(),
_ => String::new(), _ => String::new(),
} }
} }
@ -167,7 +188,7 @@ impl App {
fn adjust(&self, idx: usize, step: i32) { fn adjust(&self, idx: usize, step: i32) {
self.with_ctrl(|c| match idx { self.with_ctrl(|c| match idx {
0 => { 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; c.preset_idx = ((c.preset_idx as i32 + step).rem_euclid(n)) as usize;
} }
1 => c.pitch_delta += step as f32, 1 => c.pitch_delta += step as f32,
@ -183,6 +204,7 @@ impl App {
5 => c.mute = !c.mute, 5 => c.mute = !c.mute,
6 => c.mode.toggle(), 6 => c.mode.toggle(),
7 => c.record = !c.record, 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) { fn on_key_main(&mut self, key: crossterm::event::KeyEvent) {
match key.code { match key.code {
KeyCode::Up => self KeyCode::Up => self.with_ctrl(|c| {
.with_ctrl(|c| c.preset_idx = (c.preset_idx + PRESETS.len() - 1) % PRESETS.len()), 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::Down => {
self.with_ctrl(|c| c.preset_idx = (c.preset_idx + 1) % presets().len())
}
KeyCode::Left => self.adjust(1, -1), KeyCode::Left => self.adjust(1, -1),
KeyCode::Right => self.adjust(1, 1), KeyCode::Right => self.adjust(1, 1),
KeyCode::Char('s') | KeyCode::Char('S') | KeyCode::Tab => self.view = View::Settings, KeyCode::Char('s') | KeyCode::Char('S') | KeyCode::Tab => self.view = View::Settings,
KeyCode::Char('h') | KeyCode::Char('H') => self.view = View::Help, KeyCode::Char('h') | KeyCode::Char('H') => self.view = View::Help,
KeyCode::Char('p') | KeyCode::Char('P') => { 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| { KeyCode::Char(d) if d.is_ascii_digit() => self.with_ctrl(|c| {
let n = d.to_digit(10).unwrap() as usize; let n = d.to_digit(10).unwrap() as usize;
c.preset_idx = if n == 0 { c.preset_idx = if n == 0 {
PRESETS.len() - 1 presets().len() - 1
} else { } else {
(n - 1).min(PRESETS.len() - 1) (n - 1).min(presets().len() - 1)
}; };
}), }),
KeyCode::Char('[') => self.adjust(1, -1), KeyCode::Char('[') => self.adjust(1, -1),
@ -285,7 +310,7 @@ impl App {
&& mouse.row < presets_rect.y + presets_rect.height && mouse.row < presets_rect.y + presets_rect.height
{ {
let idx = (mouse.row - presets_rect.y) as usize; 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); self.with_ctrl(|c| c.preset_idx = idx);
} }
} }
@ -295,7 +320,7 @@ impl App {
let list_rect = rects.0; let list_rect = rects.0;
if mouse.row >= list_rect.y && mouse.row < list_rect.y + list_rect.height { if mouse.row >= list_rect.y && mouse.row < list_rect.y + list_rect.height {
let idx = (mouse.row - list_rect.y) as usize; let idx = (mouse.row - list_rect.y) as usize;
if idx < 8 { if idx < 9 {
self.settings_cursor = idx; self.settings_cursor = idx;
} }
} }
@ -371,13 +396,14 @@ impl App {
} }
fn render_header(&self, f: &mut Frame, area: Rect) { 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 { let mode_txt = match mode {
Mode::Live => "LIVE", Mode::Live => "LIVE",
Mode::Passthrough => "A/B PASSTHROUGH", Mode::Passthrough => "A/B PASSTHROUGH",
}; };
let rec_txt = if record { "● REC" } else { "" }; let rec_txt = if record { "● REC" } else { "" };
let tone_txt = if tone { "● TONE" } else { "" }; let tone_txt = if tone { "● TONE" } else { "" };
let tune_txt = if tune { "● TUNE" } else { "" };
let mute_txt = if mute { "MUTED" } else { "" }; let mute_txt = if mute { "MUTED" } else { "" };
let header = Line::from(vec![ let header = Line::from(vec![
Span::styled( Span::styled(
@ -407,6 +433,8 @@ impl App {
Span::raw(" "), Span::raw(" "),
Span::styled(tone_txt, Style::default().fg(Color::Blue)), Span::styled(tone_txt, Style::default().fg(Color::Blue)),
Span::raw(" "), Span::raw(" "),
Span::styled(tune_txt, Style::default().fg(Color::Magenta)),
Span::raw(" "),
Span::styled( Span::styled(
mute_txt, mute_txt,
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
@ -427,7 +455,7 @@ impl App {
self.render_header(f, head); 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(); self.snapshot();
let cols = Layout::default() let cols = Layout::default()
@ -441,7 +469,7 @@ impl App {
// Presets (navigable, live preview). // Presets (navigable, live preview).
let mut plines = Vec::new(); 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 selected = i == preset_idx;
let label = format!( let label = format!(
"{}{} {}", "{}{} {}",
@ -495,6 +523,16 @@ impl App {
if mute { Color::Red } else { Color::DarkGray }, if mute { Color::Red } else { Color::DarkGray },
"silence output", "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 ")); let para = Paragraph::new(param_lines).block(block(" parameters · info "));
f.render_widget(para, cols[1]); f.render_widget(para, cols[1]);
@ -524,6 +562,7 @@ impl App {
"Mute", "Mute",
"Mode", "Mode",
"Record", "Record",
"Pitch corr",
]; ];
let mut lines = Vec::new(); let mut lines = Vec::new();
for (i, label) in labels.iter().enumerate() { for (i, label) in labels.iter().enumerate() {