From a6032b2d18cf3fc336e0ddca0cf5b65e4d2d52b0 Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Sun, 2 Aug 2026 23:33:13 +0200 Subject: [PATCH] Restructure project: 1 file = 1 responsibility, max 4 files/folder, 250 lines/file --- examples/latency_probe.rs | 6 +- src/audio/capture.rs | 82 ---- src/audio/devices.rs | 18 - src/audio/mod.rs | 34 +- src/audio/playback.rs | 88 ---- src/audio/stream.rs | 165 +++++++ src/audio/virtual_mic.rs | 75 +-- src/config.rs | 4 +- src/dsp/chain.rs | 473 ------------------ src/dsp/chain/mod.rs | 230 +++++++++ src/dsp/chain/tests.rs | 101 ++++ src/dsp/chain/worker.rs | 86 ++++ src/dsp/control.rs | 95 ++++ src/dsp/effects.rs | 750 ---------------------------- src/dsp/fx/drive.rs | 101 ++++ src/dsp/fx/filter/biquad.rs | 76 +++ src/dsp/fx/filter/dynamics.rs | 108 +++++ src/dsp/fx/filter/mod.rs | 7 + src/dsp/fx/mod.rs | 19 + src/dsp/fx/modgen.rs | 100 ++++ src/dsp/fx/time/delay.rs | 128 +++++ src/dsp/fx/time/mod.rs | 7 + src/dsp/fx/time/reverb.rs | 127 +++++ src/dsp/fx/vocoder/carrier.rs | 8 + src/dsp/fx/vocoder/channel.rs | 112 +++++ src/dsp/fx/vocoder/mod.rs | 7 + src/dsp/gate.rs | 64 --- src/dsp/mod.rs | 9 +- src/dsp/pitch.rs | 454 ----------------- src/dsp/preset/build.rs | 80 +++ src/dsp/preset/mod.rs | 131 +++++ src/dsp/preset/table_a.rs | 161 ++++++ src/dsp/preset/table_b.rs | 195 ++++++++ src/dsp/presets.rs | 543 --------------------- src/dsp/shift/analysis.rs | 62 +++ src/dsp/shift/frame.rs | 91 ++++ src/dsp/shift/mod.rs | 149 ++++++ src/dsp/shift/shifter.rs | 104 ++++ src/dsp/stats.rs | 35 -- src/main.rs | 4 +- src/ui/app.rs | 231 +++++++++ src/ui/mod.rs | 11 +- src/ui/render.rs | 215 ++++++++ src/ui/screens/main.rs | 150 ++++++ src/ui/screens/mod.rs | 9 + src/ui/screens/settings.rs | 52 ++ src/ui/screens/stats.rs | 121 +++++ src/ui/tui.rs | 888 ---------------------------------- src/ui/widgets.rs | 134 +++++ 49 files changed, 3408 insertions(+), 3492 deletions(-) delete mode 100644 src/audio/capture.rs delete mode 100644 src/audio/devices.rs delete mode 100644 src/audio/playback.rs create mode 100644 src/audio/stream.rs delete mode 100644 src/dsp/chain.rs create mode 100644 src/dsp/chain/mod.rs create mode 100644 src/dsp/chain/tests.rs create mode 100644 src/dsp/chain/worker.rs create mode 100644 src/dsp/control.rs delete mode 100644 src/dsp/effects.rs create mode 100644 src/dsp/fx/drive.rs create mode 100644 src/dsp/fx/filter/biquad.rs create mode 100644 src/dsp/fx/filter/dynamics.rs create mode 100644 src/dsp/fx/filter/mod.rs create mode 100644 src/dsp/fx/mod.rs create mode 100644 src/dsp/fx/modgen.rs create mode 100644 src/dsp/fx/time/delay.rs create mode 100644 src/dsp/fx/time/mod.rs create mode 100644 src/dsp/fx/time/reverb.rs create mode 100644 src/dsp/fx/vocoder/carrier.rs create mode 100644 src/dsp/fx/vocoder/channel.rs create mode 100644 src/dsp/fx/vocoder/mod.rs delete mode 100644 src/dsp/gate.rs delete mode 100644 src/dsp/pitch.rs create mode 100644 src/dsp/preset/build.rs create mode 100644 src/dsp/preset/mod.rs create mode 100644 src/dsp/preset/table_a.rs create mode 100644 src/dsp/preset/table_b.rs delete mode 100644 src/dsp/presets.rs create mode 100644 src/dsp/shift/analysis.rs create mode 100644 src/dsp/shift/frame.rs create mode 100644 src/dsp/shift/mod.rs create mode 100644 src/dsp/shift/shifter.rs delete mode 100644 src/dsp/stats.rs create mode 100644 src/ui/app.rs create mode 100644 src/ui/render.rs create mode 100644 src/ui/screens/main.rs create mode 100644 src/ui/screens/mod.rs create mode 100644 src/ui/screens/settings.rs create mode 100644 src/ui/screens/stats.rs delete mode 100644 src/ui/tui.rs create mode 100644 src/ui/widgets.rs diff --git a/examples/latency_probe.rs b/examples/latency_probe.rs index 147af5b..f22fbe6 100644 --- a/examples/latency_probe.rs +++ b/examples/latency_probe.rs @@ -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; diff --git a/src/audio/capture.rs b/src/audio/capture.rs deleted file mode 100644 index 4fd818e..0000000 --- a/src/audio/capture.rs +++ /dev/null @@ -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 { - 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

(device: &Device, supported: &SupportedStreamConfig, tx: P) -> Result -where - P: Producer + Send + 'static, -{ - let config: StreamConfig = supported.config(); - match supported.sample_format() { - SampleFormat::F32 => build::(device, &config, tx), - SampleFormat::F64 => build::(device, &config, tx), - SampleFormat::I8 => build::(device, &config, tx), - SampleFormat::I16 => build::(device, &config, tx), - SampleFormat::I32 => build::(device, &config, tx), - SampleFormat::I64 => build::(device, &config, tx), - SampleFormat::U8 => build::(device, &config, tx), - SampleFormat::U16 => build::(device, &config, tx), - SampleFormat::U32 => build::(device, &config, tx), - SampleFormat::U64 => build::(device, &config, tx), - format => anyhow::bail!("unsupported input sample format: {format:?}"), - } -} - -fn build(device: &Device, config: &StreamConfig, mut tx: P) -> Result -where - T: SizedSample, - f32: FromSample, - P: Producer + 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}")) -} diff --git a/src/audio/devices.rs b/src/audio/devices.rs deleted file mode 100644 index 7481740..0000000 --- a/src/audio/devices.rs +++ /dev/null @@ -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(|_| "".into())); - } - - println!("Output devices:"); - for device in host.output_devices()? { - println!(" {}", device.name().unwrap_or_else(|_| "".into())); - } - - Ok(()) -} diff --git a/src/audio/mod.rs b/src/audio/mod.rs index de21daa..da4fa97 100644 --- a/src/audio/mod.rs +++ b/src/audio/mod.rs @@ -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(|_| "".into())); + } + println!("Output devices:"); + for device in host.output_devices()? { + println!(" {}", device.name().unwrap_or_else(|_| "".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::::new(ring_cap).split(); let (out_prod, out_cons) = HeapRb::::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); diff --git a/src/audio/playback.rs b/src/audio/playback.rs deleted file mode 100644 index b14abb8..0000000 --- a/src/audio/playback.rs +++ /dev/null @@ -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 { - 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(device: &Device, supported: &SupportedStreamConfig, rx: C) -> Result -where - C: Consumer + Send + 'static, -{ - let config: StreamConfig = supported.config(); - match supported.sample_format() { - SampleFormat::F32 => build::(device, &config, rx), - SampleFormat::F64 => build::(device, &config, rx), - SampleFormat::I8 => build::(device, &config, rx), - SampleFormat::I16 => build::(device, &config, rx), - SampleFormat::I32 => build::(device, &config, rx), - SampleFormat::I64 => build::(device, &config, rx), - SampleFormat::U8 => build::(device, &config, rx), - SampleFormat::U16 => build::(device, &config, rx), - SampleFormat::U32 => build::(device, &config, rx), - SampleFormat::U64 => build::(device, &config, rx), - format => anyhow::bail!("unsupported output sample format: {format:?}"), - } -} - -fn build(device: &Device, config: &StreamConfig, mut rx: C) -> Result -where - T: SizedSample + FromSample, - C: Consumer + 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}")) -} diff --git a/src/audio/stream.rs b/src/audio/stream.rs new file mode 100644 index 0000000..854ef05 --- /dev/null +++ b/src/audio/stream.rs @@ -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, + preferred: &[&str], + default: Option, +) -> Option { + 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 { + 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 { + 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

