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"
|
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"
|
||||||
|
|
|
||||||
|
|
@ -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
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 |
|
| 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`).
|
||||||
|
|
||||||
### Зависимости
|
### Зависимости
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(|| {
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
103
src/config.rs
103
src/config.rs
|
|
@ -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(", ")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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('/')
|
||||||
|
|
|
||||||
60
src/main.rs
60
src/main.rs
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue