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:
parent
b0d685a110
commit
aacaf7d9cf
14 changed files with 753 additions and 285 deletions
79
Cargo.lock
generated
79
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
20
Makefile
Normal file
20
Makefile
Normal 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
|
||||
86
README.md
86
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`).
|
||||
|
||||
### Зависимости
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Device> {
|
||||
match name {
|
||||
Some(name) => host
|
||||
|
|
@ -13,9 +15,20 @@ pub fn find_input(host: &Host, name: Option<&str>) -> Result<Device> {
|
|||
.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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(|| {
|
||||
|
|
|
|||
|
|
@ -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<Device> {
|
||||
match name {
|
||||
Some(name) => host
|
||||
|
|
@ -13,9 +15,20 @@ pub fn find_output(host: &Host, name: Option<&str>) -> Result<Device> {
|
|||
.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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
103
src/config.rs
103
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<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 input: Option<String>,
|
||||
|
|
@ -14,29 +74,41 @@ pub struct AppConfig {
|
|||
pub run_seconds: Option<u32>,
|
||||
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::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<u32>,
|
||||
/// 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<Mutex<Stats>>,
|
||||
shifter: Option<PitchShifter>,
|
||||
shift_pending: Vec<f32>,
|
||||
/// Smoothed Auto-Tune correction in semitones.
|
||||
correction: f32,
|
||||
gate: NoiseGate,
|
||||
effects: Vec<Box<dyn Effect>>,
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<EffectSpec>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
|
||||
pub enum EffectSpec {
|
||||
Distortion {
|
||||
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 {
|
||||
name: "Clean",
|
||||
name: "Clean".into(),
|
||||
pitch_semitones: 0.0,
|
||||
formant_ratio: 1.0,
|
||||
effects: &[],
|
||||
effects: vec![],
|
||||
},
|
||||
Preset {
|
||||
name: "Girl / Anime",
|
||||
name: "Girl / Anime".into(),
|
||||
pitch_semitones: 5.0,
|
||||
formant_ratio: 1.25,
|
||||
effects: &[],
|
||||
effects: vec![],
|
||||
},
|
||||
Preset {
|
||||
name: "Boy",
|
||||
name: "Boy".into(),
|
||||
pitch_semitones: 3.0,
|
||||
formant_ratio: 1.12,
|
||||
effects: &[],
|
||||
effects: vec![],
|
||||
},
|
||||
Preset {
|
||||
name: "Manly / Deep",
|
||||
name: "Manly / Deep".into(),
|
||||
pitch_semitones: -4.0,
|
||||
formant_ratio: 0.78,
|
||||
effects: &[EffectSpec::Compressor {
|
||||
effects: vec![EffectSpec::Compressor {
|
||||
threshold_db: -20.0,
|
||||
ratio: 3.0,
|
||||
makeup_db: 2.0,
|
||||
}],
|
||||
},
|
||||
Preset {
|
||||
name: "Demon",
|
||||
name: "Demon".into(),
|
||||
pitch_semitones: -9.0,
|
||||
formant_ratio: 0.85,
|
||||
effects: &[
|
||||
effects: vec![
|
||||
EffectSpec::Distortion {
|
||||
drive: 6.0,
|
||||
output: 0.7,
|
||||
|
|
@ -104,10 +119,10 @@ pub const PRESETS: &[Preset] = &[
|
|||
],
|
||||
},
|
||||
Preset {
|
||||
name: "Robot",
|
||||
name: "Robot".into(),
|
||||
pitch_semitones: 0.0,
|
||||
formant_ratio: 1.0,
|
||||
effects: &[
|
||||
effects: vec![
|
||||
EffectSpec::Vocoder {
|
||||
bands: 16,
|
||||
carrier: CarrierKind::Noise,
|
||||
|
|
@ -122,10 +137,10 @@ pub const PRESETS: &[Preset] = &[
|
|||
],
|
||||
},
|
||||
Preset {
|
||||
name: "8-bit",
|
||||
name: "8-bit".into(),
|
||||
pitch_semitones: 0.0,
|
||||
formant_ratio: 1.0,
|
||||
effects: &[EffectSpec::Bitcrush {
|
||||
effects: vec![EffectSpec::Bitcrush {
|
||||
bits: 5,
|
||||
sample_rate_div: 4,
|
||||
max_gain: 32.0,
|
||||
|
|
@ -133,10 +148,10 @@ pub const PRESETS: &[Preset] = &[
|
|||
}],
|
||||
},
|
||||
Preset {
|
||||
name: "Alien",
|
||||
name: "Alien".into(),
|
||||
pitch_semitones: -2.0,
|
||||
formant_ratio: 1.15,
|
||||
effects: &[
|
||||
effects: vec![
|
||||
EffectSpec::RingMod {
|
||||
freq_hz: 55.0,
|
||||
depth: 0.5,
|
||||
|
|
@ -149,10 +164,10 @@ pub const PRESETS: &[Preset] = &[
|
|||
],
|
||||
},
|
||||
Preset {
|
||||
name: "Radio",
|
||||
name: "Radio".into(),
|
||||
pitch_semitones: 0.0,
|
||||
formant_ratio: 1.0,
|
||||
effects: &[
|
||||
effects: vec![
|
||||
EffectSpec::Bandpass {
|
||||
low_hz: 300.0,
|
||||
high_hz: 3000.0,
|
||||
|
|
@ -167,10 +182,10 @@ pub const PRESETS: &[Preset] = &[
|
|||
],
|
||||
},
|
||||
Preset {
|
||||
name: "Megaphone",
|
||||
name: "Megaphone".into(),
|
||||
pitch_semitones: 0.0,
|
||||
formant_ratio: 1.0,
|
||||
effects: &[
|
||||
effects: vec![
|
||||
EffectSpec::Distortion {
|
||||
drive: 2.5,
|
||||
output: 0.9,
|
||||
|
|
@ -188,10 +203,10 @@ pub const PRESETS: &[Preset] = &[
|
|||
],
|
||||
},
|
||||
Preset {
|
||||
name: "Ghost",
|
||||
name: "Ghost".into(),
|
||||
pitch_semitones: -3.0,
|
||||
formant_ratio: 1.05,
|
||||
effects: &[
|
||||
effects: vec![
|
||||
EffectSpec::Reverb {
|
||||
room: 0.95,
|
||||
damp: 0.2,
|
||||
|
|
@ -205,10 +220,10 @@ pub const PRESETS: &[Preset] = &[
|
|||
],
|
||||
},
|
||||
Preset {
|
||||
name: "Cyborg",
|
||||
name: "Cyborg".into(),
|
||||
pitch_semitones: 2.0,
|
||||
formant_ratio: 1.0,
|
||||
effects: &[
|
||||
effects: vec![
|
||||
EffectSpec::Vocoder {
|
||||
bands: 16,
|
||||
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 {
|
||||
pub fn find(name: &str) -> Option<usize> {
|
||||
PRESETS.iter().position(|p| {
|
||||
presets().iter().position(|p| {
|
||||
p.name.eq_ignore_ascii_case(name)
|
||||
|| p.name
|
||||
.split('/')
|
||||
|
|
|
|||
60
src/main.rs
60
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<String>,
|
||||
|
||||
#[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<String>,
|
||||
|
||||
#[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<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(
|
||||
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<bool>,
|
||||
|
||||
#[arg(long, help = "Exit automatically after this many seconds")]
|
||||
pub run_seconds: Option<u32>,
|
||||
|
||||
#[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<f32>,
|
||||
|
||||
#[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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue