Restructure project: 1 file = 1 responsibility, max 4 files/folder, 250 lines/file

This commit is contained in:
loki5512344 2026-08-02 23:33:13 +02:00
parent 72769f4615
commit a6032b2d18
Signed by: boba
GPG key ID: 253067914055423B
49 changed files with 3408 additions and 3492 deletions

View file

@ -35,9 +35,9 @@ mod audio {
#[path = "../src/dsp/mod.rs"]
mod dsp;
use dsp::chain::{Control, Processor};
use dsp::pitch::PitchShifter;
use dsp::stats::Stats;
use dsp::chain::Processor;
use dsp::control::{Control, Stats};
use dsp::shift::PitchShifter;
const FS: u32 = 48000;

View file

@ -1,82 +0,0 @@
use anyhow::{Context, Result};
use cpal::traits::{DeviceTrait, HostTrait};
use cpal::{
Device, FromSample, Host, Sample, SampleFormat, SizedSample, Stream, StreamConfig,
SupportedStreamConfig,
};
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
.input_devices()
.context("failed to enumerate input devices")?
.find(|d| d.name().map(|n| n == name).unwrap_or(false))
.with_context(|| format!("input device {name:?} not found")),
None => {
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")
}
}
}
/// Open the input stream. Captured samples are converted to `f32` (interleaved
/// frames) and pushed into `tx`.
pub fn start<P>(device: &Device, supported: &SupportedStreamConfig, tx: P) -> Result<Stream>
where
P: Producer<Item = f32> + Send + 'static,
{
let config: StreamConfig = supported.config();
match supported.sample_format() {
SampleFormat::F32 => build::<f32, P>(device, &config, tx),
SampleFormat::F64 => build::<f64, P>(device, &config, tx),
SampleFormat::I8 => build::<i8, P>(device, &config, tx),
SampleFormat::I16 => build::<i16, P>(device, &config, tx),
SampleFormat::I32 => build::<i32, P>(device, &config, tx),
SampleFormat::I64 => build::<i64, P>(device, &config, tx),
SampleFormat::U8 => build::<u8, P>(device, &config, tx),
SampleFormat::U16 => build::<u16, P>(device, &config, tx),
SampleFormat::U32 => build::<u32, P>(device, &config, tx),
SampleFormat::U64 => build::<u64, P>(device, &config, tx),
format => anyhow::bail!("unsupported input sample format: {format:?}"),
}
}
fn build<T, P>(device: &Device, config: &StreamConfig, mut tx: P) -> Result<Stream>
where
T: SizedSample,
f32: FromSample<T>,
P: Producer<Item = f32> + Send + 'static,
{
let channels = config.channels as usize;
let mut frame = vec![0f32; channels];
let err_fn = |err| eprintln!("capture stream error: {err}");
device
.build_input_stream(
config,
move |data: &[T], _: &cpal::InputCallbackInfo| {
for chunk in data.chunks(channels) {
for (slot, sample) in frame.iter_mut().zip(chunk) {
*slot = f32::from_sample(*sample);
}
let _ = tx.push_slice(&frame);
}
},
err_fn,
None,
)
.map_err(|e| anyhow::anyhow!("failed to build input stream: {e}"))
}

View file

@ -1,18 +0,0 @@
use anyhow::Result;
use cpal::traits::{DeviceTrait, HostTrait};
pub fn print_all() -> Result<()> {
let host = cpal::default_host();
println!("Input devices:");
for device in host.input_devices()? {
println!(" {}", device.name().unwrap_or_else(|_| "<unnamed>".into()));
}
println!("Output devices:");
for device in host.output_devices()? {
println!(" {}", device.name().unwrap_or_else(|_| "<unnamed>".into()));
}
Ok(())
}

View file

@ -1,11 +1,9 @@
pub mod capture;
pub mod devices;
pub mod playback;
pub mod record;
pub mod stream;
pub mod virtual_mic;
use anyhow::{Context, Result};
use cpal::traits::{DeviceTrait, StreamTrait};
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::{SampleRate, StreamConfig, SupportedStreamConfig, SupportedStreamConfigRange};
use ringbuf::traits::Split;
use ringbuf::HeapRb;
@ -16,6 +14,20 @@ use crate::config::AppConfig;
use crate::dsp;
use crate::ui;
/// Print available input/output devices.
pub fn print_all() -> Result<()> {
let host = cpal::default_host();
println!("Input devices:");
for device in host.input_devices()? {
println!(" {}", device.name().unwrap_or_else(|_| "<unnamed>".into()));
}
println!("Output devices:");
for device in host.output_devices()? {
println!(" {}", device.name().unwrap_or_else(|_| "<unnamed>".into()));
}
Ok(())
}
/// Higher = better quality for our purposes.
fn format_rank(format: cpal::SampleFormat) -> u8 {
match format {
@ -63,8 +75,8 @@ pub fn run(cfg: &AppConfig) -> Result<()> {
virtual_mic::VirtualMic::wait_default_sink("vois.rs");
}
let input_device = capture::find_input(&host, cfg.input.as_deref())?;
let output_device = playback::find_output(&host, cfg.output.as_deref())?;
let input_device = stream::find_input(&host, cfg.input.as_deref())?;
let output_device = stream::find_output(&host, cfg.output.as_deref())?;
let in_supported = input_device
.default_input_config()
@ -136,18 +148,18 @@ pub fn run(cfg: &AppConfig) -> Result<()> {
let (in_prod, in_cons) = HeapRb::<f32>::new(ring_cap).split();
let (out_prod, out_cons) = HeapRb::<f32>::new(ring_cap).split();
let input_stream = capture::start(&input_device, &in_supported, in_prod)?;
let output_stream = playback::start(&output_device, &out_supported, out_cons)?;
let input_stream = stream::start_input(&input_device, &in_supported, in_prod)?;
let output_stream = stream::start_output(&output_device, &out_supported, out_cons)?;
let in_ch = in_cfg.channels as usize;
let out_ch = out_cfg.channels as usize;
let control = Arc::new(Mutex::new(cfg.to_control()));
let stats = Arc::new(Mutex::new(dsp::stats::Stats::default()));
let stats = Arc::new(Mutex::new(dsp::control::Stats::default()));
let worker_control = Arc::clone(&control);
let worker_stats = Arc::clone(&stats);
let worker = thread::spawn(move || {
dsp::chain::run_worker(
dsp::chain::worker::run_worker(
in_cons,
out_prod,
in_ch,
@ -186,7 +198,7 @@ pub fn run(cfg: &AppConfig) -> Result<()> {
// Keep the streams alive while the UI blocks.
let _streams = (input_stream, output_stream);
ui::tui::run(control, stats, info);
ui::run(control, stats, info);
if let Some((stop, handle, prev)) = cleanup {
stop.store(true, std::sync::atomic::Ordering::Relaxed);

View file

@ -1,88 +0,0 @@
use anyhow::{Context, Result};
use cpal::traits::{DeviceTrait, HostTrait};
use cpal::{
Device, FromSample, Host, SampleFormat, SizedSample, Stream, StreamConfig,
SupportedStreamConfig,
};
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
.output_devices()
.context("failed to enumerate output devices")?
.find(|d| d.name().map(|n| n == name).unwrap_or(false))
.with_context(|| format!("output device {name:?} not found")),
None => {
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")
}
}
}
/// Open the output stream. Pulls interleaved `f32` frames from `rx`; fills
/// with silence when the DSP worker has nothing ready yet.
pub fn start<C>(device: &Device, supported: &SupportedStreamConfig, rx: C) -> Result<Stream>
where
C: Consumer<Item = f32> + Send + 'static,
{
let config: StreamConfig = supported.config();
match supported.sample_format() {
SampleFormat::F32 => build::<f32, C>(device, &config, rx),
SampleFormat::F64 => build::<f64, C>(device, &config, rx),
SampleFormat::I8 => build::<i8, C>(device, &config, rx),
SampleFormat::I16 => build::<i16, C>(device, &config, rx),
SampleFormat::I32 => build::<i32, C>(device, &config, rx),
SampleFormat::I64 => build::<i64, C>(device, &config, rx),
SampleFormat::U8 => build::<u8, C>(device, &config, rx),
SampleFormat::U16 => build::<u16, C>(device, &config, rx),
SampleFormat::U32 => build::<u32, C>(device, &config, rx),
SampleFormat::U64 => build::<u64, C>(device, &config, rx),
format => anyhow::bail!("unsupported output sample format: {format:?}"),
}
}
fn build<T, C>(device: &Device, config: &StreamConfig, mut rx: C) -> Result<Stream>
where
T: SizedSample + FromSample<f32>,
C: Consumer<Item = f32> + Send + 'static,
{
let mut scratch = vec![0f32; 2048];
let err_fn = |err| eprintln!("playback stream error: {err}");
device
.build_output_stream(
config,
move |data: &mut [T], _: &cpal::OutputCallbackInfo| {
let mut written = 0usize;
while written < data.len() {
let want = (data.len() - written).min(scratch.len());
let n = rx.pop_slice(&mut scratch[..want]);
if n == 0 {
data[written..].fill(T::from_sample(0.0f32));
break;
}
let out = &mut data[written..written + n];
for (out_sample, in_sample) in out.iter_mut().zip(&scratch[..n]) {
*out_sample = T::from_sample(*in_sample);
}
written += n;
}
},
err_fn,
None,
)
.map_err(|e| anyhow::anyhow!("failed to build output stream: {e}"))
}

165
src/audio/stream.rs Normal file
View file

@ -0,0 +1,165 @@
//! Audio device selection and stream creation (capture + playback).
use anyhow::{Context, Result};
use cpal::traits::{DeviceTrait, HostTrait};
use cpal::{
Device, FromSample, Host, Sample, SampleFormat, SizedSample, Stream, StreamConfig,
SupportedStreamConfig,
};
use ringbuf::traits::{Consumer, Producer};
const PREFERRED_INPUTS: [&str; 3] = ["pipewire", "pulse", "default"];
const PREFERRED_OUTPUTS: [&str; 3] = ["pipewire", "pulse", "default"];
fn pick(
devices: impl Iterator<Item = Device>,
preferred: &[&str],
default: Option<Device>,
) -> Option<Device> {
let mut devices = devices;
devices
.find(|d| {
d.name()
.map(|n| preferred.contains(&n.as_str()))
.unwrap_or(false)
})
.or(default)
}
pub fn find_input(host: &Host, name: Option<&str>) -> Result<Device> {
match name {
Some(name) => host
.input_devices()
.context("failed to enumerate input devices")?
.find(|d| d.name().map(|n| n == name).unwrap_or(false))
.with_context(|| format!("input device {name:?} not found")),
None => pick(
host.input_devices()
.context("failed to enumerate input devices")?,
&PREFERRED_INPUTS,
host.default_input_device(),
)
.context("no default input device found"),
}
}
pub fn find_output(host: &Host, name: Option<&str>) -> Result<Device> {
match name {
Some(name) => host
.output_devices()
.context("failed to enumerate output devices")?
.find(|d| d.name().map(|n| n == name).unwrap_or(false))
.with_context(|| format!("output device {name:?} not found")),
None => pick(
host.output_devices()
.context("failed to enumerate output devices")?,
&PREFERRED_OUTPUTS,
host.default_output_device(),
)
.context("no default output device found"),
}
}
/// Open the input stream. Samples are converted to `f32` (interleaved frames)
/// and pushed into `tx`.
pub fn start_input<P>(device: &Device, supported: &SupportedStreamConfig, tx: P) -> Result<Stream>
where
P: Producer<Item = f32> + Send + 'static,
{
let config: StreamConfig = supported.config();
match supported.sample_format() {
SampleFormat::F32 => build_input::<f32, P>(device, &config, tx),
SampleFormat::F64 => build_input::<f64, P>(device, &config, tx),
SampleFormat::I8 => build_input::<i8, P>(device, &config, tx),
SampleFormat::I16 => build_input::<i16, P>(device, &config, tx),
SampleFormat::I32 => build_input::<i32, P>(device, &config, tx),
SampleFormat::I64 => build_input::<i64, P>(device, &config, tx),
SampleFormat::U8 => build_input::<u8, P>(device, &config, tx),
SampleFormat::U16 => build_input::<u16, P>(device, &config, tx),
SampleFormat::U32 => build_input::<u32, P>(device, &config, tx),
SampleFormat::U64 => build_input::<u64, P>(device, &config, tx),
format => anyhow::bail!("unsupported input sample format: {format:?}"),
}
}
fn build_input<T, P>(device: &Device, config: &StreamConfig, mut tx: P) -> Result<Stream>
where
T: SizedSample,
f32: FromSample<T>,
P: Producer<Item = f32> + Send + 'static,
{
let channels = config.channels as usize;
let mut frame = vec![0f32; channels];
let err_fn = |err| eprintln!("capture stream error: {err}");
device
.build_input_stream(
config,
move |data: &[T], _: &cpal::InputCallbackInfo| {
for chunk in data.chunks(channels) {
for (slot, sample) in frame.iter_mut().zip(chunk) {
*slot = f32::from_sample(*sample);
}
let _ = tx.push_slice(&frame);
}
},
err_fn,
None,
)
.map_err(|e| anyhow::anyhow!("failed to build input stream: {e}"))
}
/// Open the output stream. Pulls interleaved `f32` frames from `rx`; fills
/// with silence when nothing is ready.
pub fn start_output<C>(device: &Device, supported: &SupportedStreamConfig, rx: C) -> Result<Stream>
where
C: Consumer<Item = f32> + Send + 'static,
{
let config: StreamConfig = supported.config();
match supported.sample_format() {
SampleFormat::F32 => build_output::<f32, C>(device, &config, rx),
SampleFormat::F64 => build_output::<f64, C>(device, &config, rx),
SampleFormat::I8 => build_output::<i8, C>(device, &config, rx),
SampleFormat::I16 => build_output::<i16, C>(device, &config, rx),
SampleFormat::I32 => build_output::<i32, C>(device, &config, rx),
SampleFormat::I64 => build_output::<i64, C>(device, &config, rx),
SampleFormat::U8 => build_output::<u8, C>(device, &config, rx),
SampleFormat::U16 => build_output::<u16, C>(device, &config, rx),
SampleFormat::U32 => build_output::<u32, C>(device, &config, rx),
SampleFormat::U64 => build_output::<u64, C>(device, &config, rx),
format => anyhow::bail!("unsupported output sample format: {format:?}"),
}
}
fn build_output<T, C>(device: &Device, config: &StreamConfig, mut rx: C) -> Result<Stream>
where
T: SizedSample + FromSample<f32>,
C: Consumer<Item = f32> + Send + 'static,
{
let mut scratch = vec![0f32; 2048];
let err_fn = |err| eprintln!("playback stream error: {err}");
device
.build_output_stream(
config,
move |data: &mut [T], _: &cpal::OutputCallbackInfo| {
let mut written = 0usize;
while written < data.len() {
let want = (data.len() - written).min(scratch.len());
let n = rx.pop_slice(&mut scratch[..want]);
if n == 0 {
data[written..].fill(T::from_sample(0.0f32));
break;
}
let out = &mut data[written..written + n];
for (o, i) in out.iter_mut().zip(&scratch[..n]) {
*o = T::from_sample(*i);
}
written += n;
}
},
err_fn,
None,
)
.map_err(|e| anyhow::anyhow!("failed to build output stream: {e}"))
}

View file

@ -1,13 +1,3 @@
// Virtual microphone setup.
//
// Linux/FreeBSD (PipeWire/PulseAudio): creates a "vois.rs" null sink and
// remaps its monitor source to "vois.rs", keeps the app's stream routed there
// and restores the default sink on exit.
//
// Other platforms (Windows WASAPI, macOS CoreAudio): no built-in virtual mic.
// Point the app's output at a virtual cable (e.g. VB-CABLE "CABLE Input" on
// Windows, BlackHole on macOS) with `-o` and select it in the voice app.
use anyhow::Result;
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
use anyhow::{bail, Context};
@ -15,15 +5,8 @@ use anyhow::{bail, Context};
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use std::{thread, time::Duration};
pub struct VirtualMic;
// ---------------------------------------------------------------------------
// Linux / FreeBSD implementation (PipeWire / PulseAudio + pactl)
// ---------------------------------------------------------------------------
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
fn wireplumber_conf() -> std::path::PathBuf {
let base = std::env::var("XDG_CONFIG_HOME")
@ -37,12 +20,10 @@ fn wireplumber_conf() -> std::path::PathBuf {
.join("wireplumber.conf.d")
.join("51-vois.conf")
}
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
impl VirtualMic {
pub fn setup() -> Result<Option<String>> {
let pactl = "pactl";
let sinks = Command::new(pactl)
.args(["list", "short", "sinks"])
.output()
@ -51,7 +32,6 @@ impl VirtualMic {
bail!("`pactl list short sinks` failed");
}
let sinks_out = String::from_utf8_lossy(&sinks.stdout);
if !has_entry(&sinks_out, "vois.rs") {
let out = Command::new(pactl)
.args([
@ -69,7 +49,6 @@ impl VirtualMic {
);
}
}
let sources = Command::new(pactl)
.args(["list", "short", "sources"])
.output()
@ -78,7 +57,6 @@ impl VirtualMic {
bail!("`pactl list short sources` failed");
}
let sources_out = String::from_utf8_lossy(&sources.stdout);
if !has_entry(&sources_out, "vois.rs") {
let out = Command::new(pactl)
.args([
@ -96,25 +74,14 @@ impl VirtualMic {
);
}
}
if Self::ensure_wireplumber_fix() {
// First run: the fix needs a WirePlumber restart to apply.
Self::restart_wireplumber();
}
Ok(Some("vois.rs".to_string()))
}
/// WirePlumber remembers the target of each stream and restores it, so a
/// stream that once played to the speakers keeps going there even after the
/// default sink is switched. Disabling that makes ALSA streams follow the
/// default sink again. Returns true if a config was (re)written.
pub fn ensure_wireplumber_fix() -> bool {
let conf = wireplumber_conf();
let conf_text = "wireplumber.settings = {\n\
\x20 node.stream.restore-target = false\n\
\x20 node.stream.restore-props = false\n\
}\n";
let conf_text = "wireplumber.settings = {\n node.stream.restore-target = false\n node.stream.restore-props = false\n}\n";
if let Ok(existing) = std::fs::read_to_string(&conf) {
if existing.contains("restore-target = false") {
return false;
@ -125,9 +92,6 @@ impl VirtualMic {
}
std::fs::write(&conf, conf_text).is_ok()
}
/// Restart WirePlumber so a newly written config takes effect, and wait
/// until the audio server responds again.
pub fn restart_wireplumber() {
let _ = Command::new("systemctl")
.args(["--user", "restart", "wireplumber"])
@ -144,8 +108,6 @@ impl VirtualMic {
thread::sleep(Duration::from_millis(100));
}
}
/// Current default sink name, if any.
pub fn get_default_sink() -> Option<String> {
let out = Command::new("pactl")
.arg("get-default-sink")
@ -158,16 +120,11 @@ impl VirtualMic {
Some(name)
}
}
pub fn set_default_sink(name: &str) {
let _ = Command::new("pactl")
.args(["set-default-sink", name])
.output();
}
/// Wait (up to 2 s) until the default sink is actually `name`. WirePlumber
/// applies `set-default-sink` asynchronously; if the app creates its output
/// node before that, the node latches onto the old default.
pub fn wait_default_sink(name: &str) {
for _ in 0..40 {
if Self::get_default_sink().as_deref() == Some(name) {
@ -176,15 +133,6 @@ impl VirtualMic {
thread::sleep(Duration::from_millis(50));
}
}
/// Keep this app's streams (client name "vois") on the `vois.rs` sink and
/// every other app's streams off it (parked on `prev_sink`). Runs until
/// `stop`.
///
/// WirePlumber routes ALSA streams to the default sink, so if our output
/// node is created before the default is switched, it can end up on the
/// speakers; this thread pulls it back onto the virtual mic. Moving
/// streams explicitly sticks, so other apps' audio never leaks into it.
pub fn keep_others_off(stop: Arc<AtomicBool>, prev_sink: String) -> thread::JoinHandle<()> {
thread::spawn(move || {
while !stop.load(Ordering::Relaxed) {
@ -195,12 +143,10 @@ impl VirtualMic {
for (id, sink, client) in &inputs {
let ours = names.get(client).map(|n| n == "vois").unwrap_or(false);
if ours && sink != &lv {
// Our own stream drifted to the speakers.
let _ = Command::new("pactl")
.args(["move-sink-input", id, "vois.rs"])
.output();
} else if !ours && sink == &lv {
// Other app leaked into the virtual mic.
let _ = Command::new("pactl")
.args(["move-sink-input", id, &pv])
.output();
@ -212,9 +158,6 @@ impl VirtualMic {
}
})
}
/// Restore the default sink if the process is killed (SIGINT/SIGTERM),
/// so the user's audio isn't left pointing at the silent null sink.
pub fn restore_on_signal(prev: String) -> thread::JoinHandle<()> {
thread::spawn(move || {
let mut signals = match signal_hook::iterator::Signals::new([
@ -231,7 +174,6 @@ impl VirtualMic {
})
}
}
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
fn sink_id_named(name: &str) -> Option<String> {
let out = Command::new("pactl")
@ -246,7 +188,6 @@ fn sink_id_named(name: &str) -> Option<String> {
}
None
}
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
fn client_names() -> Result<std::collections::HashMap<String, String>> {
let out = Command::new("pactl")
@ -262,7 +203,6 @@ fn client_names() -> Result<std::collections::HashMap<String, String>> {
}
Ok(map)
}
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
fn sink_inputs() -> Result<Vec<(String, String, String)>> {
let out = Command::new("pactl")
@ -282,31 +222,21 @@ fn sink_inputs() -> Result<Vec<(String, String, String)>> {
}
Ok(inputs)
}
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
fn has_entry(list: &str, name: &str) -> bool {
list.lines()
.any(|l| l.split_whitespace().nth(1).is_some_and(|n| n == name))
}
// ---------------------------------------------------------------------------
// Other platforms: virtual mic is provided externally (VB-CABLE / BlackHole).
// ---------------------------------------------------------------------------
#[cfg(not(any(target_os = "linux", target_os = "freebsd")))]
impl VirtualMic {
pub fn setup() -> Result<Option<String>> {
Ok(None)
}
pub fn get_default_sink() -> Option<String> {
None
}
pub fn set_default_sink(_name: &str) {}
pub fn wait_default_sink(_name: &str) {}
pub fn keep_others_off(stop: Arc<AtomicBool>, _prev_sink: String) -> thread::JoinHandle<()> {
thread::spawn(move || {
while !stop.load(Ordering::Relaxed) {
@ -314,7 +244,6 @@ impl VirtualMic {
}
})
}
pub fn restore_on_signal(_prev: String) -> thread::JoinHandle<()> {
thread::spawn(|| {})
}

View file

@ -1,5 +1,5 @@
use crate::dsp::chain::Control;
use crate::dsp::presets::{presets, CustomPresetCfg, Preset};
use crate::dsp::control::Control;
use crate::dsp::preset::{presets, CustomPresetCfg, Preset};
use serde::Deserialize;
use std::collections::HashMap;
use std::path::PathBuf;

View file

@ -1,473 +0,0 @@
use crate::audio::record;
use crate::dsp::effects::Effect;
use crate::dsp::formant::ratio_from_semitones;
use crate::dsp::gate::NoiseGate;
use crate::dsp::pitch::PitchShifter;
use crate::dsp::presets::{build_effects, presets};
use crate::dsp::stats::Stats;
use ringbuf::traits::{Consumer, Producer};
use std::sync::mpsc::Sender;
use std::sync::{Arc, Mutex};
use std::thread;
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
/// Processing mode, switchable at runtime (A/B compare).
#[derive(Clone, Copy, PartialEq)]
pub enum Mode {
/// Normal processing through the DSP chain.
Live,
/// Raw input straight to output (bypass) for A/B comparison.
Passthrough,
}
impl Mode {
pub fn toggle(&mut self) {
*self = match self {
Mode::Live => Mode::Passthrough,
Mode::Passthrough => Mode::Live,
};
}
}
/// Live-adjustable state, shared between the UI and the DSP worker.
pub struct Control {
pub preset_idx: usize,
/// Manual pitch adjustment added to the preset value (semitones).
pub pitch_delta: f32,
/// Manual formant adjustment added to the preset value (semitones).
pub formant_delta: f32,
pub gate_threshold_db: f32,
pub gain: f32,
pub mute: bool,
pub record: bool,
pub record_path: Option<String>,
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,
}
impl Default for Control {
fn default() -> Self {
Self {
preset_idx: 0,
pitch_delta: 0.0,
formant_delta: 0.0,
gate_threshold_db: -100.0,
gain: 1.0,
mute: false,
record: false,
record_path: None,
run_seconds: None,
tone_hz: 0.0,
pitch_correct: false,
mode: Mode::Live,
quit: false,
}
}
}
pub struct Processor {
fs: u32,
control: Arc<Mutex<Control>>,
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,
recorder_tx: Option<Sender<Vec<f32>>>,
recorder_handle: Option<JoinHandle<()>>,
}
impl Processor {
pub fn new(fs: u32, control: Arc<Mutex<Control>>, stats: Arc<Mutex<Stats>>) -> Self {
Self {
fs,
control,
stats,
shifter: None,
shift_pending: Vec::new(),
correction: 0.0,
gate: NoiseGate::new(-60.0),
effects: Vec::new(),
last_preset: usize::MAX,
recorder_tx: None,
recorder_handle: None,
}
}
/// Stop recording and wait for the WAV writer to finalize.
fn stop_recording(&mut self) {
self.recorder_tx = None;
if let Some(handle) = self.recorder_handle.take() {
let _ = handle.join();
}
}
/// Process one fixed-size mono block through the whole chain.
pub fn process_block(&mut self, mono: &mut [f32]) {
if mono.is_empty() {
return;
}
let t0 = Instant::now();
// Input level (pre-processing).
let mut in_sum = 0.0;
for &s in mono.iter() {
in_sum += s * s;
}
let in_rms = (in_sum / mono.len() as f32).sqrt();
let (
preset_idx,
pitch,
formant,
gate_db,
gain,
preset_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)];
(
ctrl.preset_idx.min(presets().len() - 1),
preset.pitch_semitones + ctrl.pitch_delta,
preset.formant_ratio * ratio_from_semitones(ctrl.formant_delta),
// User-set gate wins; otherwise fall back to the preset's gate.
if ctrl.gate_threshold_db > -90.0 {
ctrl.gate_threshold_db
} else {
preset.gate_db
},
ctrl.gain,
preset.gain,
ctrl.mute,
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.last_preset = preset_idx;
}
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);
} else if mode == Mode::Passthrough {
// A/B compare: raw input, only gain.
for s in mono.iter_mut() {
*s *= gain;
}
} else {
self.gate.set_threshold_db(gate_db);
self.gate.process(mono);
if shifter_active {
if self.shifter.is_none() {
self.shifter = Some(PitchShifter::new(1024, 256, self.fs));
// Prime the extraction buffer with the vocoder's fixed latency
// (n - hop) so every input block yields a full output block.
self.shift_pending = vec![0.0; 1024 - 256];
}
let shifter = self.shifter.as_mut().unwrap();
// 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);
// The vocoder is a streaming delay line: per call it may emit a
// different number of samples than it consumed. Keep the excess
// in `shift_pending` and pull exactly `mono.len()` samples out,
// so the rest of the chain sees constant block sizes.
self.shift_pending.extend(out);
let take = mono.len().min(self.shift_pending.len());
mono[..take].copy_from_slice(&self.shift_pending[..take]);
mono[take..].fill(0.0);
self.shift_pending.drain(..take);
}
for effect in &mut self.effects {
effect.process(mono);
}
for s in mono.iter_mut() {
*s *= preset_gain * gain;
}
}
if record {
if self.recorder_tx.is_none() {
if let Some(path) = record_path {
let (tx, handle) = record::spawn_writer(path, self.fs);
self.recorder_tx = Some(tx);
self.recorder_handle = Some(handle);
}
}
if let Some(tx) = &self.recorder_tx {
let _ = tx.send(mono.to_vec());
}
} else if self.recorder_tx.is_some() {
self.stop_recording();
}
// Output level + runtime stats.
let mut out_sum = 0.0;
for &s in mono.iter() {
out_sum += s * s;
}
let out_rms = (out_sum / mono.len() as f32).sqrt();
let elapsed = t0.elapsed().as_secs_f32();
let block_dur = mono.len() as f32 / self.fs as f32;
let cpu = if block_dur > 0.0 {
elapsed / block_dur * 100.0
} else {
0.0
};
let mut st = match self.stats.lock() {
Ok(s) => s,
Err(p) => p.into_inner(),
};
st.input_rms = st.input_rms * 0.85 + in_rms * 0.15;
st.output_rms = st.output_rms * 0.85 + out_rms * 0.15;
st.peak_in = st.peak_in.max(in_rms);
st.peak_out = st.peak_out.max(out_rms);
st.blocks += 1;
st.samples += mono.len() as u64;
st.cpu_pct = st.cpu_pct * 0.9 + cpu * 0.1;
// DSP latency estimate: block buffering + vocoder frame-fill when active.
let latency_samples = if shifter_active { 768 } else { 0 } + 256;
st.dsp_latency_ms = latency_samples as f32 / self.fs as f32 * 1000.0;
}
}
/// DSP worker thread: pulls captured audio, runs the chain, pushes the result
/// to the output.
pub fn run_worker<I, O>(
mut input: I,
mut output: O,
in_ch: usize,
out_ch: usize,
fs: u32,
control: Arc<Mutex<Control>>,
stats: Arc<Mutex<Stats>>,
) where
I: Consumer<Item = f32> + Send + 'static,
O: Producer<Item = f32> + Send + 'static,
{
let mut processor = Processor::new(fs, control, stats);
let mut scratch = vec![0f32; 8192];
let mut acc = Vec::with_capacity(8192);
let mut block = vec![0f32; 256];
let mut out = Vec::with_capacity(8192);
let mut total_blocks: u64 = 0;
let mut total_samples: u64 = 0;
let mut tone_phase: f32 = 0.0;
loop {
if processor.control.lock().map(|c| c.quit).unwrap_or(false) {
break;
}
let n = input.pop_slice(&mut scratch);
if n == 0 {
thread::sleep(Duration::from_millis(1));
continue;
}
total_samples += n as u64;
let frames = n / in_ch.max(1);
for f in 0..frames {
let mut mix = 0.0;
for c in 0..in_ch {
mix += scratch[f * in_ch + c];
}
acc.push(mix / in_ch as f32);
}
// Optional test tone injected before the DSP chain.
let tone_hz = processor.control.lock().map(|c| c.tone_hz).unwrap_or(0.0);
if tone_hz > 0.0 {
let start = acc.len().saturating_sub(frames);
for v in acc.iter_mut().skip(start) {
*v += 0.5 * (2.0 * std::f32::consts::PI * tone_phase).sin();
tone_phase += tone_hz / fs as f32;
if tone_phase >= 1.0 {
tone_phase -= 1.0;
}
}
}
// Process in fixed-size blocks (multiple of the vocoder hop) so the
// pitch shifter always yields a full block. Smaller blocks cut the
// worst-case buffering latency; the shifter stream is unchanged.
while acc.len() >= 256 {
block.copy_from_slice(&acc[..256]);
acc.drain(..256);
total_blocks += 1;
processor.process_block(&mut block);
out.clear();
for v in &block {
for _ in 0..out_ch {
out.push(*v);
}
}
let _ = output.push_slice(&out);
}
}
eprintln!("dsp: processed {total_blocks} blocks / {total_samples} samples");
processor.stop_recording();
}
#[cfg(test)]
mod tests {
use super::*;
fn rms(x: &[f32]) -> f32 {
let s: f32 = x.iter().map(|v| v * v).sum();
(s / x.len().max(1) as f32).sqrt()
}
fn dominant_hz(x: &[f32], fs: u32) -> f32 {
use rustfft::num_complex::Complex;
use rustfft::FftPlanner;
let n = 4096;
if x.len() < n {
return 0.0;
}
let mut planner = FftPlanner::<f32>::new();
let fft = planner.plan_fft_forward(n);
let mut buf: Vec<Complex<f32>> = x[..n].iter().map(|&s| Complex::new(s, 0.0)).collect();
fft.process(&mut buf);
let bins = n / 2;
let mags: Vec<f32> = (0..bins).map(|b| buf[b].norm()).collect();
let mut best = (0usize, 0.0f32);
for b in 20..(bins / 4) {
let mut s = 0.0;
for h in 1..=4 {
if b * h < bins {
s += mags[b * h];
}
}
if s > best.1 {
best = (b, s);
}
}
fs as f32 * best.0 as f32 / n as f32
}
fn preset_control(idx: usize) -> Arc<Mutex<Control>> {
Arc::new(Mutex::new(Control {
preset_idx: idx,
..Control::default()
}))
}
#[test]
fn girl_preset_produces_energy_on_sine() {
// Clean preset + manual pitch delta = pitch-only (no formant shift).
let control = Arc::new(Mutex::new(Control {
preset_idx: 0,
pitch_delta: 5.0,
..Control::default()
}));
let mut p = Processor::new(48000, control, Arc::new(Mutex::new(Stats::default())));
// Harmonic-rich tone (closer to voice than a pure sine).
let input: Vec<f32> = (0..96000)
.map(|i| {
let t = 220.0 * i as f32 / 48000.0;
let mut s = 0.0;
for h in 1..=12 {
s += (2.0 * std::f32::consts::PI * h as f32 * t).sin() / h as f32;
}
s
})
.collect();
let out = feed_blocks(&mut p, &input);
let r = rms(&out[4000..]);
assert!(r.is_finite() && r > 0.05, "girl sine rms too low: {r}");
// Pitch must actually shift: 220 Hz * 2^(5/12) ~= 293.7 Hz.
let est = dominant_hz(&out[20000..96000], 48000);
assert!(
(est - 293.7).abs() < 12.0,
"girl pitch expected ~293.7 Hz, got {est:.1} Hz"
);
}
#[test]
fn girl_preset_produces_energy_on_noise() {
let control = preset_control(1);
let mut p = Processor::new(48000, control, Arc::new(Mutex::new(Stats::default())));
let mut state = 0x12345678u32;
let mut input = Vec::new();
for _ in 0..96000 {
state ^= state << 13;
state ^= state >> 17;
state ^= state << 5;
input.push(state as f32 / u32::MAX as f32 * 2.0 - 1.0);
}
// Scale to ~mic-hiss level.
for v in input.iter_mut() {
*v *= 0.01;
}
let out = feed_blocks(&mut p, &input);
let r = rms(&out[4000..]);
assert!(r.is_finite() && r > 0.0005, "girl noise rms too low: {r}");
}
/// Feed input through the processor in the same fixed-size blocks the
/// real worker uses, collecting the output.
fn feed_blocks(p: &mut Processor, input: &[f32]) -> Vec<f32> {
let mut out_all = Vec::with_capacity(input.len());
for chunk in input.chunks(256) {
let mut block = chunk.to_vec();
block.resize(256, 0.0);
p.process_block(&mut block);
out_all.extend_from_slice(&block[..chunk.len()]);
}
out_all
}
}

230
src/dsp/chain/mod.rs Normal file
View file

@ -0,0 +1,230 @@
//! The DSP chain: one mono block through gate -> pitch/formant -> effects.
#[cfg(test)]
mod tests;
pub mod worker;
use crate::audio::record;
use crate::dsp::control::{Control, Mode, Stats};
use crate::dsp::formant::ratio_from_semitones;
use crate::dsp::fx::filter::NoiseGate;
use crate::dsp::fx::Effect;
use crate::dsp::preset::{build_effects, presets};
use crate::dsp::shift::PitchShifter;
use std::sync::mpsc::Sender;
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::Instant;
pub struct Processor {
fs: u32,
control: Arc<Mutex<Control>>,
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,
recorder_tx: Option<Sender<Vec<f32>>>,
recorder_handle: Option<JoinHandle<()>>,
}
impl Processor {
pub fn new(fs: u32, control: Arc<Mutex<Control>>, stats: Arc<Mutex<Stats>>) -> Self {
Self {
fs,
control,
stats,
shifter: None,
shift_pending: Vec::new(),
correction: 0.0,
gate: NoiseGate::new(-60.0),
effects: Vec::new(),
last_preset: usize::MAX,
recorder_tx: None,
recorder_handle: None,
}
}
/// Stop recording and wait for the WAV writer to finalize.
pub(crate) fn stop_recording(&mut self) {
self.recorder_tx = None;
if let Some(handle) = self.recorder_handle.take() {
let _ = handle.join();
}
}
/// Process one fixed-size mono block through the whole chain.
pub fn process_block(&mut self, mono: &mut [f32]) {
if mono.is_empty() {
return;
}
let t0 = Instant::now();
let mut in_sum = 0.0;
for &s in mono.iter() {
in_sum += s * s;
}
let in_rms = (in_sum / mono.len() as f32).sqrt();
let (
preset_idx,
pitch,
formant,
gate_db,
gain,
preset_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)];
(
ctrl.preset_idx.min(presets().len() - 1),
preset.pitch_semitones + ctrl.pitch_delta,
preset.formant_ratio * ratio_from_semitones(ctrl.formant_delta),
// User-set gate wins; otherwise fall back to the preset's gate.
if ctrl.gate_threshold_db > -90.0 {
ctrl.gate_threshold_db
} else {
preset.gate_db
},
ctrl.gain,
preset.gain,
ctrl.mute,
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.last_preset = preset_idx;
}
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);
} else if mode == Mode::Passthrough {
for s in mono.iter_mut() {
*s *= gain;
}
} else {
self.gate.set_threshold_db(gate_db);
self.gate.process(mono);
if shifter_active {
self.shift(mono, pitch, formant, pitch_correct);
}
for effect in &mut self.effects {
effect.process(mono);
}
for s in mono.iter_mut() {
*s *= preset_gain * gain;
}
}
self.handle_record(mono, record, record_path);
self.update_stats(mono, in_rms, t0, shifter_active);
}
/// Pitch/formant shift with streaming OLA extraction.
fn shift(&mut self, mono: &mut [f32], pitch: f32, formant: f32, pitch_correct: bool) {
if self.shifter.is_none() {
self.shifter = Some(PitchShifter::new(1024, 256, self.fs));
// Prime with the vocoder's fixed latency so every input block
// yields a full output block.
self.shift_pending = vec![0.0; 1024 - 256];
}
let shifter = self.shifter.as_mut().unwrap();
// Auto-Tune: glide the manual pitch toward the nearest semitone of
// the detected input fundamental (smoothed so it doesn't wobble).
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);
// The vocoder is a streaming delay line: it may emit a different
// number of samples than consumed. Buffer the excess and pull exactly
// `mono.len()` samples out.
self.shift_pending.extend(out);
let take = mono.len().min(self.shift_pending.len());
mono[..take].copy_from_slice(&self.shift_pending[..take]);
mono[take..].fill(0.0);
self.shift_pending.drain(..take);
}
fn handle_record(&mut self, mono: &[f32], record: bool, record_path: Option<String>) {
if record {
if self.recorder_tx.is_none() {
if let Some(path) = record_path {
let (tx, handle) = record::spawn_writer(path, self.fs);
self.recorder_tx = Some(tx);
self.recorder_handle = Some(handle);
}
}
if let Some(tx) = &self.recorder_tx {
let _ = tx.send(mono.to_vec());
}
} else if self.recorder_tx.is_some() {
self.stop_recording();
}
}
fn update_stats(&mut self, mono: &[f32], in_rms: f32, t0: Instant, shifter_active: bool) {
let mut out_sum = 0.0;
for &s in mono.iter() {
out_sum += s * s;
}
let out_rms = (out_sum / mono.len() as f32).sqrt();
let elapsed = t0.elapsed().as_secs_f32();
let block_dur = mono.len() as f32 / self.fs as f32;
let cpu = if block_dur > 0.0 {
elapsed / block_dur * 100.0
} else {
0.0
};
let mut st = match self.stats.lock() {
Ok(s) => s,
Err(p) => p.into_inner(),
};
st.input_rms = st.input_rms * 0.85 + in_rms * 0.15;
st.output_rms = st.output_rms * 0.85 + out_rms * 0.15;
st.peak_in = st.peak_in.max(in_rms);
st.peak_out = st.peak_out.max(out_rms);
st.blocks += 1;
st.samples += mono.len() as u64;
st.cpu_pct = st.cpu_pct * 0.9 + cpu * 0.1;
// DSP latency estimate: block buffering + vocoder frame-fill.
let latency_samples = if shifter_active { 768 } else { 0 } + 256;
st.dsp_latency_ms = latency_samples as f32 / self.fs as f32 * 1000.0;
}
}

101
src/dsp/chain/tests.rs Normal file
View file

@ -0,0 +1,101 @@
use super::Processor;
use crate::dsp::control::{Control, Stats};
use std::sync::{Arc, Mutex};
fn rms(x: &[f32]) -> f32 {
let s: f32 = x.iter().map(|v| v * v).sum();
(s / x.len().max(1) as f32).sqrt()
}
fn dominant_hz(x: &[f32], fs: u32) -> f32 {
use rustfft::num_complex::Complex;
use rustfft::FftPlanner;
let n = 4096;
if x.len() < n {
return 0.0;
}
let mut planner = FftPlanner::<f32>::new();
let fft = planner.plan_fft_forward(n);
let mut buf: Vec<Complex<f32>> = x[..n].iter().map(|&s| Complex::new(s, 0.0)).collect();
fft.process(&mut buf);
let bins = n / 2;
let mags: Vec<f32> = (0..bins).map(|b| buf[b].norm()).collect();
let mut best = (0usize, 0.0f32);
for b in 20..(bins / 4) {
let mut s = 0.0;
for h in 1..=4 {
if b * h < bins {
s += mags[b * h];
}
}
if s > best.1 {
best = (b, s);
}
}
fs as f32 * best.0 as f32 / n as f32
}
fn feed_blocks(p: &mut Processor, input: &[f32]) -> Vec<f32> {
let mut out_all = Vec::with_capacity(input.len());
for chunk in input.chunks(256) {
let mut block = chunk.to_vec();
block.resize(256, 0.0);
p.process_block(&mut block);
out_all.extend_from_slice(&block[..chunk.len()]);
}
out_all
}
#[test]
fn girl_preset_produces_energy_on_sine() {
let control = Arc::new(Mutex::new(Control {
preset_idx: 0,
pitch_delta: 5.0,
..Control::default()
}));
let mut p = Processor::new(48000, control, Arc::new(Mutex::new(Stats::default())));
// Harmonic-rich tone (closer to voice than a pure sine).
let input: Vec<f32> = (0..96000)
.map(|i| {
let t = 220.0 * i as f32 / 48000.0;
let mut s = 0.0;
for h in 1..=12 {
s += (2.0 * std::f32::consts::PI * h as f32 * t).sin() / h as f32;
}
s
})
.collect();
let out = feed_blocks(&mut p, &input);
let r = rms(&out[4000..]);
assert!(r.is_finite() && r > 0.05, "girl sine rms too low: {r}");
// Pitch must actually shift: 220 Hz * 2^(5/12) ~= 293.7 Hz.
let est = dominant_hz(&out[20000..96000], 48000);
assert!(
(est - 293.7).abs() < 12.0,
"girl pitch expected ~293.7 Hz, got {est:.1} Hz"
);
}
#[test]
fn girl_preset_produces_energy_on_noise() {
let control = Arc::new(Mutex::new(Control {
preset_idx: 1,
..Control::default()
}));
let mut p = Processor::new(48000, control, Arc::new(Mutex::new(Stats::default())));
let mut state = 0x12345678u32;
let mut input = Vec::new();
for _ in 0..96000 {
state ^= state << 13;
state ^= state >> 17;
state ^= state << 5;
input.push(state as f32 / u32::MAX as f32 * 2.0 - 1.0);
}
for v in input.iter_mut() {
*v *= 0.01;
}
let out = feed_blocks(&mut p, &input);
let r = rms(&out[4000..]);
assert!(r.is_finite() && r > 0.0005, "girl noise rms too low: {r}");
}

86
src/dsp/chain/worker.rs Normal file
View file

@ -0,0 +1,86 @@
//! The DSP worker thread: pulls captured audio, runs the chain, pushes output.
use super::Processor;
use crate::dsp::control::{Control, Stats};
use ringbuf::traits::{Consumer, Producer};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
pub fn run_worker<I, O>(
mut input: I,
mut output: O,
in_ch: usize,
out_ch: usize,
fs: u32,
control: Arc<Mutex<Control>>,
stats: Arc<Mutex<Stats>>,
) where
I: Consumer<Item = f32> + Send + 'static,
O: Producer<Item = f32> + Send + 'static,
{
let mut processor = Processor::new(fs, control, stats);
let mut scratch = vec![0f32; 8192];
let mut acc = Vec::with_capacity(8192);
let mut block = vec![0f32; 256];
let mut out = Vec::with_capacity(8192);
let mut total_blocks: u64 = 0;
let mut total_samples: u64 = 0;
let mut tone_phase: f32 = 0.0;
loop {
if processor.control.lock().map(|c| c.quit).unwrap_or(false) {
break;
}
let n = input.pop_slice(&mut scratch);
if n == 0 {
thread::sleep(Duration::from_millis(1));
continue;
}
total_samples += n as u64;
let frames = n / in_ch.max(1);
for f in 0..frames {
let mut mix = 0.0;
for c in 0..in_ch {
mix += scratch[f * in_ch + c];
}
acc.push(mix / in_ch as f32);
}
// Optional test tone injected before the DSP chain.
let tone_hz = processor.control.lock().map(|c| c.tone_hz).unwrap_or(0.0);
if tone_hz > 0.0 {
let start = acc.len().saturating_sub(frames);
for v in acc.iter_mut().skip(start) {
*v += 0.5 * (2.0 * std::f32::consts::PI * tone_phase).sin();
tone_phase += tone_hz / fs as f32;
if tone_phase >= 1.0 {
tone_phase -= 1.0;
}
}
}
// Process in fixed-size blocks (multiple of the vocoder hop) so the
// pitch shifter always yields a full block.
while acc.len() >= 256 {
block.copy_from_slice(&acc[..256]);
acc.drain(..256);
total_blocks += 1;
processor.process_block(&mut block);
out.clear();
for v in &block {
for _ in 0..out_ch {
out.push(*v);
}
}
let _ = output.push_slice(&out);
}
}
eprintln!("dsp: processed {total_blocks} blocks / {total_samples} samples");
processor.stop_recording();
}

95
src/dsp/control.rs Normal file
View file

@ -0,0 +1,95 @@
//! Shared runtime state: UI control knobs and DSP statistics.
use std::time::Instant;
/// Processing mode, switchable at runtime (A/B compare).
#[derive(Clone, Copy, PartialEq)]
pub enum Mode {
/// Normal processing through the DSP chain.
Live,
/// Raw input straight to output (bypass) for A/B comparison.
Passthrough,
}
impl Mode {
pub fn toggle(&mut self) {
*self = match self {
Mode::Live => Mode::Passthrough,
Mode::Passthrough => Mode::Live,
};
}
}
/// Live-adjustable state, shared between the UI and the DSP worker.
pub struct Control {
pub preset_idx: usize,
/// Manual pitch adjustment added to the preset value (semitones).
pub pitch_delta: f32,
/// Manual formant adjustment added to the preset value (semitones).
pub formant_delta: f32,
pub gate_threshold_db: f32,
pub gain: f32,
pub mute: bool,
pub record: bool,
pub record_path: Option<String>,
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,
}
impl Default for Control {
fn default() -> Self {
Self {
preset_idx: 0,
pitch_delta: 0.0,
formant_delta: 0.0,
gate_threshold_db: -100.0,
gain: 1.0,
mute: false,
record: false,
record_path: None,
run_seconds: None,
tone_hz: 0.0,
pitch_correct: false,
mode: Mode::Live,
quit: false,
}
}
}
/// Runtime statistics produced by the DSP worker and shown in the TUI.
pub struct Stats {
/// Smoothed input level (RMS, linear).
pub input_rms: f32,
/// Smoothed output level (RMS, linear).
pub output_rms: f32,
pub peak_in: f32,
pub peak_out: f32,
pub blocks: u64,
pub samples: u64,
/// Smoothed DSP load in percent of one core.
pub cpu_pct: f32,
/// Estimated DSP latency in milliseconds.
pub dsp_latency_ms: f32,
pub started: Instant,
}
impl Default for Stats {
fn default() -> Self {
Self {
input_rms: 0.0,
output_rms: 0.0,
peak_in: 0.0,
peak_out: 0.0,
blocks: 0,
samples: 0,
cpu_pct: 0.0,
dsp_latency_ms: 0.0,
started: Instant::now(),
}
}
}

View file

@ -1,750 +0,0 @@
//! Effects chain: small processing units applied to the mono stream after
//! pitch/formant shifting. Each effect mutates the block in place.
use std::f32::consts::PI;
pub trait Effect: Send {
fn process(&mut self, mono: &mut [f32]);
}
// ---------------------------------------------------------------------------
// Distortion (waveshaper)
// ---------------------------------------------------------------------------
pub struct Distortion {
drive: f32,
output: f32,
}
impl Distortion {
pub fn new(drive: f32, output: f32) -> Self {
Self { drive, output }
}
}
impl Effect for Distortion {
fn process(&mut self, mono: &mut [f32]) {
for s in mono.iter_mut() {
*s = (*s * self.drive).tanh() * self.output;
}
}
}
// ---------------------------------------------------------------------------
// Bitcrusher (bit depth reduction + sample rate reduction)
// ---------------------------------------------------------------------------
pub struct Bitcrush {
bits: u8,
sample_rate_div: u32,
/// Max boost applied to quiet input (auto-level normalization).
max_gain: f32,
/// Target peak level after normalization.
target: f32,
gain_smooth: f32,
hold: f32,
count: u32,
}
impl Bitcrush {
/// `max_gain`/`target` normalize each block before quantization so the
/// effect works on quiet mics without clipping loud ones.
pub fn new(bits: u8, sample_rate_div: u32, max_gain: f32, target: f32) -> Self {
Self {
bits: bits.max(2),
sample_rate_div: sample_rate_div.max(1),
max_gain: max_gain.max(1.0),
target: target.clamp(0.1, 1.0),
gain_smooth: 1.0,
hold: 0.0,
count: 0,
}
}
}
impl Effect for Bitcrush {
fn process(&mut self, mono: &mut [f32]) {
if mono.is_empty() {
return;
}
let levels = (1i32 << (self.bits - 1)) as f32;
// Auto-level: bring the block peak up to `target` (with smoothing).
let peak = mono.iter().fold(0.0f32, |m, s| m.max(s.abs()));
let gain = if peak > 1e-6 {
(self.target / peak).clamp(1.0, self.max_gain)
} else {
self.max_gain
};
self.gain_smooth += (gain - self.gain_smooth) * 0.3;
let g = self.gain_smooth;
for s in mono.iter_mut() {
self.count = (self.count + 1) % self.sample_rate_div;
if self.count == 0 {
self.hold = *s;
}
let q = (self.hold * g * levels).round();
*s = (q / levels).clamp(-1.0, 1.0);
}
}
}
// ---------------------------------------------------------------------------
// Freeverb-style reverb
// ---------------------------------------------------------------------------
struct Comb {
buf: Vec<f32>,
idx: usize,
filter: f32,
feedback: f32,
damp1: f32,
damp2: f32,
}
impl Comb {
fn new(len: usize) -> Self {
Self {
buf: vec![0.0; len],
idx: 0,
filter: 0.0,
feedback: 0.84,
damp1: 0.5,
damp2: 0.5,
}
}
#[inline]
fn process(&mut self, input: f32) -> f32 {
let output = self.buf[self.idx];
self.filter = output * self.damp2 + self.filter * self.damp1;
self.buf[self.idx] = input + self.filter * self.feedback;
self.idx += 1;
if self.idx >= self.buf.len() {
self.idx = 0;
}
output
}
}
struct Allpass {
buf: Vec<f32>,
idx: usize,
feedback: f32,
}
impl Allpass {
fn new(len: usize) -> Self {
Self {
buf: vec![0.0; len],
idx: 0,
feedback: 0.5,
}
}
#[inline]
fn process(&mut self, input: f32) -> f32 {
let bufout = self.buf[self.idx];
self.buf[self.idx] = input + bufout * self.feedback;
self.idx += 1;
if self.idx >= self.buf.len() {
self.idx = 0;
}
bufout - input
}
}
pub struct Reverb {
combs: Vec<Comb>,
allpasses: Vec<Allpass>,
wet: f32,
room: f32,
damp: f32,
}
impl Reverb {
pub fn new(sample_rate: u32, room: f32, damp: f32, wet: f32) -> Self {
let scale = sample_rate as f32 / 44100.0;
let comb_lens = [1116, 1188, 1277, 1356, 1422, 1491, 1557, 1617];
let allpass_lens = [556, 441, 341, 225];
let combs: Vec<Comb> = comb_lens
.iter()
.map(|&l| Comb::new((l as f32 * scale) as usize))
.collect();
let allpasses: Vec<Allpass> = allpass_lens
.iter()
.map(|&l| Allpass::new((l as f32 * scale) as usize))
.collect();
let mut reverb = Self {
combs,
allpasses,
wet: wet.clamp(0.0, 1.0),
room: room.clamp(0.0, 1.0),
damp: damp.clamp(0.0, 1.0),
};
reverb.apply_params();
reverb
}
fn apply_params(&mut self) {
// Map room size 0..1 to feedback 0..~0.98.
let feedback = self.room * 0.88 + 0.10;
for comb in &mut self.combs {
comb.feedback = feedback;
comb.damp1 = self.damp;
comb.damp2 = 1.0 - self.damp;
}
}
}
impl Effect for Reverb {
fn process(&mut self, mono: &mut [f32]) {
let wet = self.wet;
let dry = 1.0 - wet;
for s in mono.iter_mut() {
let input = *s;
let mut acc = 0.0;
for comb in &mut self.combs {
acc += comb.process(input);
}
acc /= self.combs.len() as f32;
for ap in &mut self.allpasses {
acc = ap.process(acc);
}
*s = input * dry + acc * wet * 1.5;
}
}
}
// ---------------------------------------------------------------------------
// Chorus
// ---------------------------------------------------------------------------
pub struct Chorus {
delay: Vec<f32>,
idx: usize,
cap: usize,
depth: f32,
rate: f32,
wet: f32,
phase: f32,
fs: f32,
}
impl Chorus {
pub fn new(sample_rate: u32, depth_ms: f32, rate_hz: f32, wet: f32) -> Self {
let depth = depth_ms / 1000.0 * sample_rate as f32;
let cap = (sample_rate as f32 * 0.1) as usize + (depth * 4.0) as usize;
Self {
delay: vec![0.0; cap],
idx: 0,
cap,
depth: depth.max(1.0),
rate: rate_hz,
wet: wet.clamp(0.0, 1.0),
phase: 0.0,
fs: sample_rate as f32,
}
}
#[inline]
fn read(&self, offset: f32) -> f32 {
let mut pos = self.idx as f32 - offset;
if pos < 0.0 {
pos += self.cap as f32;
}
let i = pos.floor() as usize;
let j = (i + 1) % self.cap;
let t = pos - i as f32;
self.delay[i] * (1.0 - t) + self.delay[j] * t
}
}
impl Effect for Chorus {
fn process(&mut self, mono: &mut [f32]) {
let depth = self.depth;
let rate = self.rate;
let wet = self.wet;
let dry = 1.0 - wet;
let cap = self.cap as f32;
for s in mono.iter_mut() {
self.delay[self.idx] = *s;
let lfo = (2.0 * PI * self.phase).sin();
// Two slightly dephased voices.
let d1 = depth * (0.35 + 0.35 * lfo);
let d2 = depth * (0.35 - 0.35 * lfo);
let v1 = self.read(d1);
let v2 = self.read(d2);
*s = *s * dry + (v1 + v2) * 0.5 * wet;
self.idx += 1;
if self.idx as f32 >= cap {
self.idx = 0;
}
self.phase += rate / self.fs;
if self.phase >= 1.0 {
self.phase -= 1.0;
}
}
}
}
// ---------------------------------------------------------------------------
// Ring modulator
// ---------------------------------------------------------------------------
pub struct RingMod {
freq: f32,
depth: f32,
phase: f32,
fs: f32,
}
impl RingMod {
pub fn new(sample_rate: u32, freq_hz: f32, depth: f32) -> Self {
Self {
freq: freq_hz,
depth: depth.clamp(0.0, 1.0),
phase: 0.0,
fs: sample_rate as f32,
}
}
}
impl Effect for RingMod {
fn process(&mut self, mono: &mut [f32]) {
let depth = self.depth;
for s in mono.iter_mut() {
let m = 1.0 - depth + depth * (2.0 * PI * self.phase).sin();
*s *= m;
self.phase += self.freq / self.fs;
if self.phase >= 1.0 {
self.phase -= 1.0;
}
}
}
}
// ---------------------------------------------------------------------------
// Biquad filters (RBJ cookbook) for bandpass / narrow EQ
// ---------------------------------------------------------------------------
pub struct Biquad {
b0: f32,
b1: f32,
b2: f32,
a1: f32,
a2: f32,
z1: f32,
z2: f32,
}
impl Biquad {
/// Constant-skirt bandpass.
pub fn bandpass(sample_rate: u32, f0: f32, q: f32) -> Self {
let w0 = 2.0 * PI * f0 / sample_rate as f32;
let alpha = w0.sin() / (2.0 * q);
let b0 = alpha;
let b1 = 0.0;
let b2 = -alpha;
let a0 = 1.0 + alpha;
let a1 = -2.0 * w0.cos();
let a2 = 1.0 - alpha;
Self::normalized(b0, b1, b2, a1, a2, a0)
}
fn normalized(b0: f32, b1: f32, b2: f32, a1: f32, a2: f32, a0: f32) -> Self {
Self {
b0: b0 / a0,
b1: b1 / a0,
b2: b2 / a0,
a1: a1 / a0,
a2: a2 / a0,
z1: 0.0,
z2: 0.0,
}
}
#[inline]
pub fn tick(&mut self, x: f32) -> f32 {
let y = self.b0 * x + self.z1;
self.z1 = self.b1 * x - self.a1 * y + self.z2;
self.z2 = self.b2 * x - self.a2 * y;
y
}
}
pub struct Bandpass {
filter: Biquad,
mix: f32,
}
impl Bandpass {
pub fn new(sample_rate: u32, low_hz: f32, high_hz: f32, mix: f32) -> Self {
let f0 = (low_hz * high_hz).sqrt();
let q = f0 / (high_hz - low_hz).max(1.0);
Self {
filter: Biquad::bandpass(sample_rate, f0, q),
mix: mix.clamp(0.0, 1.0),
}
}
}
impl Effect for Bandpass {
fn process(&mut self, mono: &mut [f32]) {
let mix = self.mix;
for s in mono.iter_mut() {
let y = self.filter.tick(*s);
*s = *s * (1.0 - mix) + y * mix;
}
}
}
// ---------------------------------------------------------------------------
// White noise generator
// ---------------------------------------------------------------------------
pub struct Noise {
gain: f32,
state: u32,
}
impl Noise {
pub fn new(gain: f32) -> Self {
Self {
gain,
state: 0x9E3779B9,
}
}
#[inline]
fn next(&mut self) -> f32 {
let mut x = self.state;
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
self.state = x;
x as f32 / u32::MAX as f32 * 2.0 - 1.0
}
}
impl Effect for Noise {
fn process(&mut self, mono: &mut [f32]) {
for s in mono.iter_mut() {
*s += self.next() * self.gain;
}
}
}
// ---------------------------------------------------------------------------
// Compressor
// ---------------------------------------------------------------------------
pub struct Compressor {
threshold_db: f32,
ratio: f32,
makeup_db: f32,
env: f32,
gain: f32,
fs: f32,
}
impl Compressor {
pub fn new(sample_rate: u32, threshold_db: f32, ratio: f32, makeup_db: f32) -> Self {
Self {
threshold_db,
ratio: ratio.max(1.0),
makeup_db,
env: 0.0,
gain: 1.0,
fs: sample_rate as f32,
}
}
}
impl Effect for Compressor {
fn process(&mut self, mono: &mut [f32]) {
let threshold = 10f32.powf(self.threshold_db / 20.0);
let makeup = 10f32.powf(self.makeup_db / 20.0);
let release = 1.0 / (self.fs * 0.1).max(1.0); // ~100 ms
let attack = 1.0 / (self.fs * 0.002).max(1.0); // ~2 ms
let inv_ratio = 1.0 - 1.0 / self.ratio;
for s in mono.iter_mut() {
let peak = s.abs();
self.env *= 1.0 - release;
if peak > self.env {
self.env = self.env + (peak - self.env) * attack;
}
let over_db = 20.0 * (self.env / threshold.max(1e-9)).log10();
let target_gain = if over_db > 0.0 {
let g_db = -over_db * inv_ratio;
10f32.powf(g_db / 20.0)
} else {
1.0
};
self.gain += (target_gain - self.gain) * attack;
*s *= self.gain * makeup;
}
}
}
// ---------------------------------------------------------------------------
// Overdrive (soft tube-style saturation, unity gain at low levels)
// ---------------------------------------------------------------------------
pub struct Overdrive {
drive: f32,
output: f32,
}
impl Overdrive {
pub fn new(drive: f32, output: f32) -> Self {
Self {
drive: drive.max(0.01),
output,
}
}
}
impl Effect for Overdrive {
fn process(&mut self, mono: &mut [f32]) {
let d = self.drive;
let o = self.output;
for s in mono.iter_mut() {
*s = ((*s * d).atan() / d) * o;
}
}
}
// ---------------------------------------------------------------------------
// Tremolo (LFO amplitude modulation)
// ---------------------------------------------------------------------------
pub struct Tremolo {
depth: f32,
rate: f32,
phase: f32,
fs: f32,
}
impl Tremolo {
pub fn new(sample_rate: u32, depth: f32, rate_hz: f32) -> Self {
Self {
depth: depth.clamp(0.0, 1.0),
rate: rate_hz,
phase: 0.0,
fs: sample_rate as f32,
}
}
}
impl Effect for Tremolo {
fn process(&mut self, mono: &mut [f32]) {
for s in mono.iter_mut() {
let lfo = (2.0 * PI * self.phase).sin();
let amp = 1.0 - self.depth * (0.5 - 0.5 * lfo);
*s *= amp;
self.phase += self.rate / self.fs;
if self.phase >= 1.0 {
self.phase -= 1.0;
}
}
}
}
// ---------------------------------------------------------------------------
// Flanger (short modulated delay with feedback)
// ---------------------------------------------------------------------------
pub struct Flanger {
delay: Vec<f32>,
idx: usize,
cap: usize,
base: f32,
depth: f32,
rate: f32,
feedback: f32,
wet: f32,
phase: f32,
fs: f32,
}
impl Flanger {
pub fn new(sample_rate: u32, depth_ms: f32, rate_hz: f32, feedback: f32, wet: f32) -> Self {
let depth = (depth_ms / 1000.0 * sample_rate as f32).max(1.0);
let cap = (sample_rate as f32 * 0.1) as usize + (depth * 2.0) as usize + 2;
Self {
delay: vec![0.0; cap],
idx: 0,
cap,
base: depth * 0.5,
depth: depth * 0.5,
rate: rate_hz,
feedback: feedback.clamp(0.0, 0.9),
wet: wet.clamp(0.0, 1.0),
phase: 0.0,
fs: sample_rate as f32,
}
}
#[inline]
fn read(&self, offset: f32) -> f32 {
let mut pos = self.idx as f32 - offset;
if pos < 0.0 {
pos += self.cap as f32;
}
let i = pos.floor() as usize % self.cap;
let j = (i + 1) % self.cap;
let t = pos - pos.floor();
self.delay[i] * (1.0 - t) + self.delay[j] * t
}
}
impl Effect for Flanger {
fn process(&mut self, mono: &mut [f32]) {
let wet = self.wet;
let dry = 1.0 - wet;
for s in mono.iter_mut() {
let lfo = (2.0 * PI * self.phase).sin();
let offset = self.base + self.depth * lfo;
let delayed = self.read(offset);
let y = *s * dry + delayed * wet;
self.delay[self.idx] = *s + delayed * self.feedback;
*s = y;
self.idx = (self.idx + 1) % self.cap;
self.phase += self.rate / self.fs;
if self.phase >= 1.0 {
self.phase -= 1.0;
}
}
}
}
// ---------------------------------------------------------------------------
// Channel vocoder (classic robot voice)
// ---------------------------------------------------------------------------
#[derive(Clone, Copy, PartialEq, Debug, serde::Serialize, serde::Deserialize)]
pub enum CarrierKind {
Noise,
Saw,
Input,
}
pub struct ChannelVocoder {
bands: Vec<VocoderBand>,
carrier: CarrierKind,
bands_count: usize,
wet: f32,
/// Output makeup so vocoder presets stay loud without giant external gain.
makeup: f32,
carrier_hz: f32,
phase: f32,
noise_state: u32,
fs: f32,
}
struct VocoderBand {
analysis: Biquad,
synthesis: Biquad,
env: f32,
}
impl ChannelVocoder {
pub fn new(
sample_rate: u32,
bands_count: usize,
carrier: CarrierKind,
carrier_hz: f32,
wet: f32,
) -> Self {
let fs = sample_rate as f32;
let low: f32 = 150.0;
let high: f32 = 4500.0;
let n = bands_count.max(2);
let mut bands = Vec::with_capacity(n);
for i in 0..n {
let f = low * (high / low).powf(i as f32 / (n - 1) as f32);
let f0: f32 = f;
let q = 8.0;
let analysis = Biquad::bandpass(sample_rate, f0, q);
let synthesis = Biquad::bandpass(sample_rate, f0, q);
bands.push(VocoderBand {
analysis,
synthesis,
env: 0.0,
});
}
Self {
bands,
carrier,
bands_count: n,
wet: wet.clamp(0.0, 1.0),
makeup: 5.0,
carrier_hz: carrier_hz.max(20.0),
phase: 0.0,
noise_state: 0x2545F491,
fs,
}
}
#[inline]
fn carrier_sample(&mut self, input: f32) -> f32 {
match self.carrier {
CarrierKind::Noise => {
let mut x = self.noise_state;
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
self.noise_state = x;
x as f32 / u32::MAX as f32 * 2.0 - 1.0
}
CarrierKind::Saw => {
let s = self.phase * 2.0 - 1.0;
self.phase += self.carrier_hz / self.fs;
if self.phase >= 1.0 {
self.phase -= 1.0;
}
s
}
CarrierKind::Input => input,
}
}
}
impl Effect for ChannelVocoder {
fn process(&mut self, mono: &mut [f32]) {
let wet = self.wet;
let dry = 1.0 - wet;
let attack = 0.06;
let release = 0.0015;
let inv_n = 1.0 / self.bands_count as f32;
for s in mono.iter_mut() {
let input = *s;
let carrier = self.carrier_sample(input);
let mut acc = 0.0;
for band in &mut self.bands {
let a = band.analysis.tick(input);
let a_env = a.abs();
if a_env > band.env {
band.env += (a_env - band.env) * attack;
} else {
band.env += (a_env - band.env) * release;
}
acc += band.synthesis.tick(carrier) * band.env;
}
*s = input * dry + acc * wet * inv_n * self.makeup;
}
}
}

101
src/dsp/fx/drive.rs Normal file
View file

@ -0,0 +1,101 @@
//! Drive effects: waveshapers and bit crusher.
use super::Effect;
pub struct Distortion {
drive: f32,
output: f32,
}
impl Distortion {
pub fn new(drive: f32, output: f32) -> Self {
Self { drive, output }
}
}
impl Effect for Distortion {
fn process(&mut self, mono: &mut [f32]) {
for s in mono.iter_mut() {
*s = (*s * self.drive).tanh() * self.output;
}
}
}
pub struct Overdrive {
drive: f32,
output: f32,
}
impl Overdrive {
pub fn new(drive: f32, output: f32) -> Self {
Self {
drive: drive.max(0.01),
output,
}
}
}
impl Effect for Overdrive {
fn process(&mut self, mono: &mut [f32]) {
let d = self.drive;
let o = self.output;
for s in mono.iter_mut() {
*s = ((*s * d).atan() / d) * o;
}
}
}
pub struct Bitcrush {
bits: u8,
sample_rate_div: u32,
/// Max boost applied to quiet input (auto-level normalization).
max_gain: f32,
/// Target peak level after normalization.
target: f32,
gain_smooth: f32,
hold: f32,
count: u32,
}
impl Bitcrush {
/// `max_gain`/`target` normalize each block before quantization so the
/// effect works on quiet mics without clipping loud ones.
pub fn new(bits: u8, sample_rate_div: u32, max_gain: f32, target: f32) -> Self {
Self {
bits: bits.max(2),
sample_rate_div: sample_rate_div.max(1),
max_gain: max_gain.max(1.0),
target: target.clamp(0.1, 1.0),
gain_smooth: 1.0,
hold: 0.0,
count: 0,
}
}
}
impl Effect for Bitcrush {
fn process(&mut self, mono: &mut [f32]) {
if mono.is_empty() {
return;
}
let levels = (1i32 << (self.bits - 1)) as f32;
let peak = mono.iter().fold(0.0f32, |m, s| m.max(s.abs()));
let gain = if peak > 1e-6 {
(self.target / peak).clamp(1.0, self.max_gain)
} else {
self.max_gain
};
self.gain_smooth += (gain - self.gain_smooth) * 0.3;
let g = self.gain_smooth;
for s in mono.iter_mut() {
self.count = (self.count + 1) % self.sample_rate_div;
if self.count == 0 {
self.hold = *s;
}
let q = (self.hold * g * levels).round();
*s = (q / levels).clamp(-1.0, 1.0);
}
}
}

View file

@ -0,0 +1,76 @@
//! Biquad filters (RBJ cookbook).
use super::super::Effect;
use std::f32::consts::PI;
pub struct Biquad {
b0: f32,
b1: f32,
b2: f32,
a1: f32,
a2: f32,
z1: f32,
z2: f32,
}
impl Biquad {
/// Constant-skirt bandpass.
pub fn bandpass(sample_rate: u32, f0: f32, q: f32) -> Self {
let w0 = 2.0 * PI * f0 / sample_rate as f32;
let alpha = w0.sin() / (2.0 * q);
Self::normalized(
alpha,
0.0,
-alpha,
-2.0 * w0.cos(),
1.0 - alpha,
1.0 + alpha,
)
}
fn normalized(b0: f32, b1: f32, b2: f32, a1: f32, a2: f32, a0: f32) -> Self {
Self {
b0: b0 / a0,
b1: b1 / a0,
b2: b2 / a0,
a1: a1 / a0,
a2: a2 / a0,
z1: 0.0,
z2: 0.0,
}
}
#[inline]
pub fn tick(&mut self, x: f32) -> f32 {
let y = self.b0 * x + self.z1;
self.z1 = self.b1 * x - self.a1 * y + self.z2;
self.z2 = self.b2 * x - self.a2 * y;
y
}
}
pub struct Bandpass {
filter: Biquad,
mix: f32,
}
impl Bandpass {
pub fn new(sample_rate: u32, low_hz: f32, high_hz: f32, mix: f32) -> Self {
let f0 = (low_hz * high_hz).sqrt();
let q = f0 / (high_hz - low_hz).max(1.0);
Self {
filter: Biquad::bandpass(sample_rate, f0, q),
mix: mix.clamp(0.0, 1.0),
}
}
}
impl Effect for Bandpass {
fn process(&mut self, mono: &mut [f32]) {
let mix = self.mix;
for s in mono.iter_mut() {
let y = self.filter.tick(*s);
*s = *s * (1.0 - mix) + y * mix;
}
}
}

View file

@ -0,0 +1,108 @@
//! Dynamics: compressor and noise gate.
use super::super::Effect;
pub struct Compressor {
threshold_db: f32,
ratio: f32,
makeup_db: f32,
env: f32,
gain: f32,
fs: f32,
}
impl Compressor {
pub fn new(sample_rate: u32, threshold_db: f32, ratio: f32, makeup_db: f32) -> Self {
Self {
threshold_db,
ratio: ratio.max(1.0),
makeup_db,
env: 0.0,
gain: 1.0,
fs: sample_rate as f32,
}
}
}
impl Effect for Compressor {
fn process(&mut self, mono: &mut [f32]) {
let threshold = 10f32.powf(self.threshold_db / 20.0);
let makeup = 10f32.powf(self.makeup_db / 20.0);
let release = 1.0 / (self.fs * 0.1).max(1.0);
let attack = 1.0 / (self.fs * 0.002).max(1.0);
let inv_ratio = 1.0 - 1.0 / self.ratio;
for s in mono.iter_mut() {
let peak = s.abs();
self.env *= 1.0 - release;
if peak > self.env {
self.env += (peak - self.env) * attack;
}
let over_db = 20.0 * (self.env / threshold.max(1e-9)).log10();
let target_gain = if over_db > 0.0 {
let g_db = -over_db * inv_ratio;
10f32.powf(g_db / 20.0)
} else {
1.0
};
self.gain += (target_gain - self.gain) * attack;
*s *= self.gain * makeup;
}
}
}
/// Noise gate: suppresses quiet segments with hysteresis and smoothing.
pub struct NoiseGate {
pub threshold_db: f32,
level: f32,
gain: f32,
open: bool,
}
impl NoiseGate {
pub fn new(threshold_db: f32) -> Self {
Self {
threshold_db,
level: 0.0,
gain: 0.0,
open: false,
}
}
pub fn set_threshold_db(&mut self, db: f32) {
self.threshold_db = db;
}
}
impl Effect for NoiseGate {
fn process(&mut self, mono: &mut [f32]) {
if mono.is_empty() {
return;
}
let mut sum = 0.0;
for &s in mono.iter() {
sum += s * s;
}
let rms = (sum / mono.len() as f32).sqrt();
if rms > self.level {
self.level += (rms - self.level) * 0.5;
} else {
self.level += (rms - self.level) * 0.02;
}
let threshold = 10f32.powf(self.threshold_db / 20.0);
if self.level > threshold {
self.open = true;
} else if self.level < threshold * 0.5 {
self.open = false;
}
let target = if self.open { 1.0 } else { 0.0 };
for s in mono.iter_mut() {
self.gain += (target - self.gain) * 0.15;
*s *= self.gain;
}
}
}

7
src/dsp/fx/filter/mod.rs Normal file
View file

@ -0,0 +1,7 @@
//! Filters and dynamics: biquad filters, compressor, noise gate.
pub mod biquad;
pub mod dynamics;
pub use biquad::{Bandpass, Biquad};
pub use dynamics::{Compressor, NoiseGate};

19
src/dsp/fx/mod.rs Normal file
View file

@ -0,0 +1,19 @@
//! Effects library: small processing units applied to the mono stream after
//! pitch/formant shifting. Each effect mutates the block in place.
mod drive;
mod modgen;
pub mod filter;
pub mod time;
pub mod vocoder;
pub use drive::{Bitcrush, Distortion, Overdrive};
pub use filter::{Bandpass, Compressor};
pub use modgen::{Noise, RingMod, Tremolo};
pub use time::{Chorus, Flanger, Reverb};
pub use vocoder::{CarrierKind, ChannelVocoder};
pub trait Effect: Send {
fn process(&mut self, mono: &mut [f32]);
}

100
src/dsp/fx/modgen.rs Normal file
View file

@ -0,0 +1,100 @@
//! Generator/modulation effects: white noise, tremolo, ring modulation.
use super::Effect;
use std::f32::consts::PI;
pub struct Noise {
gain: f32,
state: u32,
}
impl Noise {
pub fn new(gain: f32) -> Self {
Self {
gain,
state: 0x9E3779B9,
}
}
#[inline]
fn next(&mut self) -> f32 {
let mut x = self.state;
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
self.state = x;
x as f32 / u32::MAX as f32 * 2.0 - 1.0
}
}
impl Effect for Noise {
fn process(&mut self, mono: &mut [f32]) {
for s in mono.iter_mut() {
*s += self.next() * self.gain;
}
}
}
pub struct Tremolo {
depth: f32,
rate: f32,
phase: f32,
fs: f32,
}
impl Tremolo {
pub fn new(sample_rate: u32, depth: f32, rate_hz: f32) -> Self {
Self {
depth: depth.clamp(0.0, 1.0),
rate: rate_hz,
phase: 0.0,
fs: sample_rate as f32,
}
}
}
impl Effect for Tremolo {
fn process(&mut self, mono: &mut [f32]) {
for s in mono.iter_mut() {
let lfo = (2.0 * PI * self.phase).sin();
let amp = 1.0 - self.depth * (0.5 - 0.5 * lfo);
*s *= amp;
self.phase += self.rate / self.fs;
if self.phase >= 1.0 {
self.phase -= 1.0;
}
}
}
}
pub struct RingMod {
freq: f32,
depth: f32,
phase: f32,
fs: f32,
}
impl RingMod {
pub fn new(sample_rate: u32, freq_hz: f32, depth: f32) -> Self {
Self {
freq: freq_hz,
depth: depth.clamp(0.0, 1.0),
phase: 0.0,
fs: sample_rate as f32,
}
}
}
impl Effect for RingMod {
fn process(&mut self, mono: &mut [f32]) {
let depth = self.depth;
for s in mono.iter_mut() {
let m = 1.0 - depth + depth * (2.0 * PI * self.phase).sin();
*s *= m;
self.phase += self.freq / self.fs;
if self.phase >= 1.0 {
self.phase -= 1.0;
}
}
}
}

128
src/dsp/fx/time/delay.rs Normal file
View file

@ -0,0 +1,128 @@
//! Modulated delay lines: chorus and flanger.
use super::super::Effect;
use std::f32::consts::PI;
struct DelayLine {
buf: Vec<f32>,
idx: usize,
cap: usize,
}
impl DelayLine {
fn new(cap: usize) -> Self {
Self {
buf: vec![0.0; cap],
idx: 0,
cap,
}
}
#[inline]
fn write(&mut self, value: f32) {
self.buf[self.idx] = value;
self.idx = (self.idx + 1) % self.cap;
}
#[inline]
fn read(&self, offset: f32) -> f32 {
let mut pos = self.idx as f32 - offset;
if pos < 0.0 {
pos += self.cap as f32;
}
let i = pos.floor() as usize % self.cap;
let j = (i + 1) % self.cap;
let t = pos - pos.floor();
self.buf[i] * (1.0 - t) + self.buf[j] * t
}
}
pub struct Chorus {
line: DelayLine,
depth: f32,
rate: f32,
wet: f32,
phase: f32,
fs: f32,
}
impl Chorus {
pub fn new(sample_rate: u32, depth_ms: f32, rate_hz: f32, wet: f32) -> Self {
let depth = depth_ms / 1000.0 * sample_rate as f32;
let cap = (sample_rate as f32 * 0.1) as usize + (depth * 4.0) as usize;
Self {
line: DelayLine::new(cap),
depth: depth.max(1.0),
rate: rate_hz,
wet: wet.clamp(0.0, 1.0),
phase: 0.0,
fs: sample_rate as f32,
}
}
}
impl Effect for Chorus {
fn process(&mut self, mono: &mut [f32]) {
let depth = self.depth;
let wet = self.wet;
let dry = 1.0 - wet;
for s in mono.iter_mut() {
self.line.write(*s);
let lfo = (2.0 * PI * self.phase).sin();
let v1 = self.line.read(depth * (0.35 + 0.35 * lfo));
let v2 = self.line.read(depth * (0.35 - 0.35 * lfo));
*s = *s * dry + (v1 + v2) * 0.5 * wet;
self.phase += self.rate / self.fs;
if self.phase >= 1.0 {
self.phase -= 1.0;
}
}
}
}
pub struct Flanger {
line: DelayLine,
base: f32,
depth: f32,
rate: f32,
feedback: f32,
wet: f32,
phase: f32,
fs: f32,
}
impl Flanger {
pub fn new(sample_rate: u32, depth_ms: f32, rate_hz: f32, feedback: f32, wet: f32) -> Self {
let depth = (depth_ms / 1000.0 * sample_rate as f32).max(1.0);
let cap = (sample_rate as f32 * 0.1) as usize + (depth * 2.0) as usize + 2;
Self {
line: DelayLine::new(cap),
base: depth * 0.5,
depth: depth * 0.5,
rate: rate_hz,
feedback: feedback.clamp(0.0, 0.9),
wet: wet.clamp(0.0, 1.0),
phase: 0.0,
fs: sample_rate as f32,
}
}
}
impl Effect for Flanger {
fn process(&mut self, mono: &mut [f32]) {
let wet = self.wet;
let dry = 1.0 - wet;
for s in mono.iter_mut() {
let lfo = (2.0 * PI * self.phase).sin();
let offset = self.base + self.depth * lfo;
let delayed = self.line.read(offset);
let y = *s * dry + delayed * wet;
self.line.write(*s + delayed * self.feedback);
*s = y;
self.phase += self.rate / self.fs;
if self.phase >= 1.0 {
self.phase -= 1.0;
}
}
}
}

7
src/dsp/fx/time/mod.rs Normal file
View file

@ -0,0 +1,7 @@
//! Time-based effects: reverb (Freeverb) and modulated delays (chorus, flanger).
pub mod delay;
pub mod reverb;
pub use delay::{Chorus, Flanger};
pub use reverb::Reverb;

127
src/dsp/fx/time/reverb.rs Normal file
View file

@ -0,0 +1,127 @@
//! Freeverb-style reverb.
use super::super::Effect;
struct Comb {
buf: Vec<f32>,
idx: usize,
filter: f32,
feedback: f32,
damp1: f32,
damp2: f32,
}
impl Comb {
fn new(len: usize) -> Self {
Self {
buf: vec![0.0; len],
idx: 0,
filter: 0.0,
feedback: 0.84,
damp1: 0.5,
damp2: 0.5,
}
}
#[inline]
fn process(&mut self, input: f32) -> f32 {
let output = self.buf[self.idx];
self.filter = output * self.damp2 + self.filter * self.damp1;
self.buf[self.idx] = input + self.filter * self.feedback;
self.idx += 1;
if self.idx >= self.buf.len() {
self.idx = 0;
}
output
}
}
struct Allpass {
buf: Vec<f32>,
idx: usize,
feedback: f32,
}
impl Allpass {
fn new(len: usize) -> Self {
Self {
buf: vec![0.0; len],
idx: 0,
feedback: 0.5,
}
}
#[inline]
fn process(&mut self, input: f32) -> f32 {
let bufout = self.buf[self.idx];
self.buf[self.idx] = input + bufout * self.feedback;
self.idx += 1;
if self.idx >= self.buf.len() {
self.idx = 0;
}
bufout - input
}
}
pub struct Reverb {
combs: Vec<Comb>,
allpasses: Vec<Allpass>,
wet: f32,
room: f32,
damp: f32,
}
impl Reverb {
pub fn new(sample_rate: u32, room: f32, damp: f32, wet: f32) -> Self {
let scale = sample_rate as f32 / 44100.0;
let comb_lens = [1116, 1188, 1277, 1356, 1422, 1491, 1557, 1617];
let allpass_lens = [556, 441, 341, 225];
let combs: Vec<Comb> = comb_lens
.iter()
.map(|&l| Comb::new((l as f32 * scale) as usize))
.collect();
let allpasses: Vec<Allpass> = allpass_lens
.iter()
.map(|&l| Allpass::new((l as f32 * scale) as usize))
.collect();
let mut reverb = Self {
combs,
allpasses,
wet: wet.clamp(0.0, 1.0),
room: room.clamp(0.0, 1.0),
damp: damp.clamp(0.0, 1.0),
};
reverb.apply_params();
reverb
}
fn apply_params(&mut self) {
let feedback = self.room * 0.88 + 0.10;
for comb in &mut self.combs {
comb.feedback = feedback;
comb.damp1 = self.damp;
comb.damp2 = 1.0 - self.damp;
}
}
}
impl Effect for Reverb {
fn process(&mut self, mono: &mut [f32]) {
let wet = self.wet;
let dry = 1.0 - wet;
for s in mono.iter_mut() {
let input = *s;
let mut acc = 0.0;
for comb in &mut self.combs {
acc += comb.process(input);
}
acc /= self.combs.len() as f32;
for ap in &mut self.allpasses {
acc = ap.process(acc);
}
*s = input * dry + acc * wet * 1.5;
}
}
}

View file

@ -0,0 +1,8 @@
//! Vocoder carrier kinds and generators.
#[derive(Clone, Copy, PartialEq, Debug, serde::Serialize, serde::Deserialize)]
pub enum CarrierKind {
Noise,
Saw,
Input,
}

View file

@ -0,0 +1,112 @@
//! Channel vocoder: analysis filterbank + envelope followers + synthesis.
use super::super::Effect;
use super::carrier::CarrierKind;
use crate::dsp::fx::filter::Biquad;
pub struct ChannelVocoder {
bands: Vec<VocoderBand>,
carrier: CarrierKind,
bands_count: usize,
wet: f32,
makeup: f32,
carrier_hz: f32,
phase: f32,
noise_state: u32,
fs: f32,
}
struct VocoderBand {
analysis: Biquad,
synthesis: Biquad,
env: f32,
}
impl ChannelVocoder {
pub fn new(
sample_rate: u32,
bands_count: usize,
carrier: CarrierKind,
carrier_hz: f32,
wet: f32,
) -> Self {
let fs = sample_rate as f32;
let low: f32 = 150.0;
let high: f32 = 4500.0;
let n = bands_count.max(2);
let mut bands = Vec::with_capacity(n);
for i in 0..n {
let f = low * (high / low).powf(i as f32 / (n - 1) as f32);
let f0: f32 = f;
let q = 8.0;
bands.push(VocoderBand {
analysis: Biquad::bandpass(sample_rate, f0, q),
synthesis: Biquad::bandpass(sample_rate, f0, q),
env: 0.0,
});
}
Self {
bands,
carrier,
bands_count: n,
wet: wet.clamp(0.0, 1.0),
makeup: 5.0,
carrier_hz: carrier_hz.max(20.0),
phase: 0.0,
noise_state: 0x2545F491,
fs,
}
}
#[inline]
fn carrier_sample(&mut self, input: f32) -> f32 {
match self.carrier {
CarrierKind::Noise => {
let mut x = self.noise_state;
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
self.noise_state = x;
x as f32 / u32::MAX as f32 * 2.0 - 1.0
}
CarrierKind::Saw => {
let s = self.phase * 2.0 - 1.0;
self.phase += self.carrier_hz / self.fs;
if self.phase >= 1.0 {
self.phase -= 1.0;
}
s
}
CarrierKind::Input => input,
}
}
}
impl Effect for ChannelVocoder {
fn process(&mut self, mono: &mut [f32]) {
let wet = self.wet;
let dry = 1.0 - wet;
let attack = 0.06;
let release = 0.0015;
let inv_n = 1.0 / self.bands_count as f32;
for s in mono.iter_mut() {
let input = *s;
let carrier = self.carrier_sample(input);
let mut acc = 0.0;
for band in &mut self.bands {
let a = band.analysis.tick(input);
let a_env = a.abs();
if a_env > band.env {
band.env += (a_env - band.env) * attack;
} else {
band.env += (a_env - band.env) * release;
}
acc += band.synthesis.tick(carrier) * band.env;
}
*s = input * dry + acc * wet * inv_n * self.makeup;
}
}
}

View file

@ -0,0 +1,7 @@
//! Channel vocoder effect (classic robot voice).
pub mod carrier;
pub mod channel;
pub use carrier::CarrierKind;
pub use channel::ChannelVocoder;

View file

@ -1,64 +0,0 @@
//! Noise gate: suppresses quiet segments below the threshold with a short
//! attack / longer release and a 6 dB hysteresis to avoid chatter.
/// Convert dB to linear amplitude.
pub fn db_to_lin(db: f32) -> f32 {
10f32.powf(db / 20.0)
}
pub struct NoiseGate {
pub threshold_db: f32,
level: f32,
gain: f32,
open: bool,
}
impl NoiseGate {
pub fn new(threshold_db: f32) -> Self {
Self {
threshold_db,
level: 0.0,
gain: 0.0,
open: false,
}
}
pub fn set_threshold_db(&mut self, db: f32) {
self.threshold_db = db;
}
pub fn process(&mut self, block: &mut [f32]) {
if block.is_empty() {
return;
}
let mut sum = 0.0;
for &s in block.iter() {
sum += s * s;
}
let rms = (sum / block.len() as f32).sqrt();
// Envelope follower: fast attack, slow release.
if rms > self.level {
self.level += (rms - self.level) * 0.5;
} else {
self.level += (rms - self.level) * 0.02;
}
let threshold = db_to_lin(self.threshold_db);
// Hysteresis: open above threshold, close 6 dB below it.
if self.level > threshold {
self.open = true;
} else if self.level < threshold * 0.5 {
self.open = false;
}
let target = if self.open { 1.0 } else { 0.0 };
for s in block.iter_mut() {
// Per-sample smoothing so the gate never clicks.
self.gain += (target - self.gain) * 0.15;
*s *= self.gain;
}
}
}

View file

@ -1,7 +1,6 @@
pub mod chain;
pub mod effects;
pub mod control;
pub mod formant;
pub mod gate;
pub mod pitch;
pub mod presets;
pub mod stats;
pub mod fx;
pub mod preset;
pub mod shift;

View file

@ -1,454 +0,0 @@
//! Real-time pitch shifting via phase vocoder.
//!
//! The phase vocoder resamples the spectral *residual* (fine structure) to
//! change pitch while keeping the spectral envelope (formants) intact, and
//! independently resamples the envelope to change formants. This gives us a
//! single FFT-based stage that drives both the pitch and formant controls.
//!
//! Latency is one FFT frame (default 1024 samples ≈ 21 ms @ 48 kHz).
//! Hann window with 75% overlap (hop = N/4) is a valid COLA window, so the
//! overlap-add reconstruction is lossless in the bypass case.
use rustfft::num_complex::Complex;
use rustfft::{Fft, FftPlanner};
use std::sync::Arc;
/// Linear interpolation over `arr` at fractional position `pos`.
fn interpolate(arr: &[f32], pos: f32) -> f32 {
let max = arr.len() as f32 - 1.0;
let pos = pos.clamp(0.0, max);
let i = pos.floor() as usize;
let j = (i + 1).min(arr.len() - 1);
let t = pos - i as f32;
arr[i] * (1.0 - t) + arr[j] * t
}
/// Wrap a phase difference into (-PI, PI].
fn princarg(x: f32) -> f32 {
x - 2.0 * std::f32::consts::PI * (x / (2.0 * std::f32::consts::PI)).round()
}
pub struct PitchShifter {
n: usize,
hop: usize,
window: Vec<f32>,
norm: f32,
fft: Arc<dyn Fft<f32>>,
ifft: Arc<dyn Fft<f32>>,
env_ifft: Arc<dyn Fft<f32>>,
env_fft: Arc<dyn Fft<f32>>,
in_buf: Vec<f32>,
out_buf: Vec<f32>,
spec: Vec<Complex<f32>>,
mag: Vec<f32>,
phase: Vec<f32>,
logmag: Vec<f32>,
env: Vec<f32>,
cep: Vec<Complex<f32>>,
prev_phase: Vec<f32>,
out_phase: Vec<f32>,
/// Quefrency bins kept by the cepstral lifter (spectral envelope cutoff).
lifter_len: usize,
/// Pitch factor: output pitch = input pitch * ratio (1.0 = unchanged).
pub pitch_ratio: f32,
/// Formant factor: output formants = input formants * ratio (1.0 = unchanged).
pub formant_ratio: f32,
fs: u32,
/// Detected fundamental of the most recent analysis frame (Hz, 0 = unvoiced).
pub last_f0: f32,
}
impl PitchShifter {
pub fn new(fft_size: usize, hop: usize, sample_rate: u32) -> Self {
debug_assert!(fft_size.is_multiple_of(hop));
let frames = fft_size / hop;
let mut window = vec![0.0; fft_size];
for (i, w) in window.iter_mut().enumerate() {
*w =
0.5 - 0.5 * (2.0 * std::f32::consts::PI * i as f32 / (fft_size as f32 - 1.0)).cos();
}
// Steady-state OLA normalization: every output sample is covered by
// `frames` overlapping windows; sum of w^2 over those offsets.
let mut norm = 0.0;
for i in 0..frames {
let idx = i * hop;
norm += window[idx] * window[idx];
}
debug_assert!(norm > 0.0);
let mut planner = FftPlanner::<f32>::new();
let fft = planner.plan_fft_forward(fft_size);
let ifft = planner.plan_fft_inverse(fft_size);
let env_ifft = planner.plan_fft_inverse(fft_size);
let env_fft = planner.plan_fft_forward(fft_size);
Self {
n: fft_size,
hop,
window,
norm,
fft,
ifft,
env_ifft,
env_fft,
in_buf: Vec::new(),
out_buf: vec![0.0; fft_size],
spec: vec![Complex::new(0.0, 0.0); fft_size],
mag: vec![0.0; fft_size / 2 + 1],
phase: vec![0.0; fft_size / 2 + 1],
logmag: vec![0.0; fft_size / 2 + 1],
env: vec![0.0; fft_size / 2 + 1],
cep: vec![Complex::new(0.0, 0.0); fft_size],
prev_phase: vec![0.0; fft_size / 2 + 1],
out_phase: vec![0.0; fft_size / 2 + 1],
lifter_len: fft_size / 8,
pitch_ratio: 1.0,
formant_ratio: 1.0,
fs: sample_rate,
last_f0: 0.0,
}
}
/// Stream `input` through the shifter, appending the same number of
/// processed samples to `output` (length is preserved).
pub fn process(&mut self, input: &[f32], output: &mut Vec<f32>) {
for &sample in input {
self.in_buf.push(sample);
if self.in_buf.len() == self.n {
self.process_frame();
self.in_buf.drain(..self.hop);
output.extend_from_slice(&self.out_buf[..self.hop]);
self.out_buf.drain(..self.hop);
self.out_buf.resize(self.n, 0.0);
}
}
}
fn process_frame(&mut self) {
let n2 = self.n / 2;
// Analysis: window + forward FFT.
for (i, c) in self.spec.iter_mut().enumerate() {
*c = Complex::new(self.in_buf[i] * self.window[i], 0.0);
}
self.fft.process(&mut self.spec);
for i in 0..=n2 {
let c = self.spec[i];
let m = c.norm();
self.mag[i] = m;
self.phase[i] = c.arg();
self.logmag[i] = (m + 1e-12).ln();
}
// Cepstral spectral-envelope estimation (used for formant control).
self.compute_envelope();
// 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;
let f_active = (fr - 1.0).abs() > 1e-4;
if p_active || f_active {
// Phase propagation (Laroche & Dolson): track the instantaneous
// frequency of each input bin and advance the output phase so the
// reconstructed components stay coherent.
let hop = self.hop as f32;
let nf = self.n as f32;
let mut new_mag = vec![0.0; n2 + 1];
let mut new_phase = vec![0.0; n2 + 1];
for (b, nm) in new_mag.iter_mut().enumerate() {
let bf = b as f32;
// Source input bin for output bin b under the pitch shift.
let src = if p_active { bf / pr } else { bf };
let base = interpolate(&self.mag, src);
// Formants: re-weight the envelope so its shape is shifted by
// `fr` independent of pitch. After the pitch resample the
// envelope sits at E[bf/pr]; we move it to E[bf/fr].
if f_active {
let num = interpolate(&self.env, bf / fr);
let den = interpolate(&self.env, bf / pr);
let ratio = (num / den.max(1e-9)).clamp(0.3, 3.3);
*nm = base * ratio;
} else {
*nm = base;
}
// Instantaneous frequency of the source bin (rad per frame).
let si = src.round().clamp(0.0, n2 as f32) as usize;
let omega_src = 2.0 * std::f32::consts::PI * si as f32 / nf;
let diff = princarg(self.phase[si] - self.prev_phase[si] - omega_src * hop);
let inst = omega_src + diff / hop;
// Output phase advances by the (pitch-shifted) source frequency.
let advance = if p_active {
inst * hop * pr
} else {
inst * hop
};
self.out_phase[b] += advance;
new_phase[b] = self.out_phase[b];
self.prev_phase[si] = self.phase[si];
}
// Rebuild the conjugate-symmetric spectrum.
for (b, (&nm, &ph)) in new_mag.iter().zip(new_phase.iter()).enumerate() {
self.spec[b] = Complex::from_polar(nm, ph);
if b > 0 && b < n2 {
self.spec[self.n - b] = Complex::from_polar(nm, -ph);
}
}
}
// Synthesis: inverse FFT + window + overlap-add.
self.ifft.process(&mut self.spec);
let scale = 1.0 / (self.n as f32 * self.norm);
for i in 0..self.n {
let y = self.spec[i].re * self.window[i] * scale;
self.out_buf[i] += y;
}
}
/// 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;
// Conjugate-symmetric spectrum of log-magnitude -> real cepstrum.
for i in 0..=n2 {
let l = self.logmag[i];
self.cep[i] = Complex::new(l, 0.0);
if i > 0 && i < n2 {
self.cep[self.n - i] = Complex::new(l, 0.0);
}
}
self.env_ifft.process(&mut self.cep);
let inv = 1.0 / self.n as f32;
// Lifter: keep low quefrencies (smooth envelope), zero the rest.
for (i, c) in self.cep.iter_mut().enumerate() {
let keep = if i <= self.lifter_len { 1.0 } else { 0.0 };
c.re *= keep * inv;
c.im = 0.0;
}
self.env_fft.process(&mut self.cep);
for i in 0..=n2 {
self.env[i] = self.cep[i].re.exp().max(1e-6);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sine(freq: f32, fs: u32, samples: usize) -> Vec<f32> {
(0..samples)
.map(|i| (2.0 * std::f32::consts::PI * freq * i as f32 / fs as f32).sin())
.collect()
}
/// Harmonic-rich tone (sawtooth): narrow pitch lines + broad envelope,
/// closer to a real voice than a pure sine.
fn saw(freq: f32, fs: u32, samples: usize) -> Vec<f32> {
(0..samples)
.map(|i| {
let t = freq * i as f32 / fs as f32;
let mut s = 0.0;
for h in 1..=12 {
s += ((2.0 * std::f32::consts::PI * h as f32 * t).sin()) / h as f32;
}
s
})
.collect()
}
fn rms(x: &[f32]) -> f32 {
let s: f32 = x.iter().map(|v| v * v).sum();
(s / x.len().max(1) as f32).sqrt()
}
fn dominant_hz(x: &[f32], fs: u32) -> f32 {
use rustfft::FftPlanner;
let n = 4096;
if x.len() < n {
return 0.0;
}
let mut planner = FftPlanner::<f32>::new();
let fft = planner.plan_fft_forward(n);
let mut buf: Vec<Complex<f32>> = x[..n].iter().map(|&s| Complex::new(s, 0.0)).collect();
fft.process(&mut buf);
let bins = n / 2;
let mags: Vec<f32> = (0..bins).map(|b| buf[b].norm()).collect();
// Harmonic product sum: find the fundamental whose harmonics add up.
let mut best = (0usize, 0.0f32);
for b in 20..(bins / 4) {
let mut s = 0.0;
for h in 1..=4 {
if b * h < bins {
s += mags[b * h];
}
}
if s > best.1 {
best = (b, s);
}
}
fs as f32 * best.0 as f32 / n as f32
}
#[test]
fn bypass_preserves_signal() {
let mut sh = PitchShifter::new(1024, 256, 48000);
let input = saw(220.0, 48000, 48000);
let mut out = Vec::new();
sh.process(&input, &mut out);
assert!(out.len() >= 47000, "out.len={}", out.len());
assert!(rms(&out[1000..]).is_finite());
assert!(
rms(&out[1000..]) > 0.05,
"bypass rms too low: {}",
rms(&out[1000..])
);
use rustfft::FftPlanner;
let n = 4096;
let seg = &out[2000..2000 + n];
let mut planner = FftPlanner::<f32>::new();
let fft = planner.plan_fft_forward(n);
let mut buf: Vec<Complex<f32>> = seg.iter().map(|&s| Complex::new(s, 0.0)).collect();
fft.process(&mut buf);
let mut peaks: Vec<(usize, f32)> = (0..n / 2).map(|b| (b, buf[b].norm())).collect();
peaks.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
let top: Vec<String> = peaks
.iter()
.take(5)
.map(|(b, m)| format!("{:.0}Hz({m:.0})", b * 48000 / n))
.collect();
eprintln!("bypass peaks: {}", top.join(", "));
}
#[test]
fn pitch_shift_produces_energy() {
let mut sh = PitchShifter::new(1024, 256, 48000);
sh.pitch_ratio = 2f32.powf(5.0 / 12.0);
let input = saw(220.0, 48000, 48000);
let mut out = Vec::new();
sh.process(&input, &mut out);
assert!(
out.iter().take(20000).all(|v| v.is_finite()),
"non-finite output"
);
let r = rms(&out[1000..20000]);
assert!(r > 0.05, "pitch-shifted rms too low: {r}");
let est = dominant_hz(&out[1000..20000], 48000);
assert!(
(est - 293.7).abs() < 8.0,
"expected ~293.7 Hz for 220*2^(5/12), got {est:.1} Hz"
);
}
#[test]
fn block_feed_matches_chain() {
// Mimics dsp::chain's block-by-block feeding with a pending buffer.
let mut sh = PitchShifter::new(1024, 256, 48000);
sh.pitch_ratio = 2f32.powf(5.0 / 12.0);
let input = sine(220.0, 48000, 48000);
let block = 2560;
let mut pending = Vec::new();
let mut out_all = Vec::new();
for chunk in input.chunks(block) {
let mut out = Vec::new();
sh.process(chunk, &mut out);
pending.extend(out);
let take = chunk.len().min(pending.len());
let mut mono = vec![0.0; chunk.len()];
mono[..take].copy_from_slice(&pending[..take]);
out_all.extend(mono);
pending.drain(..take);
}
assert!(out_all.iter().all(|v| v.is_finite()), "non-finite");
let r = rms(&out_all[4000..40000]);
assert!(r > 0.05, "block-feed rms too low: {r}");
}
#[test]
fn one_shot_vs_block_fed() {
let mut a = PitchShifter::new(1024, 256, 48000);
a.pitch_ratio = 2f32.powf(5.0 / 12.0);
let input = saw(220.0, 48000, 48000);
let mut out_a = Vec::new();
a.process(&input, &mut out_a);
// Block-fed WITHOUT prime/pending, just concatenating shifter output.
let mut b = PitchShifter::new(1024, 256, 48000);
b.pitch_ratio = 2f32.powf(5.0 / 12.0);
let mut out_b = Vec::new();
for chunk in input.chunks(1024) {
b.process(chunk, &mut out_b);
}
let n = out_a.len().min(out_b.len());
assert!(n > 10000, "n={n}");
let mut max_diff = 0.0f32;
let mut idx = 0usize;
for i in 3000..n {
let d = (out_a[i] - out_b[i]).abs();
if d > max_diff {
max_diff = d;
idx = i;
}
}
eprintln!("max diff {max_diff} at {idx} of {n}");
assert!(
max_diff < 1e-3,
"block-fed differs from one-shot: max_diff={max_diff}"
);
}
}

80
src/dsp/preset/build.rs Normal file
View file

@ -0,0 +1,80 @@
//! Effect construction: EffectSpec -> Box<dyn Effect>.
use super::EffectSpec;
use crate::dsp::fx::{self, Effect};
pub fn build_effects(specs: &[EffectSpec], sample_rate: u32) -> Vec<Box<dyn Effect>> {
specs.iter().map(|s| build_effect(s, sample_rate)).collect()
}
fn build_effect(spec: &EffectSpec, sample_rate: u32) -> Box<dyn Effect> {
match spec {
EffectSpec::Distortion { drive, output } => Box::new(fx::Distortion::new(*drive, *output)),
EffectSpec::Overdrive { drive, output } => Box::new(fx::Overdrive::new(*drive, *output)),
EffectSpec::Bitcrush {
bits,
sample_rate_div,
max_gain,
target,
} => Box::new(fx::Bitcrush::new(
*bits,
*sample_rate_div,
*max_gain,
*target,
)),
EffectSpec::Reverb { room, damp, wet } => {
Box::new(fx::Reverb::new(sample_rate, *room, *damp, *wet))
}
EffectSpec::Chorus {
depth_ms,
rate_hz,
wet,
} => Box::new(fx::Chorus::new(sample_rate, *depth_ms, *rate_hz, *wet)),
EffectSpec::Flanger {
depth_ms,
rate_hz,
feedback,
wet,
} => Box::new(fx::Flanger::new(
sample_rate,
*depth_ms,
*rate_hz,
*feedback,
*wet,
)),
EffectSpec::Tremolo { depth, rate_hz } => {
Box::new(fx::Tremolo::new(sample_rate, *depth, *rate_hz))
}
EffectSpec::RingMod { freq_hz, depth } => {
Box::new(fx::RingMod::new(sample_rate, *freq_hz, *depth))
}
EffectSpec::Bandpass {
low_hz,
high_hz,
mix,
} => Box::new(fx::Bandpass::new(sample_rate, *low_hz, *high_hz, *mix)),
EffectSpec::Noise { gain } => Box::new(fx::Noise::new(*gain)),
EffectSpec::Compressor {
threshold_db,
ratio,
makeup_db,
} => Box::new(fx::Compressor::new(
sample_rate,
*threshold_db,
*ratio,
*makeup_db,
)),
EffectSpec::Vocoder {
bands,
carrier,
carrier_hz,
wet,
} => Box::new(fx::ChannelVocoder::new(
sample_rate,
*bands,
*carrier,
*carrier_hz,
*wet,
)),
}
}

131
src/dsp/preset/mod.rs Normal file
View file

@ -0,0 +1,131 @@
//! Preset voice settings: pitch, formant, effect chain and gate.
//! Built at startup from built-ins plus custom presets from the config file.
pub mod build;
pub mod table_a;
pub mod table_b;
pub use build::build_effects;
use serde::{Deserialize, Serialize};
use std::sync::OnceLock;
pub struct Preset {
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: Vec<EffectSpec>,
/// Output makeup gain so every preset sounds equally loud.
pub gain: f32,
/// Noise gate threshold for this preset (-100 = off).
pub gate_db: f32,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
pub enum EffectSpec {
Distortion {
drive: f32,
output: f32,
},
Overdrive {
drive: f32,
output: f32,
},
Bitcrush {
bits: u8,
sample_rate_div: u32,
max_gain: f32,
target: f32,
},
Reverb {
room: f32,
damp: f32,
wet: f32,
},
Chorus {
depth_ms: f32,
rate_hz: f32,
wet: f32,
},
Flanger {
depth_ms: f32,
rate_hz: f32,
feedback: f32,
wet: f32,
},
Tremolo {
depth: f32,
rate_hz: f32,
},
RingMod {
freq_hz: f32,
depth: f32,
},
Bandpass {
low_hz: f32,
high_hz: f32,
mix: f32,
},
Noise {
gain: f32,
},
Compressor {
threshold_db: f32,
ratio: f32,
makeup_db: f32,
},
Vocoder {
bands: usize,
carrier: crate::dsp::fx::CarrierKind,
carrier_hz: f32,
wet: f32,
},
}
/// 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>>,
pub gate_db: Option<f32>,
}
static PRESETS: OnceLock<Vec<Preset>> = OnceLock::new();
fn builtin_presets() -> Vec<Preset> {
let mut all = table_a::table_a();
all.extend(table_b::table_b());
all
}
/// 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| {
p.name.eq_ignore_ascii_case(name)
|| p.name
.split('/')
.next()
.map(|s| s.trim().eq_ignore_ascii_case(name))
.unwrap_or(false)
})
}
}

161
src/dsp/preset/table_a.rs Normal file
View file

@ -0,0 +1,161 @@
//! Built-in preset table (part of the list).
use super::{EffectSpec, Preset};
use crate::dsp::fx::CarrierKind;
pub(crate) fn table_a() -> Vec<Preset> {
vec![
Preset {
name: "Clean".into(),
pitch_semitones: 0.0,
formant_ratio: 1.0,
effects: vec![],
gain: 1.1,
gate_db: -100.0,
},
Preset {
name: "Girl / Anime".into(),
pitch_semitones: 5.0,
formant_ratio: 1.25,
effects: vec![
EffectSpec::Chorus {
depth_ms: 4.0,
rate_hz: 0.6,
wet: 0.25,
},
EffectSpec::Overdrive {
drive: 1.4,
output: 1.0,
},
],
gain: 1.3,
gate_db: -45.0,
},
Preset {
name: "Boy".into(),
pitch_semitones: 2.0,
formant_ratio: 1.08,
effects: vec![EffectSpec::Compressor {
threshold_db: -20.0,
ratio: 3.0,
makeup_db: 4.0,
}],
gain: 1.1,
gate_db: -45.0,
},
Preset {
name: "Manly / Deep".into(),
pitch_semitones: -4.0,
formant_ratio: 0.78,
effects: vec![
EffectSpec::Compressor {
threshold_db: -20.0,
ratio: 3.0,
makeup_db: 2.0,
},
EffectSpec::Reverb {
room: 0.25,
damp: 0.5,
wet: 0.15,
},
],
gain: 1.3,
gate_db: -40.0,
},
Preset {
name: "Demon".into(),
pitch_semitones: -9.0,
formant_ratio: 0.85,
effects: vec![
EffectSpec::Overdrive {
drive: 2.5,
output: 0.8,
},
EffectSpec::Reverb {
room: 0.5,
damp: 0.45,
wet: 0.3,
},
EffectSpec::Tremolo {
depth: 0.15,
rate_hz: 4.0,
},
],
gain: 1.0,
gate_db: -38.0,
},
Preset {
name: "Robot".into(),
pitch_semitones: 0.0,
formant_ratio: 1.0,
effects: vec![
EffectSpec::Vocoder {
bands: 16,
carrier: CarrierKind::Noise,
carrier_hz: 110.0,
wet: 1.0,
},
EffectSpec::Bitcrush {
bits: 8,
sample_rate_div: 2,
max_gain: 4.0,
target: 0.6,
},
],
gain: 1.8,
gate_db: -40.0,
},
Preset {
name: "8-bit".into(),
pitch_semitones: 0.0,
formant_ratio: 1.0,
effects: vec![EffectSpec::Bitcrush {
bits: 5,
sample_rate_div: 4,
max_gain: 32.0,
target: 0.6,
}],
gain: 1.0,
gate_db: -45.0,
},
Preset {
name: "Alien".into(),
pitch_semitones: -2.0,
formant_ratio: 1.15,
effects: vec![
EffectSpec::RingMod {
freq_hz: 140.0,
depth: 0.5,
},
EffectSpec::Flanger {
depth_ms: 2.5,
rate_hz: 0.4,
feedback: 0.5,
wet: 0.5,
},
],
gain: 2.0,
gate_db: -40.0,
},
Preset {
name: "Radio".into(),
pitch_semitones: 0.0,
formant_ratio: 1.0,
effects: vec![
EffectSpec::Bandpass {
low_hz: 300.0,
high_hz: 3000.0,
mix: 0.9,
},
EffectSpec::Noise { gain: 0.04 },
EffectSpec::Compressor {
threshold_db: -24.0,
ratio: 4.0,
makeup_db: 6.0,
},
],
gain: 1.0,
gate_db: -40.0,
},
]
}

195
src/dsp/preset/table_b.rs Normal file
View file

@ -0,0 +1,195 @@
//! Built-in preset table (part of the list).
use super::{EffectSpec, Preset};
use crate::dsp::fx::CarrierKind;
pub(crate) fn table_b() -> Vec<Preset> {
vec![
Preset {
name: "Megaphone".into(),
pitch_semitones: 0.0,
formant_ratio: 1.0,
effects: vec![
EffectSpec::Distortion {
drive: 2.5,
output: 0.9,
},
EffectSpec::Bandpass {
low_hz: 800.0,
high_hz: 2600.0,
mix: 1.0,
},
EffectSpec::Compressor {
threshold_db: -18.0,
ratio: 5.0,
makeup_db: 4.0,
},
],
gain: 1.2,
gate_db: -38.0,
},
Preset {
name: "Ghost".into(),
pitch_semitones: -3.0,
formant_ratio: 1.05,
effects: vec![
EffectSpec::Reverb {
room: 0.85,
damp: 0.3,
wet: 0.6,
},
EffectSpec::Chorus {
depth_ms: 5.0,
rate_hz: 0.5,
wet: 0.3,
},
EffectSpec::Tremolo {
depth: 0.2,
rate_hz: 1.5,
},
],
gain: 0.80,
gate_db: -40.0,
},
Preset {
name: "Cyborg".into(),
pitch_semitones: 2.0,
formant_ratio: 1.0,
effects: vec![
EffectSpec::Vocoder {
bands: 16,
carrier: CarrierKind::Saw,
carrier_hz: 220.0,
wet: 0.9,
},
EffectSpec::Overdrive {
drive: 1.5,
output: 1.0,
},
EffectSpec::Bitcrush {
bits: 8,
sample_rate_div: 2,
max_gain: 4.0,
target: 0.6,
},
],
gain: 1.8,
gate_db: -40.0,
},
Preset {
name: "Chipmunk".into(),
pitch_semitones: 7.0,
formant_ratio: 1.4,
effects: vec![EffectSpec::Chorus {
depth_ms: 3.0,
rate_hz: 0.8,
wet: 0.2,
}],
gain: 1.0,
gate_db: -45.0,
},
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,
}],
gain: 1.0,
gate_db: -45.0,
},
Preset {
name: "Vader".into(),
pitch_semitones: -10.0,
formant_ratio: 0.6,
effects: vec![
EffectSpec::Overdrive {
drive: 2.0,
output: 0.8,
},
EffectSpec::Reverb {
room: 0.6,
damp: 0.4,
wet: 0.3,
},
EffectSpec::Tremolo {
depth: 0.1,
rate_hz: 2.0,
},
],
gain: 1.4,
gate_db: -38.0,
},
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,
},
],
gain: 1.0,
gate_db: -42.0,
},
Preset {
name: "Walkie-Talkie".into(),
pitch_semitones: 0.0,
formant_ratio: 1.05,
effects: vec![
EffectSpec::Bandpass {
low_hz: 500.0,
high_hz: 2400.0,
mix: 1.0,
},
EffectSpec::Noise { gain: 0.05 },
EffectSpec::Compressor {
threshold_db: -20.0,
ratio: 4.0,
makeup_db: 4.0,
},
EffectSpec::Bitcrush {
bits: 8,
sample_rate_div: 2,
max_gain: 4.0,
target: 0.6,
},
],
gain: 1.00,
gate_db: -40.0,
},
Preset {
name: "Growl".into(),
pitch_semitones: -5.0,
formant_ratio: 0.7,
effects: vec![
EffectSpec::Distortion {
drive: 4.0,
output: 0.6,
},
EffectSpec::Compressor {
threshold_db: -18.0,
ratio: 4.0,
makeup_db: 4.0,
},
EffectSpec::Reverb {
room: 0.3,
damp: 0.5,
wet: 0.2,
},
],
gain: 1.0,
gate_db: -38.0,
},
]
}

View file

@ -1,543 +0,0 @@
//! 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: 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: Vec<EffectSpec>,
/// Output makeup gain so every preset sounds equally loud.
pub gain: f32,
/// Noise gate threshold for this preset (-100 = off).
pub gate_db: f32,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
pub enum EffectSpec {
Distortion {
drive: f32,
output: f32,
},
Overdrive {
drive: f32,
output: f32,
},
Bitcrush {
bits: u8,
sample_rate_div: u32,
max_gain: f32,
target: f32,
},
Reverb {
room: f32,
damp: f32,
wet: f32,
},
Chorus {
depth_ms: f32,
rate_hz: f32,
wet: f32,
},
Flanger {
depth_ms: f32,
rate_hz: f32,
feedback: f32,
wet: f32,
},
Tremolo {
depth: f32,
rate_hz: f32,
},
RingMod {
freq_hz: f32,
depth: f32,
},
Bandpass {
low_hz: f32,
high_hz: f32,
mix: f32,
},
Noise {
gain: f32,
},
Compressor {
threshold_db: f32,
ratio: f32,
makeup_db: f32,
},
Vocoder {
bands: usize,
carrier: CarrierKind,
carrier_hz: f32,
wet: f32,
},
}
/// 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>>,
pub gate_db: Option<f32>,
}
static PRESETS: OnceLock<Vec<Preset>> = OnceLock::new();
fn builtin_presets() -> Vec<Preset> {
vec![
Preset {
name: "Clean".into(),
pitch_semitones: 0.0,
formant_ratio: 1.0,
effects: vec![],
gain: 1.1,
gate_db: -100.0,
},
Preset {
name: "Girl / Anime".into(),
pitch_semitones: 5.0,
formant_ratio: 1.25,
effects: vec![
EffectSpec::Chorus {
depth_ms: 4.0,
rate_hz: 0.6,
wet: 0.25,
},
EffectSpec::Overdrive {
drive: 1.4,
output: 1.0,
},
],
gain: 1.3,
gate_db: -45.0,
},
Preset {
name: "Boy".into(),
pitch_semitones: 2.0,
formant_ratio: 1.08,
effects: vec![EffectSpec::Compressor {
threshold_db: -20.0,
ratio: 3.0,
makeup_db: 4.0,
}],
gain: 1.1,
gate_db: -45.0,
},
Preset {
name: "Manly / Deep".into(),
pitch_semitones: -4.0,
formant_ratio: 0.78,
effects: vec![
EffectSpec::Compressor {
threshold_db: -20.0,
ratio: 3.0,
makeup_db: 2.0,
},
EffectSpec::Reverb {
room: 0.25,
damp: 0.5,
wet: 0.15,
},
],
gain: 1.3,
gate_db: -40.0,
},
Preset {
name: "Demon".into(),
pitch_semitones: -9.0,
formant_ratio: 0.85,
effects: vec![
EffectSpec::Overdrive {
drive: 2.5,
output: 0.8,
},
EffectSpec::Reverb {
room: 0.5,
damp: 0.45,
wet: 0.3,
},
EffectSpec::Tremolo {
depth: 0.15,
rate_hz: 4.0,
},
],
gain: 1.0,
gate_db: -38.0,
},
Preset {
name: "Robot".into(),
pitch_semitones: 0.0,
formant_ratio: 1.0,
effects: vec![
EffectSpec::Vocoder {
bands: 16,
carrier: CarrierKind::Noise,
carrier_hz: 110.0,
wet: 1.0,
},
EffectSpec::Bitcrush {
bits: 8,
sample_rate_div: 2,
max_gain: 4.0,
target: 0.6,
},
],
gain: 1.8,
gate_db: -40.0,
},
Preset {
name: "8-bit".into(),
pitch_semitones: 0.0,
formant_ratio: 1.0,
effects: vec![EffectSpec::Bitcrush {
bits: 5,
sample_rate_div: 4,
max_gain: 32.0,
target: 0.6,
}],
gain: 1.0,
gate_db: -45.0,
},
Preset {
name: "Alien".into(),
pitch_semitones: -2.0,
formant_ratio: 1.15,
effects: vec![
EffectSpec::RingMod {
freq_hz: 140.0,
depth: 0.5,
},
EffectSpec::Flanger {
depth_ms: 2.5,
rate_hz: 0.4,
feedback: 0.5,
wet: 0.5,
},
],
gain: 2.0,
gate_db: -40.0,
},
Preset {
name: "Radio".into(),
pitch_semitones: 0.0,
formant_ratio: 1.0,
effects: vec![
EffectSpec::Bandpass {
low_hz: 300.0,
high_hz: 3000.0,
mix: 0.9,
},
EffectSpec::Noise { gain: 0.04 },
EffectSpec::Compressor {
threshold_db: -24.0,
ratio: 4.0,
makeup_db: 6.0,
},
],
gain: 1.0,
gate_db: -40.0,
},
Preset {
name: "Megaphone".into(),
pitch_semitones: 0.0,
formant_ratio: 1.0,
effects: vec![
EffectSpec::Distortion {
drive: 2.5,
output: 0.9,
},
EffectSpec::Bandpass {
low_hz: 800.0,
high_hz: 2600.0,
mix: 1.0,
},
EffectSpec::Compressor {
threshold_db: -18.0,
ratio: 5.0,
makeup_db: 4.0,
},
],
gain: 1.2,
gate_db: -38.0,
},
Preset {
name: "Ghost".into(),
pitch_semitones: -3.0,
formant_ratio: 1.05,
effects: vec![
EffectSpec::Reverb {
room: 0.85,
damp: 0.3,
wet: 0.6,
},
EffectSpec::Chorus {
depth_ms: 5.0,
rate_hz: 0.5,
wet: 0.3,
},
EffectSpec::Tremolo {
depth: 0.2,
rate_hz: 1.5,
},
],
gain: 0.80,
gate_db: -40.0,
},
Preset {
name: "Cyborg".into(),
pitch_semitones: 2.0,
formant_ratio: 1.0,
effects: vec![
EffectSpec::Vocoder {
bands: 16,
carrier: CarrierKind::Saw,
carrier_hz: 220.0,
wet: 0.9,
},
EffectSpec::Overdrive {
drive: 1.5,
output: 1.0,
},
EffectSpec::Bitcrush {
bits: 8,
sample_rate_div: 2,
max_gain: 4.0,
target: 0.6,
},
],
gain: 1.8,
gate_db: -40.0,
},
Preset {
name: "Chipmunk".into(),
pitch_semitones: 7.0,
formant_ratio: 1.4,
effects: vec![EffectSpec::Chorus {
depth_ms: 3.0,
rate_hz: 0.8,
wet: 0.2,
}],
gain: 1.0,
gate_db: -45.0,
},
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,
}],
gain: 1.0,
gate_db: -45.0,
},
Preset {
name: "Vader".into(),
pitch_semitones: -10.0,
formant_ratio: 0.6,
effects: vec![
EffectSpec::Overdrive {
drive: 2.0,
output: 0.8,
},
EffectSpec::Reverb {
room: 0.6,
damp: 0.4,
wet: 0.3,
},
EffectSpec::Tremolo {
depth: 0.1,
rate_hz: 2.0,
},
],
gain: 1.4,
gate_db: -38.0,
},
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,
},
],
gain: 1.0,
gate_db: -42.0,
},
Preset {
name: "Walkie-Talkie".into(),
pitch_semitones: 0.0,
formant_ratio: 1.05,
effects: vec![
EffectSpec::Bandpass {
low_hz: 500.0,
high_hz: 2400.0,
mix: 1.0,
},
EffectSpec::Noise { gain: 0.05 },
EffectSpec::Compressor {
threshold_db: -20.0,
ratio: 4.0,
makeup_db: 4.0,
},
EffectSpec::Bitcrush {
bits: 8,
sample_rate_div: 2,
max_gain: 4.0,
target: 0.6,
},
],
gain: 1.00,
gate_db: -40.0,
},
Preset {
name: "Growl".into(),
pitch_semitones: -5.0,
formant_ratio: 0.7,
effects: vec![
EffectSpec::Distortion {
drive: 4.0,
output: 0.6,
},
EffectSpec::Compressor {
threshold_db: -18.0,
ratio: 4.0,
makeup_db: 4.0,
},
EffectSpec::Reverb {
room: 0.3,
damp: 0.5,
wet: 0.2,
},
],
gain: 1.0,
gate_db: -38.0,
},
]
}
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| {
p.name.eq_ignore_ascii_case(name)
|| p.name
.split('/')
.next()
.map(|s| s.trim().eq_ignore_ascii_case(name))
.unwrap_or(false)
})
}
}
pub fn build_effects(specs: &[EffectSpec], sample_rate: u32) -> Vec<Box<dyn Effect>> {
specs.iter().map(|s| build_effect(s, sample_rate)).collect()
}
fn build_effect(spec: &EffectSpec, sample_rate: u32) -> Box<dyn Effect> {
match spec {
EffectSpec::Distortion { drive, output } => {
Box::new(effects::Distortion::new(*drive, *output))
}
EffectSpec::Overdrive { drive, output } => {
Box::new(effects::Overdrive::new(*drive, *output))
}
EffectSpec::Bitcrush {
bits,
sample_rate_div,
max_gain,
target,
} => Box::new(effects::Bitcrush::new(
*bits,
*sample_rate_div,
*max_gain,
*target,
)),
EffectSpec::Reverb { room, damp, wet } => {
Box::new(effects::Reverb::new(sample_rate, *room, *damp, *wet))
}
EffectSpec::Chorus {
depth_ms,
rate_hz,
wet,
} => Box::new(effects::Chorus::new(sample_rate, *depth_ms, *rate_hz, *wet)),
EffectSpec::Flanger {
depth_ms,
rate_hz,
feedback,
wet,
} => Box::new(effects::Flanger::new(
sample_rate,
*depth_ms,
*rate_hz,
*feedback,
*wet,
)),
EffectSpec::Tremolo { depth, rate_hz } => {
Box::new(effects::Tremolo::new(sample_rate, *depth, *rate_hz))
}
EffectSpec::RingMod { freq_hz, depth } => {
Box::new(effects::RingMod::new(sample_rate, *freq_hz, *depth))
}
EffectSpec::Bandpass {
low_hz,
high_hz,
mix,
} => Box::new(effects::Bandpass::new(sample_rate, *low_hz, *high_hz, *mix)),
EffectSpec::Noise { gain } => Box::new(effects::Noise::new(*gain)),
EffectSpec::Compressor {
threshold_db,
ratio,
makeup_db,
} => Box::new(effects::Compressor::new(
sample_rate,
*threshold_db,
*ratio,
*makeup_db,
)),
EffectSpec::Vocoder {
bands,
carrier,
carrier_hz,
wet,
} => Box::new(effects::ChannelVocoder::new(
sample_rate,
*bands,
*carrier,
*carrier_hz,
*wet,
)),
}
}

62
src/dsp/shift/analysis.rs Normal file
View file

@ -0,0 +1,62 @@
//! Frame analysis: fundamental detection and cepstral envelope.
use super::shifter::PitchShifter;
use rustfft::num_complex::Complex;
impl PitchShifter {
/// Fundamental frequency via harmonic product sum. Sets `last_f0` (Hz),
/// or 0.0 when the frame is unvoiced/weak.
pub(crate) fn detect_f0(&mut self) {
let n2 = self.n / 2;
let bin_hz = self.fs as f32 / self.n as f32;
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;
}
}
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.
pub(crate) fn compute_envelope(&mut self) {
let n2 = self.n / 2;
for i in 0..=n2 {
let l = self.logmag[i];
self.cep[i] = Complex::new(l, 0.0);
if i > 0 && i < n2 {
self.cep[self.n - i] = Complex::new(l, 0.0);
}
}
self.env_ifft.process(&mut self.cep);
let inv = 1.0 / self.n as f32;
// Lifter: keep low quefrencies (smooth envelope), zero the rest.
for (i, c) in self.cep.iter_mut().enumerate() {
let keep = if i <= self.lifter_len { 1.0 } else { 0.0 };
c.re *= keep * inv;
c.im = 0.0;
}
self.env_fft.process(&mut self.cep);
for i in 0..=n2 {
self.env[i] = self.cep[i].re.exp().max(1e-6);
}
}
}

91
src/dsp/shift/frame.rs Normal file
View file

@ -0,0 +1,91 @@
//! Per-frame FFT processing: pitch/formant resampling with phase propagation.
use super::shifter::PitchShifter;
use super::{interpolate, princarg};
use rustfft::num_complex::Complex;
impl PitchShifter {
pub(crate) fn process_frame(&mut self) {
let n2 = self.n / 2;
for (i, c) in self.spec.iter_mut().enumerate() {
*c = Complex::new(self.in_buf[i] * self.window[i], 0.0);
}
self.fft.process(&mut self.spec);
for i in 0..=n2 {
let c = self.spec[i];
let m = c.norm();
self.mag[i] = m;
self.phase[i] = c.arg();
self.logmag[i] = (m + 1e-12).ln();
}
self.compute_envelope();
self.detect_f0();
let pr = self.pitch_ratio;
let fr = self.formant_ratio;
let p_active = (pr - 1.0).abs() > 1e-4;
let f_active = (fr - 1.0).abs() > 1e-4;
if p_active || f_active {
// Phase propagation (Laroche & Dolson): track the instantaneous
// frequency of each input bin so reconstructed components stay
// coherent across frames.
let hop = self.hop as f32;
let nf = self.n as f32;
let mut new_mag = vec![0.0; n2 + 1];
let mut new_phase = vec![0.0; n2 + 1];
for (b, nm) in new_mag.iter_mut().enumerate() {
let bf = b as f32;
// Source input bin for output bin b under the pitch shift.
let src = if p_active { bf / pr } else { bf };
let base = interpolate(&self.mag, src);
// Formants: move the envelope shape by `fr` independently.
if f_active {
let num = interpolate(&self.env, bf / fr);
let den = interpolate(&self.env, bf / pr);
let ratio = (num / den.max(1e-9)).clamp(0.3, 3.3);
*nm = base * ratio;
} else {
*nm = base;
}
// Instantaneous frequency of the source bin (rad per frame).
let si = src.round().clamp(0.0, n2 as f32) as usize;
let omega_src = 2.0 * std::f32::consts::PI * si as f32 / nf;
let diff = princarg(self.phase[si] - self.prev_phase[si] - omega_src * hop);
let inst = omega_src + diff / hop;
let advance = if p_active {
inst * hop * pr
} else {
inst * hop
};
self.out_phase[b] += advance;
new_phase[b] = self.out_phase[b];
self.prev_phase[si] = self.phase[si];
}
// Rebuild the conjugate-symmetric spectrum.
for (b, (&nm, &ph)) in new_mag.iter().zip(new_phase.iter()).enumerate() {
self.spec[b] = Complex::from_polar(nm, ph);
if b > 0 && b < n2 {
self.spec[self.n - b] = Complex::from_polar(nm, -ph);
}
}
}
// Synthesis: inverse FFT + window + overlap-add.
self.ifft.process(&mut self.spec);
let scale = 1.0 / (self.n as f32 * self.norm);
for i in 0..self.n {
let y = self.spec[i].re * self.window[i] * scale;
self.out_buf[i] += y;
}
}
}

149
src/dsp/shift/mod.rs Normal file
View file

@ -0,0 +1,149 @@
//! Real-time pitch/formant shifting via phase vocoder.
//!
//! Resamples the spectral *residual* to change pitch while keeping the
//! envelope (formants), and independently resamples the envelope to change
//! formants. Latency is one FFT frame (~21 ms @ 48 kHz).
pub mod shifter;
mod analysis;
mod frame;
pub use shifter::PitchShifter;
/// Linear interpolation over `arr` at fractional position `pos`.
pub(crate) fn interpolate(arr: &[f32], pos: f32) -> f32 {
let max = arr.len() as f32 - 1.0;
let pos = pos.clamp(0.0, max);
let i = pos.floor() as usize;
let j = (i + 1).min(arr.len() - 1);
let t = pos - i as f32;
arr[i] * (1.0 - t) + arr[j] * t
}
/// Wrap a phase difference into (-PI, PI].
pub(crate) fn princarg(x: f32) -> f32 {
x - 2.0 * std::f32::consts::PI * (x / (2.0 * std::f32::consts::PI)).round()
}
#[cfg(test)]
mod tests {
use super::*;
use rustfft::num_complex::Complex;
fn saw(freq: f32, fs: u32, samples: usize) -> Vec<f32> {
(0..samples)
.map(|i| {
let t = freq * i as f32 / fs as f32;
let mut s = 0.0;
for h in 1..=12 {
s += ((2.0 * std::f32::consts::PI * h as f32 * t).sin()) / h as f32;
}
s
})
.collect()
}
fn sine(freq: f32, fs: u32, samples: usize) -> Vec<f32> {
(0..samples)
.map(|i| (2.0 * std::f32::consts::PI * freq * i as f32 / fs as f32).sin())
.collect()
}
fn rms(x: &[f32]) -> f32 {
let s: f32 = x.iter().map(|v| v * v).sum();
(s / x.len().max(1) as f32).sqrt()
}
fn dominant_hz(x: &[f32], fs: u32) -> f32 {
use rustfft::FftPlanner;
let n = 4096;
if x.len() < n {
return 0.0;
}
let mut planner = FftPlanner::<f32>::new();
let fft = planner.plan_fft_forward(n);
let mut buf: Vec<Complex<f32>> = x[..n].iter().map(|&s| Complex::new(s, 0.0)).collect();
fft.process(&mut buf);
let bins = n / 2;
let mags: Vec<f32> = (0..bins).map(|b| buf[b].norm()).collect();
let mut best = (0usize, 0.0f32);
for b in 20..(bins / 4) {
let mut s = 0.0;
for h in 1..=4 {
if b * h < bins {
s += mags[b * h];
}
}
if s > best.1 {
best = (b, s);
}
}
fs as f32 * best.0 as f32 / n as f32
}
#[test]
fn pitch_shift_produces_energy() {
let mut sh = PitchShifter::new(1024, 256, 48000);
sh.pitch_ratio = 2f32.powf(5.0 / 12.0);
let input = saw(220.0, 48000, 48000);
let mut out = Vec::new();
sh.process(&input, &mut out);
assert!(out.iter().take(20000).all(|v| v.is_finite()), "non-finite");
let r = rms(&out[1000..20000]);
assert!(r > 0.05, "rms too low: {r}");
let est = dominant_hz(&out[1000..20000], 48000);
assert!(
(est - 293.7).abs() < 8.0,
"expected ~293.7 Hz, got {est:.1}"
);
}
#[test]
fn block_feed_matches_chain() {
let mut sh = PitchShifter::new(1024, 256, 48000);
sh.pitch_ratio = 2f32.powf(5.0 / 12.0);
let input = sine(220.0, 48000, 48000);
let mut pending = Vec::new();
let mut out_all = Vec::new();
for chunk in input.chunks(2560) {
let mut out = Vec::new();
sh.process(chunk, &mut out);
pending.extend(out);
let take = chunk.len().min(pending.len());
let mut mono = vec![0.0; chunk.len()];
mono[..take].copy_from_slice(&pending[..take]);
out_all.extend(mono);
pending.drain(..take);
}
assert!(out_all.iter().all(|v| v.is_finite()), "non-finite");
let r = rms(&out_all[4000..40000]);
assert!(r > 0.05, "block-feed rms too low: {r}");
}
#[test]
fn one_shot_vs_block_fed() {
let mut a = PitchShifter::new(1024, 256, 48000);
a.pitch_ratio = 2f32.powf(5.0 / 12.0);
let input = saw(220.0, 48000, 48000);
let mut out_a = Vec::new();
a.process(&input, &mut out_a);
let mut b = PitchShifter::new(1024, 256, 48000);
b.pitch_ratio = 2f32.powf(5.0 / 12.0);
let mut out_b = Vec::new();
for chunk in input.chunks(1024) {
b.process(chunk, &mut out_b);
}
let n = out_a.len().min(out_b.len());
assert!(n > 10000, "n={n}");
let mut max_diff = 0.0f32;
for i in 3000..n {
let d = (out_a[i] - out_b[i]).abs();
if d > max_diff {
max_diff = d;
}
}
assert!(max_diff < 1e-3, "differs: max_diff={max_diff}");
}
}

104
src/dsp/shift/shifter.rs Normal file
View file

@ -0,0 +1,104 @@
//! The phase-vocoder shifter: streaming interface and buffers.
use rustfft::num_complex::Complex;
use rustfft::{Fft, FftPlanner};
use std::sync::Arc;
pub struct PitchShifter {
pub(crate) n: usize,
pub(crate) hop: usize,
pub(crate) window: Vec<f32>,
pub(crate) norm: f32,
pub(crate) fft: Arc<dyn Fft<f32>>,
pub(crate) ifft: Arc<dyn Fft<f32>>,
pub(crate) env_ifft: Arc<dyn Fft<f32>>,
pub(crate) env_fft: Arc<dyn Fft<f32>>,
pub(crate) in_buf: Vec<f32>,
pub(crate) out_buf: Vec<f32>,
pub(crate) spec: Vec<Complex<f32>>,
pub(crate) mag: Vec<f32>,
pub(crate) phase: Vec<f32>,
pub(crate) logmag: Vec<f32>,
pub(crate) env: Vec<f32>,
pub(crate) cep: Vec<Complex<f32>>,
pub(crate) prev_phase: Vec<f32>,
pub(crate) out_phase: Vec<f32>,
pub(crate) lifter_len: usize,
pub(crate) fs: u32,
/// Pitch factor: output pitch = input pitch * ratio (1.0 = unchanged).
pub pitch_ratio: f32,
/// Formant factor: output formants = input formants * ratio (1.0 = unchanged).
pub formant_ratio: f32,
/// Detected fundamental of the most recent frame (Hz, 0 = unvoiced).
pub last_f0: f32,
}
impl PitchShifter {
pub fn new(fft_size: usize, hop: usize, sample_rate: u32) -> Self {
debug_assert!(fft_size.is_multiple_of(hop));
let frames = fft_size / hop;
let mut window = vec![0.0; fft_size];
for (i, w) in window.iter_mut().enumerate() {
*w =
0.5 - 0.5 * (2.0 * std::f32::consts::PI * i as f32 / (fft_size as f32 - 1.0)).cos();
}
// Steady-state OLA normalization (75% overlap: every sample is covered
// by `frames` windows; sum of w^2 over those offsets).
let mut norm = 0.0;
for i in 0..frames {
let idx = i * hop;
norm += window[idx] * window[idx];
}
debug_assert!(norm > 0.0);
let mut planner = FftPlanner::<f32>::new();
let fft = planner.plan_fft_forward(fft_size);
let ifft = planner.plan_fft_inverse(fft_size);
let env_ifft = planner.plan_fft_inverse(fft_size);
let env_fft = planner.plan_fft_forward(fft_size);
Self {
n: fft_size,
hop,
window,
norm,
fft,
ifft,
env_ifft,
env_fft,
in_buf: Vec::new(),
out_buf: vec![0.0; fft_size],
spec: vec![Complex::new(0.0, 0.0); fft_size],
mag: vec![0.0; fft_size / 2 + 1],
phase: vec![0.0; fft_size / 2 + 1],
logmag: vec![0.0; fft_size / 2 + 1],
env: vec![0.0; fft_size / 2 + 1],
cep: vec![Complex::new(0.0, 0.0); fft_size],
prev_phase: vec![0.0; fft_size / 2 + 1],
out_phase: vec![0.0; fft_size / 2 + 1],
lifter_len: fft_size / 8,
fs: sample_rate,
pitch_ratio: 1.0,
formant_ratio: 1.0,
last_f0: 0.0,
}
}
/// Stream `input` through the shifter, appending processed samples to
/// `output` (same count in steady state, plus startup latency).
pub fn process(&mut self, input: &[f32], output: &mut Vec<f32>) {
for &sample in input {
self.in_buf.push(sample);
if self.in_buf.len() == self.n {
self.process_frame();
self.in_buf.drain(..self.hop);
output.extend_from_slice(&self.out_buf[..self.hop]);
self.out_buf.drain(..self.hop);
self.out_buf.resize(self.n, 0.0);
}
}
}
}

View file

@ -1,35 +0,0 @@
//! Runtime statistics produced by the DSP worker and shown in the TUI.
use std::time::Instant;
pub struct Stats {
/// Smoothed input level (RMS, linear).
pub input_rms: f32,
/// Smoothed output level (RMS, linear).
pub output_rms: f32,
pub peak_in: f32,
pub peak_out: f32,
pub blocks: u64,
pub samples: u64,
/// Smoothed DSP load in percent of one core.
pub cpu_pct: f32,
/// Estimated DSP latency in milliseconds.
pub dsp_latency_ms: f32,
pub started: Instant,
}
impl Default for Stats {
fn default() -> Self {
Self {
input_rms: 0.0,
output_rms: 0.0,
peak_in: 0.0,
peak_out: 0.0,
blocks: 0,
samples: 0,
cpu_pct: 0.0,
dsp_latency_ms: 0.0,
started: Instant::now(),
}
}
}

View file

@ -79,10 +79,10 @@ fn main() -> Result<()> {
let cli = Cli::parse();
let file = FileConfig::load();
dsp::presets::init_custom(file.to_presets());
dsp::preset::init_custom(file.to_presets());
if cli.list {
audio::devices::print_all()?;
audio::print_all()?;
return Ok(());
}

231
src/ui/app.rs Normal file
View file

@ -0,0 +1,231 @@
//! App state, the live snapshot and keyboard / mouse handling.
use crate::dsp::control::Stats;
use crate::dsp::control::{Control, Mode};
use crate::dsp::preset::presets;
use crate::ui::SessionInfo;
use crossterm::event::{KeyCode, KeyModifiers};
use std::sync::{Arc, Mutex};
use std::time::Instant;
#[derive(Clone, Copy, PartialEq)]
pub(super) enum View {
Main,
Settings,
Help,
}
pub(super) struct App {
pub(super) control: Arc<Mutex<Control>>,
pub(super) stats: Arc<Mutex<Stats>>,
pub(super) info: SessionInfo,
pub(super) view: View,
pub(super) settings_cursor: usize,
pub(super) splash_until: Instant,
pub(super) started: Instant,
pub(super) running: bool,
}
/// Copy of the live control values used by the render layer.
pub(super) struct Snap {
pub(super) preset_idx: usize,
pub(super) name: String,
pub(super) pitch: f32,
pub(super) formant: f32,
pub(super) gate: f32,
pub(super) gain: f32,
pub(super) mute: bool,
pub(super) record: bool,
pub(super) tone: bool,
pub(super) tune: bool,
pub(super) mode: Mode,
}
impl App {
/// Current live values as a plain snapshot for rendering.
pub(super) fn snapshot(&self) -> Snap {
self.control
.lock()
.map(|c| {
let p = &presets()[c.preset_idx.min(presets().len() - 1)];
Snap {
preset_idx: c.preset_idx.min(presets().len() - 1),
name: p.name.to_string(),
pitch: p.pitch_semitones + c.pitch_delta,
formant: p.formant_ratio * 2f32.powf(c.formant_delta / 12.0),
gate: if c.gate_threshold_db > -90.0 {
c.gate_threshold_db
} else {
p.gate_db
},
gain: c.gain,
mute: c.mute,
record: c.record,
tone: c.tone_hz > 0.0,
tune: c.pitch_correct,
mode: c.mode,
}
})
.unwrap_or(Snap {
preset_idx: 0,
name: "Clean".into(),
pitch: 0.0,
formant: 1.0,
gate: -100.0,
gain: 1.0,
mute: false,
record: false,
tone: false,
tune: false,
mode: Mode::Live,
})
}
/// Rendered value of a settings row.
pub(super) fn setting_value(&self, idx: usize) -> String {
let s = self.snapshot();
match idx {
0 => s.name,
1 => format!("{:+.1} st", s.pitch),
2 => format!("{:.2}×", s.formant),
3 => {
if s.gate <= -90.0 {
"off".to_string()
} else {
format!("{:.0} dB", s.gate)
}
}
4 => format!("{:+.1} dB", 20.0 * s.gain.max(1e-6).log10()),
5 => if s.mute { "ON" } else { "off" }.to_string(),
6 => match s.mode {
Mode::Live => "LIVE".to_string(),
Mode::Passthrough => "PASSTHROUGH".to_string(),
},
7 => if s.record { "recording" } else { "off" }.to_string(),
8 => if s.tune { "ON" } else { "off" }.to_string(),
_ => String::new(),
}
}
/// Adjust a settings row. `step` is negative/positive; toggles ignore it.
fn adjust(&self, idx: usize, step: i32) {
self.with_ctrl(|c| match idx {
0 => {
let n = presets().len() as i32;
c.preset_idx = ((c.preset_idx as i32 + step).rem_euclid(n)) as usize;
}
1 => c.pitch_delta += step as f32,
2 => c.formant_delta += step as f32,
3 => {
c.gate_threshold_db =
(c.gate_threshold_db + step as f32 * 2.0).clamp(-100.0, -20.0);
}
4 => {
let db = 20.0 * c.gain.max(1e-6).log10() + step as f32;
c.gain = 10f32.powf(db / 20.0).clamp(0.0316, 4.0); // -30 .. +12 dB
}
5 => c.mute = !c.mute,
6 => c.mode.toggle(),
7 => c.record = !c.record,
8 => c.pitch_correct = !c.pitch_correct,
_ => {}
});
}
pub(super) fn on_key(&mut self, key: crossterm::event::KeyEvent) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let quit = key.code == KeyCode::Char('q')
|| (ctrl && (key.code == KeyCode::Char('c') || key.code == KeyCode::Char('q')));
// Any key dismisses the splash.
if Instant::now() < self.splash_until {
self.splash_until = Instant::now();
if quit {
self.set_quit();
}
return;
}
if quit {
self.set_quit();
return;
}
match self.view {
View::Help => {
if key.code == KeyCode::Esc
|| key.code == KeyCode::Char('h')
|| key.code == KeyCode::Char('H')
{
self.view = View::Main;
}
}
View::Main => self.on_key_main(key),
View::Settings => self.on_key_settings(key),
}
}
fn on_key_main(&mut self, key: crossterm::event::KeyEvent) {
match key.code {
KeyCode::Up => self.with_ctrl(|c| {
c.preset_idx = (c.preset_idx + presets().len() - 1) % presets().len()
}),
KeyCode::Down => {
self.with_ctrl(|c| c.preset_idx = (c.preset_idx + 1) % presets().len())
}
KeyCode::Left => self.adjust(1, -1),
KeyCode::Right => self.adjust(1, 1),
KeyCode::Char('s') | KeyCode::Char('S') | KeyCode::Tab => self.view = View::Settings,
KeyCode::Char('h') | KeyCode::Char('H') => self.view = View::Help,
KeyCode::Char('p') | KeyCode::Char('P') => {
self.with_ctrl(|c| c.preset_idx = (c.preset_idx + 1) % presets().len())
}
KeyCode::Char(d) if d.is_ascii_digit() => self.with_ctrl(|c| {
let n = d.to_digit(10).unwrap() as usize;
c.preset_idx = if n == 0 {
presets().len() - 1
} else {
(n - 1).min(presets().len() - 1)
};
}),
KeyCode::Char('[') => self.adjust(1, -1),
KeyCode::Char(']') => self.adjust(1, 1),
KeyCode::Char('{') => self.adjust(2, -1),
KeyCode::Char('}') => self.adjust(2, 1),
KeyCode::Char('g') | KeyCode::Char('G') => self.adjust(3, -1),
KeyCode::Char('b') | KeyCode::Char('B') => self.adjust(6, 1),
KeyCode::Char('m') | KeyCode::Char('M') => self.adjust(5, 1),
KeyCode::Char('r') | KeyCode::Char('R') => self.adjust(7, 1),
KeyCode::Char('+') | KeyCode::Char('=') => self.adjust(4, 1),
KeyCode::Char('-') => self.adjust(4, -1),
_ => {}
}
}
fn on_key_settings(&mut self, key: crossterm::event::KeyEvent) {
match key.code {
KeyCode::Up => self.settings_cursor = self.settings_cursor.saturating_sub(1),
KeyCode::Down => self.settings_cursor = (self.settings_cursor + 1).min(7),
KeyCode::Left => self.adjust(self.settings_cursor, -1),
KeyCode::Right => self.adjust(self.settings_cursor, 1),
KeyCode::Enter => self.adjust(self.settings_cursor, 1),
KeyCode::Esc | KeyCode::Char('s') | KeyCode::Char('S') | KeyCode::Tab => {
self.view = View::Main
}
KeyCode::Char('h') | KeyCode::Char('H') => self.view = View::Help,
KeyCode::Char('q') => {}
_ => {}
}
}
pub(super) fn set_quit(&mut self) {
self.with_ctrl(|c| c.quit = true);
self.running = false;
}
pub(super) fn with_ctrl(&self, f: impl FnOnce(&mut Control)) {
if let Ok(mut c) = self.control.lock() {
f(&mut c);
}
}
}

View file

@ -1,4 +1,13 @@
pub mod tui;
//! Ratatui full-screen interface: `app` keeps state, `render` draws screens
//! and drives the main loop, `widgets` provides shared helpers, `screens`
//! builds the widgets for each screen.
pub mod app;
pub mod render;
pub mod screens;
pub mod widgets;
pub use render::run;
/// Static information about the audio session, shown in the TUI.
pub struct SessionInfo {

215
src/ui/render.rs Normal file
View file

@ -0,0 +1,215 @@
//! Frame rendering for each screen, mouse input and the main TUI run loop.
use crate::dsp::control::Control;
use crate::dsp::control::Stats;
use crate::dsp::preset::presets;
use crate::ui::app::{App, View};
use crate::ui::screens::{
header_line, help, params_paragraph, presets_paragraph, settings_paragraph, splash,
stats_column, StatsSnap,
};
use crate::ui::widgets::{self, main_layout, settings_layout};
use crate::ui::SessionInfo;
use crossterm::event::{self, Event, MouseButton, MouseEvent, MouseEventKind};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::{Frame, Terminal};
use std::io::{stdout, IsTerminal, Write};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
impl App {
fn render(&mut self, f: &mut Frame) {
if Instant::now() < self.splash_until {
splash(f, f.area());
return;
}
match self.view {
View::Main => self.render_main(f),
View::Settings => self.render_settings(f),
View::Help => help(f, f.area()),
}
}
fn render_main(&mut self, f: &mut Frame) {
let area = f.area();
let (head, body, foot) = main_layout(area);
self.render_header(f, head);
let s = self.snapshot();
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(38),
Constraint::Percentage(28),
Constraint::Percentage(34),
])
.split(body);
f.render_widget(presets_paragraph(s.preset_idx), cols[0]);
f.render_widget(params_paragraph(&s), cols[1]);
self.render_stats(f, cols[2]);
self.render_footer(
f,
foot,
" ↑↓ preset | ←→ pitch | S settings | H help | Q quit ",
);
}
fn render_settings(&mut self, f: &mut Frame) {
let area = f.area();
let (head, list_rect, foot) = settings_layout(area);
self.render_header(f, head);
let values: Vec<String> = (0..9).map(|i| self.setting_value(i)).collect();
f.render_widget(settings_paragraph(&values, self.settings_cursor), list_rect);
self.render_footer(
f,
foot,
" ↑↓ select | ←→ change | Esc back | H help | Q quit ",
);
}
fn render_header(&self, f: &mut Frame, area: Rect) {
f.render_widget(Paragraph::new(header_line(&self.snapshot())), area);
}
fn render_footer(&self, f: &mut Frame, area: Rect, hint: &str) {
let footer = Line::from(Span::styled(hint, Style::default().fg(Color::DarkGray)));
f.render_widget(Paragraph::new(footer), area);
}
fn render_stats(&self, f: &mut Frame, area: Rect) {
let snap = self
.stats
.lock()
.map(|s| StatsSnap {
in_db: widgets::db(s.input_rms),
out_db: widgets::db(s.output_rms),
peak_in: widgets::db(s.peak_in),
peak_out: widgets::db(s.peak_out),
cpu: s.cpu_pct,
latency: s.dsp_latency_ms,
blocks: s.blocks,
samples: s.samples,
uptime: s.started.elapsed().as_secs(),
})
.unwrap_or_default();
stats_column(f, area, &self.info, &snap);
}
/// Map a mouse click onto the current screen (preset / settings row).
fn on_mouse(&mut self, mouse: MouseEvent) {
if Instant::now() < self.splash_until {
self.splash_until = Instant::now();
return;
}
if let MouseEventKind::Down(MouseButton::Left) = mouse.kind {
let area = crossterm::terminal::size()
.map(|(w, h)| Rect::new(0, 0, w, h))
.unwrap_or(Rect::new(0, 0, 100, 30));
match self.view {
View::Main => {
let presets_rect = main_layout(area).0;
if mouse.row >= presets_rect.y
&& mouse.row < presets_rect.y + presets_rect.height
{
let idx = (mouse.row - presets_rect.y) as usize;
if idx < presets().len() {
self.with_ctrl(|c| c.preset_idx = idx);
}
}
}
View::Settings => {
let list_rect = settings_layout(area).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 < 9 {
self.settings_cursor = idx;
}
}
}
View::Help => self.view = View::Main,
}
}
}
}
/// Run headless until `run_seconds` elapse or `quit` is set.
fn headless_wait(control: &Arc<Mutex<Control>>, run_seconds: u32) {
let started = Instant::now();
loop {
let done = control
.lock()
.map(|c| c.quit || run_seconds > 0 && started.elapsed().as_secs() >= run_seconds as u64)
.unwrap_or(true);
if done {
if let Ok(mut c) = control.lock() {
c.quit = true;
}
break;
}
std::thread::sleep(Duration::from_millis(50));
}
}
pub fn run(control: Arc<Mutex<Control>>, stats: Arc<Mutex<Stats>>, info: SessionInfo) {
let run_seconds = control.lock().ok().and_then(|c| c.run_seconds).unwrap_or(0);
if !std::io::stdin().is_terminal() || enable_raw_mode().is_err() {
// No real terminal: keep the audio running headless.
headless_wait(&control, run_seconds);
return;
}
let _ = crossterm::execute!(stdout(), crossterm::event::EnableMouseCapture);
let mut terminal = match Terminal::new(CrosstermBackend::new(stdout())) {
Ok(t) => t,
Err(e) => {
disable_raw_mode().ok();
eprintln!("tui init failed: {e}");
return;
}
};
let _ = terminal.clear();
let mut app = App {
control,
stats,
info,
view: View::Main,
settings_cursor: 0,
splash_until: Instant::now() + Duration::from_millis(1500),
started: Instant::now(),
running: true,
};
while app.running {
let _ = terminal.draw(|f| app.render(f));
let _ = stdout().flush();
if event::poll(Duration::from_millis(50)).unwrap_or(false) {
if let Ok(ev) = event::read() {
match ev {
Event::Key(key) => app.on_key(key),
Event::Mouse(m) => app.on_mouse(m),
_ => {}
}
}
}
if run_seconds > 0 && app.started.elapsed().as_secs() >= run_seconds as u64 {
app.set_quit();
}
}
let _ = terminal.show_cursor();
let _ = crossterm::execute!(stdout(), crossterm::event::DisableMouseCapture);
disable_raw_mode().ok();
println!("shutting down...");
}

150
src/ui/screens/main.rs Normal file
View file

@ -0,0 +1,150 @@
//! Splash, help, header, preset and parameter widgets of the main screen.
use crate::dsp::control::Mode;
use crate::dsp::preset::presets;
use crate::ui::app::Snap;
use crate::ui::widgets::{block, centered_rect, p_row, HELP_TEXT, LOGO};
use ratatui::layout::{Alignment, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
/// Splash screen shown for the first ~1.5s.
pub(crate) fn splash(f: &mut Frame, area: Rect) {
let mut lines: Vec<Line> = Vec::new();
for (i, l) in LOGO.iter().enumerate() {
let color = match i {
0 | 1 => Color::Cyan,
2 | 3 => Color::Magenta,
4 | 5 => Color::Blue,
_ => Color::Gray,
};
lines.push(Line::styled(
l.to_string(),
Style::default().fg(color).add_modifier(Modifier::BOLD),
));
}
lines.push(Line::from(""));
lines.push(Line::from(
" press any key, click, or use the arrow keys ...",
));
lines.push(Line::styled(
" audio is already running",
Style::default().fg(Color::DarkGray),
));
let para = Paragraph::new(lines).alignment(Alignment::Center);
f.render_widget(para, centered_rect(70, 55, area));
}
/// Static key reference screen.
pub(crate) fn help(f: &mut Frame, area: Rect) {
let para = Paragraph::new(HELP_TEXT)
.style(Style::default().fg(Color::White))
.block(block(" vois · help "));
f.render_widget(para, area);
}
/// Top status line: mode, record / tone / tune / mute indicators.
pub(crate) fn header_line(s: &Snap) -> Line<'static> {
let mode_txt = match s.mode {
Mode::Live => "LIVE",
Mode::Passthrough => "A/B PASSTHROUGH",
};
let rec = if s.record { "● REC" } else { "" };
let tone = if s.tone { "● TONE" } else { "" };
let tune = if s.tune { "● TUNE" } else { "" };
let mute = if s.mute { "MUTED" } else { "" };
let b = Modifier::BOLD;
Line::from(vec![
Span::styled(" VOIS ", Style::default().fg(Color::Cyan).add_modifier(b)),
Span::styled(
"real-time voice changer",
Style::default()
.fg(Color::Gray)
.add_modifier(Modifier::ITALIC),
),
Span::raw(" · "),
Span::styled(mode_txt, Style::default().fg(Color::Yellow).add_modifier(b)),
Span::raw(" "),
Span::styled(rec, Style::default().fg(Color::Red).add_modifier(b)),
Span::raw(" "),
Span::styled(tone, Style::default().fg(Color::Blue)),
Span::raw(" "),
Span::styled(tune, Style::default().fg(Color::Magenta)),
Span::raw(" "),
Span::styled(mute, Style::default().fg(Color::Red).add_modifier(b)),
])
}
/// Preset list column, current one highlighted.
pub(crate) fn presets_paragraph(preset_idx: usize) -> Paragraph<'static> {
let mut lines = Vec::new();
for (i, p) in presets().iter().enumerate() {
let selected = i == preset_idx;
let label = format!(
"{}{} {}",
if selected { "▶" } else { " " },
if selected { "" } else { " " },
p.name
);
let style = if selected {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Gray)
};
lines.push(Line::styled(label, style));
}
Paragraph::new(lines).block(block(" presets · ↑↓ to switch "))
}
/// Parameters column built from the live values.
pub(crate) fn params_paragraph(s: &Snap) -> Paragraph<'static> {
let gate = if s.gate <= -90.0 {
"off".to_string()
} else {
format!("{:.0} dB", s.gate)
};
let gain = 20.0 * s.gain.max(1e-6).log10();
let lines = vec![
p_row("preset", &s.name, Color::Yellow, "voice character"),
p_row(
"pitch",
&format!("{:+.1} st", s.pitch),
Color::Cyan,
"higher=girl, lower=deep",
),
p_row(
"formant",
&format!("{:.2}×", s.formant),
Color::Magenta,
"brightness/timbre",
),
p_row("gate", &gate, Color::Blue, "noise below this dB is cut"),
p_row(
"gain",
&format!("{gain:+.1} dB"),
Color::Green,
"output loudness",
),
p_row(
"mute",
if s.mute { "ON" } else { "off" },
if s.mute { Color::Red } else { Color::DarkGray },
"silence output",
),
p_row(
"tune",
if s.tune { "ON" } else { "off" },
if s.tune {
Color::Magenta
} else {
Color::DarkGray
},
"auto-tune: snap pitch",
),
];
Paragraph::new(lines).block(block(" parameters · info "))
}

9
src/ui/screens/mod.rs Normal file
View file

@ -0,0 +1,9 @@
//! Widget builders for each screen, one file per screen.
pub(super) mod main;
pub(super) mod settings;
pub(super) mod stats;
pub(super) use main::{header_line, help, params_paragraph, presets_paragraph, splash};
pub(super) use settings::settings_paragraph;
pub(super) use stats::{stats_column, StatsSnap};

View file

@ -0,0 +1,52 @@
//! Settings screen widget builder.
use crate::ui::widgets::{block, SETTING_INFO};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
/// Settings rows from rendered values and the current cursor row.
pub(crate) fn settings_paragraph(values: &[String], cursor: usize) -> Paragraph<'static> {
let labels = [
"Preset",
"Pitch",
"Formant",
"Gate (min)",
"Gain",
"Mute",
"Mode",
"Record",
"Pitch corr",
];
let mut lines = Vec::new();
for (i, label) in labels.iter().enumerate() {
let selected = i == cursor;
let style = if selected {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Gray)
};
let info_style = Style::default().fg(Color::DarkGray);
lines.push(Line::from(vec![
Span::styled(
format!(
"{}{} {:<12}",
if selected { "▶" } else { " " },
if selected { "" } else { " " },
label
),
style,
),
Span::styled(format!("{:<16}", values[i]), style),
Span::styled(SETTING_INFO[i], info_style),
]));
}
lines.push(Line::from(""));
lines.push(Line::styled(
" use ↑ ↓ to select, ← → (or Enter) to change, Esc to go back",
Style::default().fg(Color::DarkGray),
));
Paragraph::new(lines).block(block(" settings "))
}

121
src/ui/screens/stats.rs Normal file
View file

@ -0,0 +1,121 @@
//! Stats screen widget: peak meters, dsp load, rates, uptime, devices.
use crate::ui::widgets::{meter_ratio, meter_style};
use crate::ui::SessionInfo;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Gauge, Paragraph};
use ratatui::Frame;
/// Snapshot of the DSP stats column, pre-computed by the render layer.
#[derive(Default)]
pub(crate) struct StatsSnap {
pub(crate) in_db: f32,
pub(crate) out_db: f32,
pub(crate) peak_in: f32,
pub(crate) peak_out: f32,
pub(crate) cpu: f32,
pub(crate) latency: f32,
pub(crate) blocks: u64,
pub(crate) samples: u64,
pub(crate) uptime: u64,
}
/// Stats column: meters on top, text lines below.
pub(crate) fn stats_column(f: &mut Frame, area: Rect, info: &SessionInfo, s: &StatsSnap) {
let secs = (s.uptime as f32).max(0.1);
let lines = vec![
Line::from(""),
Line::from(vec![
Span::styled(" peak in ", Style::default().fg(Color::DarkGray)),
Span::styled(format!("{:+.0} dB", s.peak_in), meter_style(s.peak_in)),
Span::styled(" peak out ", Style::default().fg(Color::DarkGray)),
Span::styled(format!("{:+.0} dB", s.peak_out), meter_style(s.peak_out)),
]),
Line::from(""),
Line::from(vec![
Span::styled(" dsp load ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!("{:.1} %", s.cpu),
if s.cpu > 50.0 {
Style::default().fg(Color::Red)
} else {
Style::default().fg(Color::Green)
},
),
Span::raw(" "),
Span::styled("latency", Style::default().fg(Color::DarkGray)),
Span::raw(format!(" {:.0} ms", s.latency)),
]),
Line::from(vec![
Span::styled(" rate ", Style::default().fg(Color::DarkGray)),
Span::raw(format!(
"{} Hz {} ch → {} ch ({} → {})",
info.sample_rate, info.in_ch, info.out_ch, info.format_in, info.format_out
)),
]),
Line::from(vec![
Span::styled(" uptime ", Style::default().fg(Color::DarkGray)),
Span::raw(format!(
"{:02}:{:02}:{:02}",
s.uptime / 3600,
(s.uptime / 60) % 60,
s.uptime % 60
)),
]),
Line::from(vec![
Span::styled(" blocks ", Style::default().fg(Color::DarkGray)),
Span::raw(format!(
"{} ({:.0} blk/s)",
s.blocks,
s.samples as f32 / secs
)),
]),
Line::from(""),
Line::from(Span::styled(
format!(" in: {}", info.input),
Style::default().fg(Color::DarkGray),
)),
Line::from(Span::styled(
format!(" out: {}", info.output),
Style::default().fg(Color::DarkGray),
)),
];
let vert = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2),
Constraint::Length(1),
Constraint::Length(2),
Constraint::Length(1),
Constraint::Min(0),
])
.split(area);
let in_gauge = Gauge::default()
.ratio(meter_ratio(s.in_db))
.gauge_style(meter_style(s.in_db));
let out_gauge = Gauge::default()
.ratio(meter_ratio(s.out_db))
.gauge_style(meter_style(s.out_db));
f.render_widget(
Paragraph::new(Line::from(vec![
Span::raw(" in "),
Span::styled(format!("{:+.0} dB", s.in_db), meter_style(s.in_db)),
])),
vert[0],
);
f.render_widget(in_gauge, vert[1]);
f.render_widget(
Paragraph::new(Line::from(vec![
Span::raw(" out "),
Span::styled(format!("{:+.0} dB", s.out_db), meter_style(s.out_db)),
])),
vert[2],
);
f.render_widget(out_gauge, vert[3]);
f.render_widget(Paragraph::new(lines), vert[4]);
}

View file

@ -1,888 +0,0 @@
//! Full-screen interactive TUI built on ratatui: splash, dashboard with
//! presets / parameters / levels / stats, and a settings screen with arrow-key
//! and mouse control.
use crate::dsp::chain::{Control, Mode};
use crate::dsp::presets::presets;
use crate::dsp::stats::Stats;
use crate::ui::SessionInfo;
use crossterm::event::{
self, Event, KeyCode, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Gauge, Paragraph};
use ratatui::{Frame, Terminal};
use std::io::{stdout, IsTerminal, Write};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
const LOGO: [&str; 7] = [
" ██╗ ██╗ ██████╗ ██╗███████╗ ",
" ██║ ██║██╔═══██╗██║██╔════╝ ",
" ██║ ██║██║ ██║██║███████╗ ",
" ╚██╗ ██╔╝██║ ██║██║╚════██║ ",
" ╚████╔╝ ╚██████╔╝██║███████║ ",
" ╚═══╝ ╚═════╝ ╚═╝╚══════╝ ",
" real-time voice changer ",
];
const SETTING_INFO: [&str; 9] = [
"voice character, previewed live",
"pitch: higher = girl/kid, lower = deep",
"formant: brightness / timbre of the voice",
"noise gate: audio below this dB is cut",
"output loudness, in dB",
"silence the output",
"LIVE = processed, PASSTHROUGH = raw input (A/B)",
"write processed audio to a WAV file",
"auto-tune: snap pitch to the nearest semitone",
];
const HELP_TEXT: &str = "\
KEY / MOUSE ACTION
───────────────────────────────────────────────
↑ ↓ (or mouse) select preset / settings row
← → change value
Enter toggle / apply
S / Tab toggle SETTINGS screen
Esc back to main screen
H toggle this help screen
Q / Ctrl+C quit
0 - 9 jump to preset
M mute R record
B A/B passthrough mode
";
const METER_FLOOR_DB: f32 = -60.0;
fn db(rms: f32) -> f32 {
if rms <= 0.0 {
METER_FLOOR_DB
} else {
(20.0 * rms.log10()).clamp(METER_FLOOR_DB, 6.0)
}
}
fn meter_ratio(db: f32) -> f64 {
((db - METER_FLOOR_DB) / -METER_FLOOR_DB).clamp(0.0, 1.0) as f64
}
fn meter_style(level_db: f32) -> Style {
if level_db >= -6.0 {
Style::default().fg(Color::Red)
} else if level_db >= -20.0 {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::Green)
}
}
#[derive(Clone, Copy, PartialEq)]
enum View {
Main,
Settings,
Help,
}
struct App {
control: Arc<Mutex<Control>>,
stats: Arc<Mutex<Stats>>,
info: SessionInfo,
view: View,
settings_cursor: usize,
splash_until: Instant,
started: Instant,
running: bool,
}
impl App {
/// (preset_idx, preset_name, pitch_st, formant_ratio, gate_db, gain_lin,
/// mute, record, tone, mode)
/// (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)];
(
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),
if c.gate_threshold_db > -90.0 {
c.gate_threshold_db
} else {
preset.gate_db
},
c.gain,
c.mute,
c.record,
c.tone_hz > 0.0,
c.pitch_correct,
c.mode,
)
})
.unwrap_or((
0,
"Clean".into(),
0.0,
1.0,
-100.0,
1.0,
false,
false,
false,
false,
Mode::Live,
))
}
fn setting_value(&self, idx: usize) -> String {
let (_, _name, pitch, formant, gate, gain, mute, record, _tone, tune, mode) =
self.snapshot();
match idx {
0 => presets()[self
.control
.lock()
.map(|c| c.preset_idx.min(presets().len() - 1))
.unwrap_or(0)]
.name
.to_string(),
1 => format!("{pitch:+.1} st"),
2 => format!("{formant:.2}×"),
3 => {
if gate <= -90.0 {
"off".to_string()
} else {
format!("{gate:.0} dB")
}
}
4 => format!("{:+.1} dB", 20.0 * gain.max(1e-6).log10()),
5 => if mute { "ON" } else { "off" }.to_string(),
6 => match mode {
Mode::Live => "LIVE".to_string(),
Mode::Passthrough => "PASSTHROUGH".to_string(),
},
7 => if record { "recording" } else { "off" }.to_string(),
8 => if tune { "ON" } else { "off" }.to_string(),
_ => String::new(),
}
}
/// Adjust a settings row. `step` is negative/positive; toggles ignore it.
fn adjust(&self, idx: usize, step: i32) {
self.with_ctrl(|c| match idx {
0 => {
let n = presets().len() as i32;
c.preset_idx = ((c.preset_idx as i32 + step).rem_euclid(n)) as usize;
}
1 => c.pitch_delta += step as f32,
2 => c.formant_delta += step as f32,
3 => {
c.gate_threshold_db =
(c.gate_threshold_db + step as f32 * 2.0).clamp(-100.0, -20.0);
}
4 => {
let db = 20.0 * c.gain.max(1e-6).log10() + step as f32;
c.gain = 10f32.powf(db / 20.0).clamp(0.0316, 4.0); // -30 .. +12 dB
}
5 => c.mute = !c.mute,
6 => c.mode.toggle(),
7 => c.record = !c.record,
8 => c.pitch_correct = !c.pitch_correct,
_ => {}
});
}
fn on_key(&mut self, key: crossterm::event::KeyEvent) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let quit = key.code == KeyCode::Char('q')
|| (ctrl && (key.code == KeyCode::Char('c') || key.code == KeyCode::Char('q')));
// Any key dismisses the splash.
if Instant::now() < self.splash_until {
self.splash_until = Instant::now();
if quit {
self.set_quit();
}
return;
}
if quit {
self.set_quit();
return;
}
match self.view {
View::Help => {
if key.code == KeyCode::Esc
|| key.code == KeyCode::Char('h')
|| key.code == KeyCode::Char('H')
{
self.view = View::Main;
}
}
View::Main => self.on_key_main(key),
View::Settings => self.on_key_settings(key),
}
}
fn on_key_main(&mut self, key: crossterm::event::KeyEvent) {
match key.code {
KeyCode::Up => self.with_ctrl(|c| {
c.preset_idx = (c.preset_idx + presets().len() - 1) % presets().len()
}),
KeyCode::Down => {
self.with_ctrl(|c| c.preset_idx = (c.preset_idx + 1) % presets().len())
}
KeyCode::Left => self.adjust(1, -1),
KeyCode::Right => self.adjust(1, 1),
KeyCode::Char('s') | KeyCode::Char('S') | KeyCode::Tab => self.view = View::Settings,
KeyCode::Char('h') | KeyCode::Char('H') => self.view = View::Help,
KeyCode::Char('p') | KeyCode::Char('P') => {
self.with_ctrl(|c| c.preset_idx = (c.preset_idx + 1) % presets().len())
}
KeyCode::Char(d) if d.is_ascii_digit() => self.with_ctrl(|c| {
let n = d.to_digit(10).unwrap() as usize;
c.preset_idx = if n == 0 {
presets().len() - 1
} else {
(n - 1).min(presets().len() - 1)
};
}),
KeyCode::Char('[') => self.adjust(1, -1),
KeyCode::Char(']') => self.adjust(1, 1),
KeyCode::Char('{') => self.adjust(2, -1),
KeyCode::Char('}') => self.adjust(2, 1),
KeyCode::Char('g') | KeyCode::Char('G') => self.adjust(3, -1),
KeyCode::Char('b') | KeyCode::Char('B') => self.adjust(6, 1),
KeyCode::Char('m') | KeyCode::Char('M') => self.adjust(5, 1),
KeyCode::Char('r') | KeyCode::Char('R') => self.adjust(7, 1),
KeyCode::Char('+') | KeyCode::Char('=') => self.adjust(4, 1),
KeyCode::Char('-') => self.adjust(4, -1),
_ => {}
}
}
fn on_key_settings(&mut self, key: crossterm::event::KeyEvent) {
match key.code {
KeyCode::Up => self.settings_cursor = self.settings_cursor.saturating_sub(1),
KeyCode::Down => self.settings_cursor = (self.settings_cursor + 1).min(7),
KeyCode::Left => self.adjust(self.settings_cursor, -1),
KeyCode::Right => self.adjust(self.settings_cursor, 1),
KeyCode::Enter => self.adjust(self.settings_cursor, 1),
KeyCode::Esc | KeyCode::Char('s') | KeyCode::Char('S') | KeyCode::Tab => {
self.view = View::Main
}
KeyCode::Char('h') | KeyCode::Char('H') => self.view = View::Help,
KeyCode::Char('q') => {}
_ => {}
}
}
fn on_mouse(&mut self, mouse: MouseEvent) {
if Instant::now() < self.splash_until {
self.splash_until = Instant::now();
return;
}
if let MouseEventKind::Down(MouseButton::Left) = mouse.kind {
let area = self.terminal_size();
match self.view {
View::Main => {
let rects = main_layout(area);
let presets_rect = rects.0;
if mouse.row >= presets_rect.y
&& mouse.row < presets_rect.y + presets_rect.height
{
let idx = (mouse.row - presets_rect.y) as usize;
if idx < presets().len() {
self.with_ctrl(|c| c.preset_idx = idx);
}
}
}
View::Settings => {
let rects = settings_layout(area);
let list_rect = rects.0;
if mouse.row >= list_rect.y && mouse.row < list_rect.y + list_rect.height {
let idx = (mouse.row - list_rect.y) as usize;
if idx < 9 {
self.settings_cursor = idx;
}
}
}
View::Help => self.view = View::Main,
}
}
}
fn terminal_size(&self) -> Rect {
crossterm::terminal::size()
.map(|(w, h)| Rect::new(0, 0, w, h))
.unwrap_or(Rect::new(0, 0, 100, 30))
}
fn set_quit(&mut self) {
self.with_ctrl(|c| c.quit = true);
self.running = false;
}
fn with_ctrl(&self, f: impl FnOnce(&mut Control)) {
if let Ok(mut c) = self.control.lock() {
f(&mut c);
}
}
fn render(&mut self, f: &mut Frame) {
if Instant::now() < self.splash_until {
self.render_splash(f);
return;
}
match self.view {
View::Main => self.render_main(f),
View::Settings => self.render_settings(f),
View::Help => self.render_help(f),
}
}
fn render_splash(&self, f: &mut Frame) {
let area = f.area();
let mut lines: Vec<Line> = Vec::new();
for (i, l) in LOGO.iter().enumerate() {
let color = match i {
0 | 1 => Color::Cyan,
2 | 3 => Color::Magenta,
4 | 5 => Color::Blue,
_ => Color::Gray,
};
lines.push(Line::styled(
l.to_string(),
Style::default().fg(color).add_modifier(Modifier::BOLD),
));
}
lines.push(Line::from(""));
lines.push(Line::from(
" press any key, click, or use the arrow keys ...",
));
lines.push(Line::styled(
" audio is already running",
Style::default().fg(Color::DarkGray),
));
let para = Paragraph::new(lines).alignment(ratatui::layout::Alignment::Center);
// Center in the full area, but never taller than the terminal.
let rect = centered_rect(70, 55, area);
f.render_widget(para, rect);
}
fn render_help(&self, f: &mut Frame) {
let para = Paragraph::new(HELP_TEXT)
.style(Style::default().fg(Color::White))
.block(block(" vois · help "));
f.render_widget(para, f.area());
}
fn render_header(&self, f: &mut Frame, area: Rect) {
let (_, _name, _p, _f, _g, _gain, mute, record, tone, 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(
" VOIS ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::styled(
"real-time voice changer",
Style::default()
.fg(Color::Gray)
.add_modifier(Modifier::ITALIC),
),
Span::raw(" · "),
Span::styled(
mode_txt,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
rec_txt,
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(tone_txt, Style::default().fg(Color::Blue)),
Span::raw(" "),
Span::styled(tune_txt, Style::default().fg(Color::Magenta)),
Span::raw(" "),
Span::styled(
mute_txt,
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
]);
f.render_widget(Paragraph::new(header), area);
}
fn render_footer(&self, f: &mut Frame, area: Rect, hint: &str) {
let footer = Line::from(Span::styled(hint, Style::default().fg(Color::DarkGray)));
f.render_widget(Paragraph::new(footer), area);
}
fn render_main(&mut self, f: &mut Frame) {
let area = f.area();
let rects = main_layout(area);
let (head, body, foot) = (rects.0, rects.1, rects.2);
self.render_header(f, head);
let (preset_idx, _name, pitch, formant, gate, gain, mute, _record, _tone, tune, _mode) =
self.snapshot();
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(38),
Constraint::Percentage(28),
Constraint::Percentage(34),
])
.split(body);
// Presets (navigable, live preview).
let mut plines = Vec::new();
for (i, p) in presets().iter().enumerate() {
let selected = i == preset_idx;
let label = format!(
"{}{} {}",
if selected { "▶" } else { " " },
if selected { "" } else { " " },
p.name
);
let style = if selected {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Gray)
};
plines.push(Line::styled(label, style));
}
let para = Paragraph::new(plines).block(block(" presets · ↑↓ to switch "));
f.render_widget(para, cols[0]);
// Parameters + info.
let gate_txt = if gate <= -90.0 {
"off".to_string()
} else {
format!("{gate:.0} dB")
};
let gain_db = 20.0 * gain.max(1e-6).log10();
let param_lines = vec![
p_row("preset", &_name, Color::Yellow, "voice character"),
p_row(
"pitch",
&format!("{pitch:+.1} st"),
Color::Cyan,
"higher=girl, lower=deep",
),
p_row(
"formant",
&format!("{formant:.2}×"),
Color::Magenta,
"brightness/timbre",
),
p_row("gate", &gate_txt, Color::Blue, "noise below this dB is cut"),
p_row(
"gain",
&format!("{gain_db:+.1} dB"),
Color::Green,
"output loudness",
),
p_row(
"mute",
if mute { "ON" } else { "off" },
if mute { Color::Red } else { Color::DarkGray },
"silence output",
),
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]);
self.render_stats(f, cols[2]);
self.render_footer(
f,
foot,
" ↑↓ preset | ←→ pitch | S settings | H help | Q quit ",
);
}
fn render_settings(&mut self, f: &mut Frame) {
let area = f.area();
let rects = settings_layout(area);
let (head, list_rect, foot) = (rects.0, rects.1, rects.2);
self.render_header(f, head);
let labels = [
"Preset",
"Pitch",
"Formant",
"Gate (min)",
"Gain",
"Mute",
"Mode",
"Record",
"Pitch corr",
];
let mut lines = Vec::new();
for (i, label) in labels.iter().enumerate() {
let value = self.setting_value(i);
let selected = i == self.settings_cursor;
let style = if selected {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Gray)
};
let info_style = Style::default().fg(Color::DarkGray);
lines.push(Line::from(vec![
Span::styled(
format!(
"{}{} {:<12}",
if selected { "▶" } else { " " },
if selected { "" } else { " " },
label
),
style,
),
Span::styled(format!("{:<16}", value), style),
Span::styled(SETTING_INFO[i], info_style),
]));
}
lines.push(Line::from(""));
lines.push(Line::styled(
" use ↑ ↓ to select, ← → (or Enter) to change, Esc to go back",
Style::default().fg(Color::DarkGray),
));
let para = Paragraph::new(lines).block(block(" settings "));
f.render_widget(para, list_rect);
self.render_footer(
f,
foot,
" ↑↓ select | ←→ change | Esc back | H help | Q quit ",
);
}
fn render_stats(&self, f: &mut Frame, area: Rect) {
let s = self
.stats
.lock()
.map(|s| {
(
s.input_rms,
s.output_rms,
s.peak_in,
s.peak_out,
s.blocks,
s.samples,
s.cpu_pct,
s.dsp_latency_ms,
s.started,
)
})
.unwrap_or((0.0, 0.0, 0.0, 0.0, 0, 0, 0.0, 0.0, Instant::now()));
let (in_rms, out_rms, peak_in, peak_out, blocks, samples, cpu, latency, started) = s;
let in_db = db(in_rms);
let out_db = db(out_rms);
let peak_in_db = db(peak_in);
let peak_out_db = db(peak_out);
let uptime = started.elapsed().as_secs();
let up = format!(
"{:02}:{:02}:{:02}",
uptime / 3600,
(uptime / 60) % 60,
uptime % 60
);
let secs = started.elapsed().as_secs_f32().max(0.1);
let hz = samples as f32 / secs;
let lines = vec![
Line::from(""),
Line::from(vec![
Span::styled(" peak in ", Style::default().fg(Color::DarkGray)),
Span::styled(format!("{peak_in_db:+.0} dB"), meter_style(peak_in_db)),
Span::styled(" peak out ", Style::default().fg(Color::DarkGray)),
Span::styled(format!("{peak_out_db:+.0} dB"), meter_style(peak_out_db)),
]),
Line::from(""),
Line::from(vec![
Span::styled(" dsp load ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!("{cpu:.1} %"),
if cpu > 50.0 {
Style::default().fg(Color::Red)
} else {
Style::default().fg(Color::Green)
},
),
Span::raw(" "),
Span::styled("latency", Style::default().fg(Color::DarkGray)),
Span::raw(format!(" {latency:.0} ms")),
]),
Line::from(vec![
Span::styled(" rate ", Style::default().fg(Color::DarkGray)),
Span::raw(format!(
"{} Hz {} ch → {} ch ({} → {})",
self.info.sample_rate,
self.info.in_ch,
self.info.out_ch,
self.info.format_in,
self.info.format_out
)),
]),
Line::from(vec![
Span::styled(" uptime ", Style::default().fg(Color::DarkGray)),
Span::raw(up),
]),
Line::from(vec![
Span::styled(" blocks ", Style::default().fg(Color::DarkGray)),
Span::raw(format!("{blocks} ({hz:.0} blk/s)")),
]),
Line::from(""),
Line::from(Span::styled(
format!(" in: {}", self.info.input),
Style::default().fg(Color::DarkGray),
)),
Line::from(Span::styled(
format!(" out: {}", self.info.output),
Style::default().fg(Color::DarkGray),
)),
];
// Meters on top of the column.
let vert = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2),
Constraint::Length(1),
Constraint::Length(2),
Constraint::Length(1),
Constraint::Min(0),
])
.split(area);
let in_gauge = Gauge::default()
.ratio(meter_ratio(in_db))
.gauge_style(meter_style(in_db));
let out_gauge = Gauge::default()
.ratio(meter_ratio(out_db))
.gauge_style(meter_style(out_db));
f.render_widget(
Paragraph::new(Line::from(vec![
Span::raw(" in "),
Span::styled(format!("{in_db:+.0} dB"), meter_style(in_db)),
])),
vert[0],
);
f.render_widget(in_gauge, vert[1]);
f.render_widget(
Paragraph::new(Line::from(vec![
Span::raw(" out "),
Span::styled(format!("{out_db:+.0} dB"), meter_style(out_db)),
])),
vert[2],
);
f.render_widget(out_gauge, vert[3]);
f.render_widget(Paragraph::new(lines), vert[4]);
}
}
/// Layout rects for the main screen: (header, body, footer).
fn main_layout(area: Rect) -> (Rect, Rect, Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(1),
])
.split(area);
(chunks[0], chunks[1], chunks[2])
}
/// Layout rects for the settings screen: (header, list, footer).
fn settings_layout(area: Rect) -> (Rect, Rect, Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(1),
])
.split(area);
(chunks[0], chunks[1], chunks[2])
}
fn p_row(label: &str, value: &str, color: Color, info: &str) -> Line<'static> {
Line::from(vec![
Span::styled(
format!(" {label:<8}"),
Style::default().fg(Color::DarkGray),
),
Span::styled(
value.to_string(),
Style::default().fg(color).add_modifier(Modifier::BOLD),
),
Span::styled(format!(" ({info})"), Style::default().fg(Color::DarkGray)),
])
}
fn block(title: &str) -> Block<'static> {
Block::default()
.borders(Borders::ALL)
.title(Span::styled(
title.to_string(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(Color::DarkGray))
}
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
let height = ((area.height as u32 * percent_y as u32 / 100) as u16).max(12);
let top = area.y + (area.height.saturating_sub(height) / 2);
let rect = Rect::new(area.x, top, area.width, height);
let width = ((rect.width as u32 * percent_x as u32 / 100) as u16).max(30);
let x = rect.x + (rect.width.saturating_sub(width) / 2);
Rect::new(x, rect.y, width, rect.height)
}
pub fn run(control: Arc<Mutex<Control>>, stats: Arc<Mutex<Stats>>, info: SessionInfo) {
let run_seconds = control.lock().ok().and_then(|c| c.run_seconds).unwrap_or(0);
if !std::io::stdin().is_terminal() {
let started = Instant::now();
loop {
let done = control
.lock()
.map(|c| {
c.quit || run_seconds > 0 && started.elapsed().as_secs() >= run_seconds as u64
})
.unwrap_or(true);
if done {
if let Ok(mut c) = control.lock() {
c.quit = true;
}
break;
}
std::thread::sleep(Duration::from_millis(50));
}
return;
}
if enable_raw_mode().is_err() {
// No real terminal: keep the audio running headless.
let started = Instant::now();
loop {
let done = control
.lock()
.map(|c| {
c.quit || run_seconds > 0 && started.elapsed().as_secs() >= run_seconds as u64
})
.unwrap_or(true);
if done {
if let Ok(mut c) = control.lock() {
c.quit = true;
}
break;
}
std::thread::sleep(Duration::from_millis(50));
}
return;
}
let _ = crossterm::execute!(stdout(), crossterm::event::EnableMouseCapture);
let mut terminal = match Terminal::new(CrosstermBackend::new(stdout())) {
Ok(t) => t,
Err(e) => {
disable_raw_mode().ok();
eprintln!("tui init failed: {e}");
return;
}
};
let _ = terminal.clear();
let mut app = App {
control,
stats,
info,
view: View::Main,
settings_cursor: 0,
splash_until: Instant::now() + Duration::from_millis(1500),
started: Instant::now(),
running: true,
};
while app.running {
let _ = terminal.draw(|f| app.render(f));
let _ = stdout().flush();
if event::poll(Duration::from_millis(50)).unwrap_or(false) {
if let Ok(ev) = event::read() {
match ev {
Event::Key(key) => app.on_key(key),
Event::Mouse(m) => app.on_mouse(m),
_ => {}
}
}
}
if run_seconds > 0 && app.started.elapsed().as_secs() >= run_seconds as u64 {
app.set_quit();
}
}
let _ = terminal.show_cursor();
let _ = crossterm::execute!(stdout(), crossterm::event::DisableMouseCapture);
disable_raw_mode().ok();
println!("shutting down...");
}

134
src/ui/widgets.rs Normal file
View file

@ -0,0 +1,134 @@
//! Shared constants and stateless render helpers for the TUI screens.
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders};
pub(super) const LOGO: [&str; 7] = [
" ██╗ ██╗ ██████╗ ██╗███████╗ ",
" ██║ ██║██╔═══██╗██║██╔════╝ ",
" ██║ ██║██║ ██║██║███████╗ ",
" ╚██╗ ██╔╝██║ ██║██║╚════██║ ",
" ╚████╔╝ ╚██████╔╝██║███████║ ",
" ╚═══╝ ╚═════╝ ╚═╝╚══════╝ ",
" real-time voice changer ",
];
pub(super) const SETTING_INFO: [&str; 9] = [
"voice character, previewed live",
"pitch: higher = girl/kid, lower = deep",
"formant: brightness / timbre of the voice",
"noise gate: audio below this dB is cut",
"output loudness, in dB",
"silence the output",
"LIVE = processed, PASSTHROUGH = raw input (A/B)",
"write processed audio to a WAV file",
"auto-tune: snap pitch to the nearest semitone",
];
pub(super) const HELP_TEXT: &str = "\
KEY / MOUSE ACTION
───────────────────────────────────────────────
↑ ↓ (or mouse) select preset / settings row
← → change value
Enter toggle / apply
S / Tab toggle SETTINGS screen
Esc back to main screen
H toggle this help screen
Q / Ctrl+C quit
0 - 9 jump to preset
M mute R record
B A/B passthrough mode
";
const METER_FLOOR_DB: f32 = -60.0;
/// RMS / peak value converted to decibels, floored at the meter range.
pub(super) fn db(rms: f32) -> f32 {
if rms <= 0.0 {
METER_FLOOR_DB
} else {
(20.0 * rms.log10()).clamp(METER_FLOOR_DB, 6.0)
}
}
/// Meter ratio in 0..=1 for the level gauges.
pub(super) fn meter_ratio(db: f32) -> f64 {
((db - METER_FLOOR_DB) / -METER_FLOOR_DB).clamp(0.0, 1.0) as f64
}
/// Meter color: red near the top, yellow mid, green low.
pub(super) fn meter_style(level_db: f32) -> Style {
if level_db >= -6.0 {
Style::default().fg(Color::Red)
} else if level_db >= -20.0 {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::Green)
}
}
/// Layout rects for the main screen: (header, body, footer).
pub(super) fn main_layout(area: Rect) -> (Rect, Rect, Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(1),
])
.split(area);
(chunks[0], chunks[1], chunks[2])
}
/// Layout rects for the settings screen: (header, list, footer).
pub(super) fn settings_layout(area: Rect) -> (Rect, Rect, Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(1),
])
.split(area);
(chunks[0], chunks[1], chunks[2])
}
/// A settings table row: label / value / hint.
pub(super) fn p_row(label: &str, value: &str, color: Color, info: &str) -> Line<'static> {
Line::from(vec![
Span::styled(
format!(" {label:<8}"),
Style::default().fg(Color::DarkGray),
),
Span::styled(
value.to_string(),
Style::default().fg(color).add_modifier(Modifier::BOLD),
),
Span::styled(format!(" ({info})"), Style::default().fg(Color::DarkGray)),
])
}
/// Bordered panel with a cyan title.
pub(super) fn block(title: &str) -> Block<'static> {
Block::default()
.borders(Borders::ALL)
.title(Span::styled(
title.to_string(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(Color::DarkGray))
}
/// A rect of `percent_x`/`percent_y` centered inside `area`.
pub(super) fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
let height = ((area.height as u32 * percent_y as u32 / 100) as u16).max(12);
let top = area.y + (area.height.saturating_sub(height) / 2);
let rect = Rect::new(area.x, top, area.width, height);
let width = ((rect.width as u32 * percent_x as u32 / 100) as u16).max(30);
let x = rect.x + (rect.width.saturating_sub(width) / 2);
Rect::new(x, rect.y, width, rect.height)
}