(device: &Device, supported: &SupportedStreamConfig, tx: P) -> Result +where + P: Producer + Send + 'static, +{ + let config: StreamConfig = supported.config(); + match supported.sample_format() { + SampleFormat::F32 => build_input::(device, &config, tx), + SampleFormat::F64 => build_input::(device, &config, tx), + SampleFormat::I8 => build_input::(device, &config, tx), + SampleFormat::I16 => build_input::(device, &config, tx), + SampleFormat::I32 => build_input::(device, &config, tx), + SampleFormat::I64 => build_input::(device, &config, tx), + SampleFormat::U8 => build_input::(device, &config, tx), + SampleFormat::U16 => build_input::(device, &config, tx), + SampleFormat::U32 => build_input::(device, &config, tx), + SampleFormat::U64 => build_input::(device, &config, tx), + format => anyhow::bail!("unsupported input sample format: {format:?}"), + } +} + +fn build_input(device: &Device, config: &StreamConfig, mut tx: P) -> Result +where + T: SizedSample, + f32: FromSample, + P: Producer + 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(device: &Device, supported: &SupportedStreamConfig, rx: C) -> Result +where + C: Consumer + Send + 'static, +{ + let config: StreamConfig = supported.config(); + match supported.sample_format() { + SampleFormat::F32 => build_output::(device, &config, rx), + SampleFormat::F64 => build_output::(device, &config, rx), + SampleFormat::I8 => build_output::(device, &config, rx), + SampleFormat::I16 => build_output::(device, &config, rx), + SampleFormat::I32 => build_output::(device, &config, rx), + SampleFormat::I64 => build_output::(device, &config, rx), + SampleFormat::U8 => build_output::(device, &config, rx), + SampleFormat::U16 => build_output::(device, &config, rx), + SampleFormat::U32 => build_output::(device, &config, rx), + SampleFormat::U64 => build_output::(device, &config, rx), + format => anyhow::bail!("unsupported output sample format: {format:?}"), + } +} + +fn build_output(device: &Device, config: &StreamConfig, mut rx: C) -> Result +where + T: SizedSample + FromSample, + C: Consumer + 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}")) +} diff --git a/src/audio/virtual_mic.rs b/src/audio/virtual_mic.rs index fd27453..74ffbdf 100644 --- a/src/audio/virtual_mic.rs +++ b/src/audio/virtual_mic.rs @@ -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> { 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 { 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, 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 { let out = Command::new("pactl") @@ -246,7 +188,6 @@ fn sink_id_named(name: &str) -> Option { } None } - #[cfg(any(target_os = "linux", target_os = "freebsd"))] fn client_names() -> Result> { let out = Command::new("pactl") @@ -262,7 +203,6 @@ fn client_names() -> Result> { } Ok(map) } - #[cfg(any(target_os = "linux", target_os = "freebsd"))] fn sink_inputs() -> Result> { let out = Command::new("pactl") @@ -282,31 +222,21 @@ fn sink_inputs() -> Result> { } 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> { Ok(None) } - pub fn get_default_sink() -> Option { None } - pub fn set_default_sink(_name: &str) {} - pub fn wait_default_sink(_name: &str) {} - pub fn keep_others_off(stop: Arc, _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(|| {}) } diff --git a/src/config.rs b/src/config.rs index 2c838bc..873fb6c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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; diff --git a/src/dsp/chain.rs b/src/dsp/chain.rs deleted file mode 100644 index 3829693..0000000 --- a/src/dsp/chain.rs +++ /dev/null @@ -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, - pub run_seconds: Option, - /// Test tone frequency (Hz) mixed into the input; 0 = off. - pub tone_hz: f32, - /// Snap the voice to the nearest semitone (Auto-Tune style). - pub pitch_correct: bool, - pub mode: Mode, - pub quit: bool, -} - -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>, - stats: Arc>, - shifter: Option, - shift_pending: Vec, - /// Smoothed Auto-Tune correction in semitones. - correction: f32, - gate: NoiseGate, - effects: Vec>, - last_preset: usize, - recorder_tx: Option>>, - recorder_handle: Option>, -} - -impl Processor { - pub fn new(fs: u32, control: Arc>, stats: Arc>) -> 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( - mut input: I, - mut output: O, - in_ch: usize, - out_ch: usize, - fs: u32, - control: Arc>, - stats: Arc>, -) where - I: Consumer + Send + 'static, - O: Producer + 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::::new(); - let fft = planner.plan_fft_forward(n); - let mut buf: Vec> = x[..n].iter().map(|&s| Complex::new(s, 0.0)).collect(); - fft.process(&mut buf); - let bins = n / 2; - let mags: Vec = (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> { - 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 = (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 { - 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 - } -} diff --git a/src/dsp/chain/mod.rs b/src/dsp/chain/mod.rs new file mode 100644 index 0000000..7d06e4b --- /dev/null +++ b/src/dsp/chain/mod.rs @@ -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>, + stats: Arc>, + shifter: Option, + shift_pending: Vec, + /// Smoothed Auto-Tune correction in semitones. + correction: f32, + gate: NoiseGate, + effects: Vec>, + last_preset: usize, + recorder_tx: Option>>, + recorder_handle: Option>, +} + +impl Processor { + pub fn new(fs: u32, control: Arc>, stats: Arc>) -> 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) { + 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; + } +} diff --git a/src/dsp/chain/tests.rs b/src/dsp/chain/tests.rs new file mode 100644 index 0000000..890aea1 --- /dev/null +++ b/src/dsp/chain/tests.rs @@ -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::::new(); + let fft = planner.plan_fft_forward(n); + let mut buf: Vec> = x[..n].iter().map(|&s| Complex::new(s, 0.0)).collect(); + fft.process(&mut buf); + let bins = n / 2; + let mags: Vec = (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 { + 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 = (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}"); +} diff --git a/src/dsp/chain/worker.rs b/src/dsp/chain/worker.rs new file mode 100644 index 0000000..8928a36 --- /dev/null +++ b/src/dsp/chain/worker.rs @@ -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( + mut input: I, + mut output: O, + in_ch: usize, + out_ch: usize, + fs: u32, + control: Arc>, + stats: Arc>, +) where + I: Consumer + Send + 'static, + O: Producer + 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(); +} diff --git a/src/dsp/control.rs b/src/dsp/control.rs new file mode 100644 index 0000000..09dcfbf --- /dev/null +++ b/src/dsp/control.rs @@ -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, + pub run_seconds: Option, + /// Test tone frequency (Hz) mixed into the input; 0 = off. + pub tone_hz: f32, + /// Snap the voice to the nearest semitone (Auto-Tune style). + pub pitch_correct: bool, + pub mode: Mode, + pub quit: bool, +} + +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(), + } + } +} diff --git a/src/dsp/effects.rs b/src/dsp/effects.rs deleted file mode 100644 index 1e4788b..0000000 --- a/src/dsp/effects.rs +++ /dev/null @@ -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, - 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, - 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, - allpasses: Vec, - 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_lens - .iter() - .map(|&l| Comb::new((l as f32 * scale) as usize)) - .collect(); - let allpasses: Vec = 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, - 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, - 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, - 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; - } - } -} diff --git a/src/dsp/fx/drive.rs b/src/dsp/fx/drive.rs new file mode 100644 index 0000000..b4102ba --- /dev/null +++ b/src/dsp/fx/drive.rs @@ -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); + } + } +} diff --git a/src/dsp/fx/filter/biquad.rs b/src/dsp/fx/filter/biquad.rs new file mode 100644 index 0000000..4bfdf3d --- /dev/null +++ b/src/dsp/fx/filter/biquad.rs @@ -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; + } + } +} diff --git a/src/dsp/fx/filter/dynamics.rs b/src/dsp/fx/filter/dynamics.rs new file mode 100644 index 0000000..4365e7d --- /dev/null +++ b/src/dsp/fx/filter/dynamics.rs @@ -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; + } + } +} diff --git a/src/dsp/fx/filter/mod.rs b/src/dsp/fx/filter/mod.rs new file mode 100644 index 0000000..5380db9 --- /dev/null +++ b/src/dsp/fx/filter/mod.rs @@ -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}; diff --git a/src/dsp/fx/mod.rs b/src/dsp/fx/mod.rs new file mode 100644 index 0000000..dd9134b --- /dev/null +++ b/src/dsp/fx/mod.rs @@ -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]); +} diff --git a/src/dsp/fx/modgen.rs b/src/dsp/fx/modgen.rs new file mode 100644 index 0000000..6c30f5e --- /dev/null +++ b/src/dsp/fx/modgen.rs @@ -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; + } + } + } +} diff --git a/src/dsp/fx/time/delay.rs b/src/dsp/fx/time/delay.rs new file mode 100644 index 0000000..cd95cd5 --- /dev/null +++ b/src/dsp/fx/time/delay.rs @@ -0,0 +1,128 @@ +//! Modulated delay lines: chorus and flanger. + +use super::super::Effect; +use std::f32::consts::PI; + +struct DelayLine { + buf: Vec, + 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; + } + } + } +} diff --git a/src/dsp/fx/time/mod.rs b/src/dsp/fx/time/mod.rs new file mode 100644 index 0000000..d8011e2 --- /dev/null +++ b/src/dsp/fx/time/mod.rs @@ -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; diff --git a/src/dsp/fx/time/reverb.rs b/src/dsp/fx/time/reverb.rs new file mode 100644 index 0000000..c319f2a --- /dev/null +++ b/src/dsp/fx/time/reverb.rs @@ -0,0 +1,127 @@ +//! Freeverb-style reverb. + +use super::super::Effect; + +struct Comb { + buf: Vec, + 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, + 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, + allpasses: Vec, + 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_lens + .iter() + .map(|&l| Comb::new((l as f32 * scale) as usize)) + .collect(); + let allpasses: Vec = 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; + } + } +} diff --git a/src/dsp/fx/vocoder/carrier.rs b/src/dsp/fx/vocoder/carrier.rs new file mode 100644 index 0000000..269525a --- /dev/null +++ b/src/dsp/fx/vocoder/carrier.rs @@ -0,0 +1,8 @@ +//! Vocoder carrier kinds and generators. + +#[derive(Clone, Copy, PartialEq, Debug, serde::Serialize, serde::Deserialize)] +pub enum CarrierKind { + Noise, + Saw, + Input, +} diff --git a/src/dsp/fx/vocoder/channel.rs b/src/dsp/fx/vocoder/channel.rs new file mode 100644 index 0000000..9c374f5 --- /dev/null +++ b/src/dsp/fx/vocoder/channel.rs @@ -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, + 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; + } + } +} diff --git a/src/dsp/fx/vocoder/mod.rs b/src/dsp/fx/vocoder/mod.rs new file mode 100644 index 0000000..9ff655c --- /dev/null +++ b/src/dsp/fx/vocoder/mod.rs @@ -0,0 +1,7 @@ +//! Channel vocoder effect (classic robot voice). + +pub mod carrier; +pub mod channel; + +pub use carrier::CarrierKind; +pub use channel::ChannelVocoder; diff --git a/src/dsp/gate.rs b/src/dsp/gate.rs deleted file mode 100644 index 50b3b87..0000000 --- a/src/dsp/gate.rs +++ /dev/null @@ -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; - } - } -} diff --git a/src/dsp/mod.rs b/src/dsp/mod.rs index a1346e8..07de560 100644 --- a/src/dsp/mod.rs +++ b/src/dsp/mod.rs @@ -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; diff --git a/src/dsp/pitch.rs b/src/dsp/pitch.rs deleted file mode 100644 index 2f1798d..0000000 --- a/src/dsp/pitch.rs +++ /dev/null @@ -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, - norm: f32, - - fft: Arc>, - ifft: Arc>, - env_ifft: Arc>, - env_fft: Arc>, - - in_buf: Vec, - out_buf: Vec, - - spec: Vec>, - mag: Vec, - phase: Vec, - logmag: Vec, - env: Vec, - cep: Vec>, - prev_phase: Vec, - out_phase: Vec, - - /// 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::::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) { - 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 { - (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 { - (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::::new(); - let fft = planner.plan_fft_forward(n); - let mut buf: Vec> = x[..n].iter().map(|&s| Complex::new(s, 0.0)).collect(); - fft.process(&mut buf); - let bins = n / 2; - let mags: Vec = (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::::new(); - let fft = planner.plan_fft_forward(n); - let mut buf: Vec> = 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 = 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}" - ); - } -} diff --git a/src/dsp/preset/build.rs b/src/dsp/preset/build.rs new file mode 100644 index 0000000..05e906f --- /dev/null +++ b/src/dsp/preset/build.rs @@ -0,0 +1,80 @@ +//! Effect construction: EffectSpec -> Box. + +use super::EffectSpec; +use crate::dsp::fx::{self, Effect}; + +pub fn build_effects(specs: &[EffectSpec], sample_rate: u32) -> Vec> { + specs.iter().map(|s| build_effect(s, sample_rate)).collect() +} + +fn build_effect(spec: &EffectSpec, sample_rate: u32) -> Box { + 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, + )), + } +} diff --git a/src/dsp/preset/mod.rs b/src/dsp/preset/mod.rs new file mode 100644 index 0000000..c232f93 --- /dev/null +++ b/src/dsp/preset/mod.rs @@ -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, + /// 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, + pub formant: Option, + pub effects: Option>, + pub gate_db: Option, +} + +static PRESETS: OnceLock> = OnceLock::new(); + +fn builtin_presets() -> Vec { + 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) { + let _ = PRESETS.get_or_init(|| { + let mut all = builtin_presets(); + all.extend(customs); + all + }); +} + +/// The full preset list (built-ins + customs). +pub fn presets() -> &'static [Preset] { + init_custom(Vec::new()); + PRESETS.get_or_init(builtin_presets) +} + +impl Preset { + pub fn find(name: &str) -> Option { + presets().iter().position(|p| { + p.name.eq_ignore_ascii_case(name) + || p.name + .split('/') + .next() + .map(|s| s.trim().eq_ignore_ascii_case(name)) + .unwrap_or(false) + }) + } +} diff --git a/src/dsp/preset/table_a.rs b/src/dsp/preset/table_a.rs new file mode 100644 index 0000000..f0ae5f6 --- /dev/null +++ b/src/dsp/preset/table_a.rs @@ -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 { + 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, + }, + ] +} diff --git a/src/dsp/preset/table_b.rs b/src/dsp/preset/table_b.rs new file mode 100644 index 0000000..ec4496c --- /dev/null +++ b/src/dsp/preset/table_b.rs @@ -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 { + 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, + }, + ] +} diff --git a/src/dsp/presets.rs b/src/dsp/presets.rs deleted file mode 100644 index 2818008..0000000 --- a/src/dsp/presets.rs +++ /dev/null @@ -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, - /// 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, - pub formant: Option, - pub effects: Option>, - pub gate_db: Option, -} - -static PRESETS: OnceLock> = OnceLock::new(); - -fn builtin_presets() -> Vec { - vec![ - Preset { - name: "Clean".into(), - pitch_semitones: 0.0, - formant_ratio: 1.0, - effects: vec![], - 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) { - let _ = PRESETS.get_or_init(|| { - let mut all = builtin_presets(); - all.extend(customs); - all - }); -} - -/// The full preset list (built-ins + customs). -pub fn presets() -> &'static [Preset] { - init_custom(Vec::new()); - PRESETS.get_or_init(builtin_presets) -} - -impl Preset { - pub fn find(name: &str) -> Option { - presets().iter().position(|p| { - 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> { - specs.iter().map(|s| build_effect(s, sample_rate)).collect() -} - -fn build_effect(spec: &EffectSpec, sample_rate: u32) -> Box { - 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, - )), - } -} diff --git a/src/dsp/shift/analysis.rs b/src/dsp/shift/analysis.rs new file mode 100644 index 0000000..4b91f54 --- /dev/null +++ b/src/dsp/shift/analysis.rs @@ -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); + } + } +} diff --git a/src/dsp/shift/frame.rs b/src/dsp/shift/frame.rs new file mode 100644 index 0000000..bb4f922 --- /dev/null +++ b/src/dsp/shift/frame.rs @@ -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; + } + } +} diff --git a/src/dsp/shift/mod.rs b/src/dsp/shift/mod.rs new file mode 100644 index 0000000..a9acd7e --- /dev/null +++ b/src/dsp/shift/mod.rs @@ -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 { + (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 { + (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::::new(); + let fft = planner.plan_fft_forward(n); + let mut buf: Vec> = x[..n].iter().map(|&s| Complex::new(s, 0.0)).collect(); + fft.process(&mut buf); + let bins = n / 2; + let mags: Vec = (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}"); + } +} diff --git a/src/dsp/shift/shifter.rs b/src/dsp/shift/shifter.rs new file mode 100644 index 0000000..0ac20a3 --- /dev/null +++ b/src/dsp/shift/shifter.rs @@ -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, + pub(crate) norm: f32, + pub(crate) fft: Arc>, + pub(crate) ifft: Arc>, + pub(crate) env_ifft: Arc>, + pub(crate) env_fft: Arc>, + pub(crate) in_buf: Vec, + pub(crate) out_buf: Vec, + pub(crate) spec: Vec>, + pub(crate) mag: Vec, + pub(crate) phase: Vec, + pub(crate) logmag: Vec, + pub(crate) env: Vec, + pub(crate) cep: Vec>, + pub(crate) prev_phase: Vec, + pub(crate) out_phase: Vec, + 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::::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) { + 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); + } + } + } +} diff --git a/src/dsp/stats.rs b/src/dsp/stats.rs deleted file mode 100644 index 6eb7fc8..0000000 --- a/src/dsp/stats.rs +++ /dev/null @@ -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(), - } - } -} diff --git a/src/main.rs b/src/main.rs index 85ee171..b457cb1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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(()); } diff --git a/src/ui/app.rs b/src/ui/app.rs new file mode 100644 index 0000000..4d4311b --- /dev/null +++ b/src/ui/app.rs @@ -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>, + pub(super) stats: Arc>, + 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); + } + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index cbc86dd..40d1707 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -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 { diff --git a/src/ui/render.rs b/src/ui/render.rs new file mode 100644 index 0000000..d1fbd7e --- /dev/null +++ b/src/ui/render.rs @@ -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 = (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>, 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>, stats: Arc>, 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..."); +} diff --git a/src/ui/screens/main.rs b/src/ui/screens/main.rs new file mode 100644 index 0000000..613a102 --- /dev/null +++ b/src/ui/screens/main.rs @@ -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 = 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 ")) +} diff --git a/src/ui/screens/mod.rs b/src/ui/screens/mod.rs new file mode 100644 index 0000000..df31d2a --- /dev/null +++ b/src/ui/screens/mod.rs @@ -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}; diff --git a/src/ui/screens/settings.rs b/src/ui/screens/settings.rs new file mode 100644 index 0000000..72c74bd --- /dev/null +++ b/src/ui/screens/settings.rs @@ -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 ")) +} diff --git a/src/ui/screens/stats.rs b/src/ui/screens/stats.rs new file mode 100644 index 0000000..34fec79 --- /dev/null +++ b/src/ui/screens/stats.rs @@ -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]); +} diff --git a/src/ui/tui.rs b/src/ui/tui.rs deleted file mode 100644 index 26b7b7d..0000000 --- a/src/ui/tui.rs +++ /dev/null @@ -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>, - stats: Arc>, - 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 = 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>, stats: Arc>, 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..."); -} diff --git a/src/ui/widgets.rs b/src/ui/widgets.rs new file mode 100644 index 0000000..e421e32 --- /dev/null +++ b/src/ui/widgets.rs @@ -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) +}