Initial release: real-time voice changer in pure Rust

Phase-vocoder pitch/formant shifting, 12 presets, effect chain,
noise gate, WAV recording, full-screen TUI (arrow/mouse control)
and a built-in PipeWire/PulseAudio virtual mic (vois.rs).
GPL-3.0-or-later.
This commit is contained in:
loki5512344 2026-08-02 17:54:50 +02:00
commit b0d685a110
25 changed files with 6243 additions and 0 deletions

69
src/audio/capture.rs Normal file
View file

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

18
src/audio/devices.rs Normal file
View file

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

191
src/audio/mod.rs Normal file
View file

@ -0,0 +1,191 @@
pub mod capture;
pub mod devices;
pub mod playback;
pub mod record;
pub mod virtual_mic;
use anyhow::{Context, Result};
use cpal::traits::{DeviceTrait, StreamTrait};
use cpal::{SampleRate, StreamConfig, SupportedStreamConfig, SupportedStreamConfigRange};
use ringbuf::traits::Split;
use ringbuf::HeapRb;
use std::sync::{Arc, Mutex};
use std::thread;
use crate::config::AppConfig;
use crate::dsp;
use crate::ui;
/// Higher = better quality for our purposes.
fn format_rank(format: cpal::SampleFormat) -> u8 {
match format {
cpal::SampleFormat::F32 => 10,
cpal::SampleFormat::I16 => 9,
cpal::SampleFormat::I32 => 8,
cpal::SampleFormat::F64 => 7,
cpal::SampleFormat::U16 => 6,
cpal::SampleFormat::I8 => 5,
cpal::SampleFormat::U8 => 4,
_ => 0,
}
}
/// Find the best supported config with the exact sample rate (same channel
/// count as `channels`). This is how we force both devices onto one sample
/// rate while preferring a high-quality sample format.
fn pick_config(
ranges: impl Iterator<Item = SupportedStreamConfigRange>,
channels: u16,
rate: SampleRate,
) -> Option<SupportedStreamConfig> {
ranges
.filter(|r| r.channels() == channels && r.try_with_sample_rate(rate).is_some())
.max_by_key(|r| format_rank(r.sample_format()))
.map(|r| r.with_sample_rate(rate))
}
pub fn run(cfg: &AppConfig) -> Result<()> {
let host = cpal::default_host();
let input_device = capture::find_input(&host, cfg.input.as_deref())?;
let output_device = playback::find_output(&host, cfg.output.as_deref())?;
let in_supported = input_device
.default_input_config()
.context("failed to read default input config")?;
let rate = cfg
.sample_rate
.map(SampleRate)
.unwrap_or_else(|| in_supported.sample_rate());
let in_supported = if cfg.sample_rate.is_some() {
pick_config(
input_device.supported_input_configs()?,
in_supported.channels(),
rate,
)
.with_context(|| format!("input device does not support sample rate {} Hz", rate.0))?
} else {
in_supported
};
let out_default = output_device
.default_output_config()
.context("failed to read default output config")?;
let out_supported = pick_config(
output_device.supported_output_configs()?,
out_default.channels(),
rate,
)
.with_context(|| {
format!(
"output device does not support sample rate {} Hz; try `--sample-rate`",
rate.0
)
})?;
let in_cfg: StreamConfig = in_supported.config();
let out_cfg: StreamConfig = out_supported.config();
println!(
"input : {} @ {} Hz, {:?}, {} ch",
input_device.name().unwrap_or_else(|_| "<unnamed>".into()),
in_cfg.sample_rate.0,
in_supported.sample_format(),
in_cfg.channels,
);
println!(
"output : {} @ {} Hz, {:?}, {} ch",
output_device.name().unwrap_or_else(|_| "<unnamed>".into()),
out_cfg.sample_rate.0,
out_supported.sample_format(),
out_cfg.channels,
);
let ring_cap = (rate.0 as usize * 2).max(8192);
let (in_prod, in_cons) = HeapRb::<f32>::new(ring_cap).split();
let (out_prod, out_cons) = HeapRb::<f32>::new(ring_cap).split();
// Virtual mic: keep `vois.rs` as the default sink for the whole run so the
// app's output lands in the virtual mic. A helper thread parks other
// apps' streams back on the real sink so their audio doesn't leak into the
// mic. The default sink is restored on exit.
let prev_sink = if cfg.virtual_mic {
virtual_mic::VirtualMic::setup()?;
virtual_mic::VirtualMic::get_default_sink()
} else {
None
};
if prev_sink.is_some() {
virtual_mic::VirtualMic::set_default_sink("vois.rs");
}
let input_stream = capture::start(&input_device, &in_supported, in_prod)?;
let output_stream = playback::start(&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 worker_control = Arc::clone(&control);
let worker_stats = Arc::clone(&stats);
let worker = thread::spawn(move || {
dsp::chain::run_worker(
in_cons,
out_prod,
in_ch,
out_ch,
rate.0,
worker_control,
worker_stats,
)
});
input_stream
.play()
.context("failed to start input stream")?;
output_stream
.play()
.context("failed to start output stream")?;
let cleanup = if let Some(prev) = prev_sink {
let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
let handle = virtual_mic::VirtualMic::keep_others_off(Arc::clone(&stop), prev.clone());
let _sig = virtual_mic::VirtualMic::restore_on_signal(prev.clone());
Some((stop, handle, prev))
} else {
None
};
let info = ui::SessionInfo {
input: input_device.name().unwrap_or_else(|_| "<unnamed>".into()),
output: output_device.name().unwrap_or_else(|_| "<unnamed>".into()),
sample_rate: rate.0,
in_ch,
out_ch,
format_in: format!("{:?}", in_supported.sample_format()),
format_out: format!("{:?}", out_supported.sample_format()),
};
// Keep the streams alive while the UI blocks.
let _streams = (input_stream, output_stream);
ui::tui::run(control, stats, info);
if let Some((stop, handle, prev)) = cleanup {
stop.store(true, std::sync::atomic::Ordering::Relaxed);
let _ = handle.join();
virtual_mic::VirtualMic::set_default_sink(&prev);
}
println!("waiting for DSP worker to finish...");
let _ = worker.join();
// cpal's ALSA teardown can panic on PipeWire when the stream drop races
// with the worker thread closing its self-pipe. The process is about to
// exit anyway, so leak the streams instead of dropping them.
std::mem::forget(_streams);
println!("done.");
Ok(())
}

75
src/audio/playback.rs Normal file
View file

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

40
src/audio/record.rs Normal file
View file

@ -0,0 +1,40 @@
//! WAV recording of the processed mono stream via a dedicated writer thread.
use hound::{SampleFormat, WavSpec, WavWriter};
use std::sync::mpsc;
use std::sync::mpsc::Sender;
use std::thread;
use std::thread::JoinHandle;
pub fn spawn_writer(path: String, sample_rate: u32) -> (Sender<Vec<f32>>, JoinHandle<()>) {
let (tx, rx) = mpsc::channel::<Vec<f32>>();
let handle = thread::spawn(move || {
let spec = WavSpec {
channels: 1,
sample_rate,
bits_per_sample: 16,
sample_format: SampleFormat::Int,
};
let mut writer = match WavWriter::create(&path, spec) {
Ok(w) => w,
Err(e) => {
eprintln!("record: cannot create {path}: {e}");
return;
}
};
while let Ok(block) = rx.recv() {
for &s in &block {
let v = (s.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
if writer.write_sample(v).is_err() {
break;
}
}
}
if let Err(e) = writer.finalize() {
eprintln!("record: {e}");
} else {
println!("recorded to {path}");
}
});
(tx, handle)
}

197
src/audio/virtual_mic.rs Normal file
View file

@ -0,0 +1,197 @@
// Virtual microphone setup for PipeWire/PulseAudio.
//
// Creates a "vois.rs" null sink and remaps its monitor source to "vois.rs".
// The app does NOT hijack the default sink permanently: it briefly points the
// default sink at `vois.rs` while its own output stream is created (so the
// processed voice lands in the virtual mic) and restores it right after.
// Other apps' streams are not moved, so their audio never leaks into the mic.
use anyhow::{bail, Context, Result};
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
pub struct VirtualMic;
impl VirtualMic {
pub fn setup() -> Result<Option<String>> {
let pactl = "pactl";
let sinks = Command::new(pactl)
.args(["list", "short", "sinks"])
.output()
.context("failed to run `pactl list short sinks`")?;
if !sinks.status.success() {
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([
"load-module",
"module-null-sink",
"sink_name=vois.rs",
"sink_properties=device.description=vois virtual mic",
])
.output()
.context("failed to create the vois.rs null sink")?;
if !out.status.success() {
bail!(
"`pactl load-module module-null-sink sink_name=vois.rs` failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
}
let sources = Command::new(pactl)
.args(["list", "short", "sources"])
.output()
.context("failed to run `pactl list short sources`")?;
if !sources.status.success() {
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([
"load-module",
"module-remap-source",
"source_name=vois.rs",
"master=vois.rs.monitor",
])
.output()
.context("failed to create the vois.rs remap source")?;
if !out.status.success() {
bail!(
"`pactl load-module module-remap-source source_name=vois.rs` failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
}
Ok(Some("vois.rs".to_string()))
}
/// Current default sink name, if any.
pub fn get_default_sink() -> Option<String> {
let out = Command::new("pactl")
.arg("get-default-sink")
.output()
.ok()?;
let name = String::from_utf8_lossy(&out.stdout).trim().to_string();
if name.is_empty() {
None
} else {
Some(name)
}
}
pub fn set_default_sink(name: &str) {
let _ = Command::new("pactl")
.args(["set-default-sink", name])
.output();
}
/// Keep every sink-input that is NOT this app's (client name "vois") away
/// from the `vois.rs` sink, moving it to `prev_sink`. Runs until `stop`.
///
/// WirePlumber routes ALSA streams to the default sink, so while the app
/// runs with `vois.rs` as default, other apps' audio would leak into the
/// virtual mic. Moving their streams explicitly sticks, so this thread
/// parks them on the real output.
pub fn keep_others_off(stop: Arc<AtomicBool>, prev_sink: String) -> thread::JoinHandle<()> {
thread::spawn(move || {
while !stop.load(Ordering::Relaxed) {
let target_sink_id = sink_id_named("vois.rs");
let prev_id = sink_id_named(&prev_sink);
if let (Some(lv), Some(pv)) = (target_sink_id, prev_id) {
if let (Ok(names), Ok(inputs)) = (client_names(), sink_inputs()) {
for (id, sink, client) in &inputs {
let ours = names.get(client).map(|n| n == "vois").unwrap_or(false);
if sink == &lv && !ours {
let _ = Command::new("pactl")
.args(["move-sink-input", id, &pv])
.output();
}
}
}
}
thread::sleep(Duration::from_millis(500));
}
})
}
/// 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([
signal_hook::consts::signal::SIGINT,
signal_hook::consts::signal::SIGTERM,
]) {
Ok(s) => s,
Err(_) => return,
};
if let Some(_sig) = signals.forever().next() {
Self::set_default_sink(&prev);
std::process::exit(130);
}
})
}
}
fn sink_id_named(name: &str) -> Option<String> {
let out = Command::new("pactl")
.args(["list", "short", "sinks"])
.output()
.ok()?;
for line in String::from_utf8_lossy(&out.stdout).lines() {
let fields: Vec<&str> = line.split_whitespace().collect();
if fields.len() >= 2 && fields[1] == name {
return Some(fields[0].to_string());
}
}
None
}
fn client_names() -> Result<std::collections::HashMap<String, String>> {
let out = Command::new("pactl")
.args(["list", "short", "clients"])
.output()
.context("failed to run `pactl list short clients`")?;
let mut map = std::collections::HashMap::new();
for line in String::from_utf8_lossy(&out.stdout).lines() {
let fields: Vec<&str> = line.split_whitespace().collect();
if fields.len() >= 3 {
map.insert(fields[0].to_string(), fields[2].to_string());
}
}
Ok(map)
}
fn sink_inputs() -> Result<Vec<(String, String, String)>> {
let out = Command::new("pactl")
.args(["list", "short", "sink-inputs"])
.output()
.context("failed to run `pactl list short sink-inputs`")?;
let mut inputs = Vec::new();
for line in String::from_utf8_lossy(&out.stdout).lines() {
let fields: Vec<&str> = line.split_whitespace().collect();
if fields.len() >= 3 {
inputs.push((
fields[0].to_string(),
fields[1].to_string(),
fields[2].to_string(),
));
}
}
Ok(inputs)
}
fn has_entry(list: &str, name: &str) -> bool {
list.lines()
.any(|l| l.split_whitespace().nth(1).is_some_and(|n| n == name))
}

64
src/config.rs Normal file
View file

@ -0,0 +1,64 @@
use crate::dsp::chain::Control;
use crate::dsp::presets::PRESETS;
pub struct AppConfig {
pub input: Option<String>,
pub output: Option<String>,
pub sample_rate: Option<u32>,
pub preset: String,
pub record: Option<String>,
pub pitch: f32,
pub formant: f32,
pub gate: f32,
pub gain: f32,
pub run_seconds: Option<u32>,
pub tone: f32,
pub virtual_mic: bool,
}
impl AppConfig {
pub fn from_cli(cli: &crate::Cli) -> Self {
Self {
input: cli.input.clone(),
output: cli.output.clone(),
sample_rate: cli.sample_rate,
preset: cli.preset.clone(),
record: cli.record.clone(),
pitch: cli.pitch,
formant: cli.formant,
gate: cli.gate,
gain: cli.gain,
run_seconds: cli.run_seconds,
tone: cli.tone,
virtual_mic: !cli.no_virtual_mic,
}
}
/// Resolve the preset name into an index, defaulting to "Clean" on error.
fn preset_idx(&self) -> usize {
crate::dsp::presets::Preset::find(&self.preset).unwrap_or(0)
}
pub fn to_control(&self) -> Control {
Control {
preset_idx: self.preset_idx(),
pitch_delta: self.pitch,
formant_delta: self.formant,
gate_threshold_db: self.gate,
gain: self.gain,
record: self.record.is_some(),
record_path: self.record.clone(),
run_seconds: self.run_seconds,
tone_hz: self.tone,
..Control::default()
}
}
pub fn preset_names() -> String {
PRESETS
.iter()
.map(|p| p.name)
.collect::<Vec<_>>()
.join(", ")
}
}

430
src/dsp/chain.rs Normal file
View file

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

605
src/dsp/effects.rs Normal file
View file

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

12
src/dsp/formant.rs Normal file
View file

@ -0,0 +1,12 @@
//! Formant shifting helpers.
//!
//! Formant shifting (changing the vocal-tract resonances without changing
//! pitch) is performed by the phase vocoder in [`crate::dsp::pitch`] via its
//! `formant_ratio` parameter. This module holds the tiny helper used to convert
//! a manual formant adjustment in semitones into the ratio the vocoder expects.
/// Convert a formant adjustment in semitones into a ratio factor
/// (1.0 = unchanged, >1 = higher/brighter).
pub fn ratio_from_semitones(semitones: f32) -> f32 {
2f32.powf(semitones / 12.0)
}

64
src/dsp/gate.rs Normal file
View file

@ -0,0 +1,64 @@
//! 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;
}
}
}

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

@ -0,0 +1,7 @@
pub mod chain;
pub mod effects;
pub mod formant;
pub mod gate;
pub mod pitch;
pub mod presets;
pub mod stats;

416
src/dsp/pitch.rs Normal file
View file

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

298
src/dsp/presets.rs Normal file
View file

@ -0,0 +1,298 @@
//! Preset voice settings: pitch, formant and an effect chain.
use crate::dsp::effects::{self, CarrierKind, Effect};
pub struct Preset {
pub name: &'static str,
/// Pitch shift in semitones (positive = higher).
pub pitch_semitones: f32,
/// Formant ratio (1.0 = unchanged, >1 = higher/brighter).
pub formant_ratio: f32,
/// Effects applied in order after pitch/formant.
pub effects: &'static [EffectSpec],
}
#[derive(Clone, Copy, Debug)]
pub enum EffectSpec {
Distortion {
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,
},
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,
wet: f32,
},
}
pub const PRESETS: &[Preset] = &[
Preset {
name: "Clean",
pitch_semitones: 0.0,
formant_ratio: 1.0,
effects: &[],
},
Preset {
name: "Girl / Anime",
pitch_semitones: 5.0,
formant_ratio: 1.25,
effects: &[],
},
Preset {
name: "Boy",
pitch_semitones: 3.0,
formant_ratio: 1.12,
effects: &[],
},
Preset {
name: "Manly / Deep",
pitch_semitones: -4.0,
formant_ratio: 0.78,
effects: &[EffectSpec::Compressor {
threshold_db: -20.0,
ratio: 3.0,
makeup_db: 2.0,
}],
},
Preset {
name: "Demon",
pitch_semitones: -9.0,
formant_ratio: 0.85,
effects: &[
EffectSpec::Distortion {
drive: 6.0,
output: 0.7,
},
EffectSpec::Reverb {
room: 0.6,
damp: 0.4,
wet: 0.4,
},
],
},
Preset {
name: "Robot",
pitch_semitones: 0.0,
formant_ratio: 1.0,
effects: &[
EffectSpec::Vocoder {
bands: 16,
carrier: CarrierKind::Noise,
wet: 1.0,
},
EffectSpec::Bitcrush {
bits: 8,
sample_rate_div: 2,
max_gain: 8.0,
target: 0.6,
},
],
},
Preset {
name: "8-bit",
pitch_semitones: 0.0,
formant_ratio: 1.0,
effects: &[EffectSpec::Bitcrush {
bits: 5,
sample_rate_div: 4,
max_gain: 32.0,
target: 0.6,
}],
},
Preset {
name: "Alien",
pitch_semitones: -2.0,
formant_ratio: 1.15,
effects: &[
EffectSpec::RingMod {
freq_hz: 55.0,
depth: 0.5,
},
EffectSpec::Chorus {
depth_ms: 6.0,
rate_hz: 0.5,
wet: 0.4,
},
],
},
Preset {
name: "Radio",
pitch_semitones: 0.0,
formant_ratio: 1.0,
effects: &[
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,
},
],
},
Preset {
name: "Megaphone",
pitch_semitones: 0.0,
formant_ratio: 1.0,
effects: &[
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,
},
],
},
Preset {
name: "Ghost",
pitch_semitones: -3.0,
formant_ratio: 1.05,
effects: &[
EffectSpec::Reverb {
room: 0.95,
damp: 0.2,
wet: 0.8,
},
EffectSpec::Chorus {
depth_ms: 8.0,
rate_hz: 0.4,
wet: 0.5,
},
],
},
Preset {
name: "Cyborg",
pitch_semitones: 2.0,
formant_ratio: 1.0,
effects: &[
EffectSpec::Vocoder {
bands: 16,
carrier: CarrierKind::Saw,
wet: 0.9,
},
EffectSpec::Bitcrush {
bits: 8,
sample_rate_div: 2,
max_gain: 8.0,
target: 0.6,
},
],
},
];
impl Preset {
pub fn find(name: &str) -> Option<usize> {
PRESETS.iter().position(|p| {
p.name.eq_ignore_ascii_case(name)
|| p.name
.split('/')
.next()
.map(|s| s.trim().eq_ignore_ascii_case(name))
.unwrap_or(false)
})
}
}
pub fn build_effects(specs: &[EffectSpec], sample_rate: u32) -> Vec<Box<dyn Effect>> {
specs.iter().map(|s| build_effect(s, sample_rate)).collect()
}
fn build_effect(spec: &EffectSpec, sample_rate: u32) -> Box<dyn Effect> {
match spec {
EffectSpec::Distortion { drive, output } => {
Box::new(effects::Distortion::new(*drive, *output))
}
EffectSpec::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::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,
wet,
} => Box::new(effects::ChannelVocoder::new(
sample_rate,
*bands,
*carrier,
*wet,
)),
}
}

35
src/dsp/stats.rs Normal file
View file

@ -0,0 +1,35 @@
//! 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(),
}
}
}

113
src/main.rs Normal file
View file

@ -0,0 +1,113 @@
mod audio;
mod config;
mod dsp;
mod ui;
use anyhow::Result;
use clap::Parser;
#[derive(Parser, Debug)]
#[command(
name = "vois",
version,
about = "Real-time voice changer written in pure Rust"
)]
pub struct Cli {
#[arg(short = 'i', long, help = "Input (microphone) device name")]
pub input: Option<String>,
#[arg(short = 'o', long, help = "Output device name")]
pub output: Option<String>,
#[arg(
short = 's',
long,
help = "Sample rate in Hz (must be supported by both devices)"
)]
pub sample_rate: Option<u32>,
#[arg(long, help = "List available audio devices and exit")]
pub list: bool,
#[arg(
short = 'p',
long,
default_value = "Clean",
help = "Starting preset name"
)]
pub preset: String,
#[arg(
long,
help = "Record processed audio to a WAV file (also toggle with R in the UI)"
)]
pub record: Option<String>,
#[arg(long, default_value_t = 0.0, help = "Initial pitch shift in semitones")]
pub pitch: f32,
#[arg(
long,
default_value_t = 0.0,
help = "Initial formant shift in semitones"
)]
pub formant: f32,
#[arg(
long,
default_value_t = -100.0,
help = "Noise gate threshold in dB (default -100 = off)"
)]
pub gate: f32,
#[arg(long, default_value_t = 1.0, help = "Output gain (1.0 = unity)")]
pub gain: f32,
#[arg(long, help = "Exit automatically after this many seconds")]
pub run_seconds: Option<u32>,
#[arg(
long,
default_value_t = 0.0,
help = "Mix a test tone (Hz) into the input; 0 = off. Useful to hear presets without a mic"
)]
pub tone: f32,
#[arg(long, help = "List available presets and exit")]
pub list_presets: bool,
#[arg(
long,
help = "Disable the built-in virtual mic (vois.rs); on by default"
)]
pub no_virtual_mic: bool,
}
fn main() -> Result<()> {
let cli = Cli::parse();
if cli.list {
audio::devices::print_all()?;
return Ok(());
}
if cli.list_presets {
println!("Available presets: {}", config::AppConfig::preset_names());
return Ok(());
}
if !cli.no_virtual_mic {
match audio::virtual_mic::VirtualMic::setup() {
Ok(Some(src)) => {
println!(
"virtual mic ready: use \"{src}\" (or \"vois.rs.monitor\") as your microphone"
)
}
Ok(None) => println!("virtual mic setup failed: no source reported"),
Err(e) => println!("warning: virtual mic setup failed: {e:#}"),
}
}
let cfg = config::AppConfig::from_cli(&cli);
audio::run(&cfg)
}

12
src/ui/mod.rs Normal file
View file

@ -0,0 +1,12 @@
pub mod tui;
/// Static information about the audio session, shown in the TUI.
pub struct SessionInfo {
pub input: String,
pub output: String,
pub sample_rate: u32,
pub in_ch: usize,
pub out_ch: usize,
pub format_in: String,
pub format_out: String,
}

826
src/ui/tui.rs Normal file
View file

@ -0,0 +1,826 @@
//! 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; 8] = [
"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",
];
const HELP_TEXT: &str = "\
KEY / MOUSE ACTION
───────────────────────────────────────────────
↑ ↓ (or mouse) select preset / settings row
← → change value
Enter toggle / apply
S / Tab toggle SETTINGS screen
Esc back to main screen
H toggle this help screen
Q / Ctrl+C quit
0 - 9 jump to preset
M mute R record
B A/B passthrough mode
";
const METER_FLOOR_DB: f32 = -60.0;
fn db(rms: f32) -> f32 {
if rms <= 0.0 {
METER_FLOOR_DB
} else {
(20.0 * rms.log10()).clamp(METER_FLOOR_DB, 6.0)
}
}
fn meter_ratio(db: f32) -> f64 {
((db - METER_FLOOR_DB) / -METER_FLOOR_DB).clamp(0.0, 1.0) as f64
}
fn meter_style(level_db: f32) -> Style {
if level_db >= -6.0 {
Style::default().fg(Color::Red)
} else if level_db >= -20.0 {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::Green)
}
}
#[derive(Clone, Copy, PartialEq)]
enum View {
Main,
Settings,
Help,
}
struct App {
control: Arc<Mutex<Control>>,
stats: Arc<Mutex<Stats>>,
info: SessionInfo,
view: View,
settings_cursor: usize,
splash_until: Instant,
started: Instant,
running: bool,
}
impl App {
/// (preset_idx, preset_name, pitch_st, formant_ratio, gate_db, gain_lin,
/// mute, record, tone, mode)
fn snapshot(&self) -> (usize, String, f32, f32, f32, f32, 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),
c.gate_threshold_db,
c.gain,
c.mute,
c.record,
c.tone_hz > 0.0,
c.mode,
)
})
.unwrap_or((
0,
"Clean".into(),
0.0,
1.0,
-100.0,
1.0,
false,
false,
false,
Mode::Live,
))
}
fn setting_value(&self, idx: usize) -> String {
let (_, _name, pitch, formant, gate, gain, mute, record, _tone, 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(),
_ => 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,
_ => {}
});
}
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 < 8 {
self.settings_cursor = idx;
}
}
}
View::Help => self.view = View::Main,
}
}
}
fn terminal_size(&self) -> Rect {
crossterm::terminal::size()
.map(|(w, h)| Rect::new(0, 0, w, h))
.unwrap_or(Rect::new(0, 0, 100, 30))
}
fn set_quit(&mut self) {
self.with_ctrl(|c| c.quit = true);
self.running = false;
}
fn with_ctrl(&self, f: impl FnOnce(&mut Control)) {
if let Ok(mut c) = self.control.lock() {
f(&mut c);
}
}
fn render(&mut self, f: &mut Frame) {
if Instant::now() < self.splash_until {
self.render_splash(f);
return;
}
match self.view {
View::Main => self.render_main(f),
View::Settings => self.render_settings(f),
View::Help => self.render_help(f),
}
}
fn render_splash(&self, f: &mut Frame) {
let area = f.area();
let mut lines: Vec<Line> = Vec::new();
for (i, l) in LOGO.iter().enumerate() {
let color = match i {
0 | 1 => Color::Cyan,
2 | 3 => Color::Magenta,
4 | 5 => Color::Blue,
_ => Color::Gray,
};
lines.push(Line::styled(
l.to_string(),
Style::default().fg(color).add_modifier(Modifier::BOLD),
));
}
lines.push(Line::from(""));
lines.push(Line::from(
" press any key, click, or use the arrow keys ...",
));
lines.push(Line::styled(
" audio is already running",
Style::default().fg(Color::DarkGray),
));
let para = Paragraph::new(lines).alignment(ratatui::layout::Alignment::Center);
// Center in the full area, but never taller than the terminal.
let rect = centered_rect(70, 55, area);
f.render_widget(para, rect);
}
fn render_help(&self, f: &mut Frame) {
let para = Paragraph::new(HELP_TEXT)
.style(Style::default().fg(Color::White))
.block(block(" vois · help "));
f.render_widget(para, f.area());
}
fn render_header(&self, f: &mut Frame, area: Rect) {
let (_, _name, _p, _f, _g, _gain, mute, record, tone, 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 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(
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, _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",
),
];
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",
];
let mut lines = Vec::new();
for (i, label) in labels.iter().enumerate() {
let value = self.setting_value(i);
let selected = i == self.settings_cursor;
let style = if selected {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Gray)
};
let info_style = Style::default().fg(Color::DarkGray);
lines.push(Line::from(vec![
Span::styled(
format!(
"{}{} {:<12}",
if selected { "▶" } else { " " },
if selected { "" } else { " " },
label
),
style,
),
Span::styled(format!("{:<16}", value), style),
Span::styled(SETTING_INFO[i], info_style),
]));
}
lines.push(Line::from(""));
lines.push(Line::styled(
" use ↑ ↓ to select, ← → (or Enter) to change, Esc to go back",
Style::default().fg(Color::DarkGray),
));
let para = Paragraph::new(lines).block(block(" settings "));
f.render_widget(para, list_rect);
self.render_footer(
f,
foot,
" ↑↓ select | ←→ change | Esc back | H help | Q quit ",
);
}
fn render_stats(&self, f: &mut Frame, area: Rect) {
let s = self
.stats
.lock()
.map(|s| {
(
s.input_rms,
s.output_rms,
s.peak_in,
s.peak_out,
s.blocks,
s.samples,
s.cpu_pct,
s.dsp_latency_ms,
s.started,
)
})
.unwrap_or((0.0, 0.0, 0.0, 0.0, 0, 0, 0.0, 0.0, Instant::now()));
let (in_rms, out_rms, peak_in, peak_out, blocks, samples, cpu, latency, started) = s;
let in_db = db(in_rms);
let out_db = db(out_rms);
let peak_in_db = db(peak_in);
let peak_out_db = db(peak_out);
let uptime = started.elapsed().as_secs();
let up = format!(
"{:02}:{:02}:{:02}",
uptime / 3600,
(uptime / 60) % 60,
uptime % 60
);
let secs = started.elapsed().as_secs_f32().max(0.1);
let hz = samples as f32 / secs;
let lines = vec![
Line::from(""),
Line::from(vec![
Span::styled(" peak in ", Style::default().fg(Color::DarkGray)),
Span::styled(format!("{peak_in_db:+.0} dB"), meter_style(peak_in_db)),
Span::styled(" peak out ", Style::default().fg(Color::DarkGray)),
Span::styled(format!("{peak_out_db:+.0} dB"), meter_style(peak_out_db)),
]),
Line::from(""),
Line::from(vec![
Span::styled(" dsp load ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!("{cpu:.1} %"),
if cpu > 50.0 {
Style::default().fg(Color::Red)
} else {
Style::default().fg(Color::Green)
},
),
Span::raw(" "),
Span::styled("latency", Style::default().fg(Color::DarkGray)),
Span::raw(format!(" {latency:.0} ms")),
]),
Line::from(vec![
Span::styled(" rate ", Style::default().fg(Color::DarkGray)),
Span::raw(format!(
"{} Hz {} ch → {} ch ({} → {})",
self.info.sample_rate,
self.info.in_ch,
self.info.out_ch,
self.info.format_in,
self.info.format_out
)),
]),
Line::from(vec![
Span::styled(" uptime ", Style::default().fg(Color::DarkGray)),
Span::raw(up),
]),
Line::from(vec![
Span::styled(" blocks ", Style::default().fg(Color::DarkGray)),
Span::raw(format!("{blocks} ({hz:.0} blk/s)")),
]),
Line::from(""),
Line::from(Span::styled(
format!(" in: {}", self.info.input),
Style::default().fg(Color::DarkGray),
)),
Line::from(Span::styled(
format!(" out: {}", self.info.output),
Style::default().fg(Color::DarkGray),
)),
];
// Meters on top of the column.
let vert = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2),
Constraint::Length(1),
Constraint::Length(2),
Constraint::Length(1),
Constraint::Min(0),
])
.split(area);
let in_gauge = Gauge::default()
.ratio(meter_ratio(in_db))
.gauge_style(meter_style(in_db));
let out_gauge = Gauge::default()
.ratio(meter_ratio(out_db))
.gauge_style(meter_style(out_db));
f.render_widget(
Paragraph::new(Line::from(vec![
Span::raw(" in "),
Span::styled(format!("{in_db:+.0} dB"), meter_style(in_db)),
])),
vert[0],
);
f.render_widget(in_gauge, vert[1]);
f.render_widget(
Paragraph::new(Line::from(vec![
Span::raw(" out "),
Span::styled(format!("{out_db:+.0} dB"), meter_style(out_db)),
])),
vert[2],
);
f.render_widget(out_gauge, vert[3]);
f.render_widget(Paragraph::new(lines), vert[4]);
}
}
/// Layout rects for the main screen: (header, body, footer).
fn main_layout(area: Rect) -> (Rect, Rect, Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(1),
])
.split(area);
(chunks[0], chunks[1], chunks[2])
}
/// Layout rects for the settings screen: (header, list, footer).
fn settings_layout(area: Rect) -> (Rect, Rect, Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(1),
])
.split(area);
(chunks[0], chunks[1], chunks[2])
}
fn p_row(label: &str, value: &str, color: Color, info: &str) -> Line<'static> {
Line::from(vec![
Span::styled(
format!(" {label:<8}"),
Style::default().fg(Color::DarkGray),
),
Span::styled(
value.to_string(),
Style::default().fg(color).add_modifier(Modifier::BOLD),
),
Span::styled(format!(" ({info})"), Style::default().fg(Color::DarkGray)),
])
}
fn block(title: &str) -> Block<'static> {
Block::default()
.borders(Borders::ALL)
.title(Span::styled(
title.to_string(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(Color::DarkGray))
}
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
let height = ((area.height as u32 * percent_y as u32 / 100) as u16).max(12);
let top = area.y + (area.height.saturating_sub(height) / 2);
let rect = Rect::new(area.x, top, area.width, height);
let width = ((rect.width as u32 * percent_x as u32 / 100) as u16).max(30);
let x = rect.x + (rect.width.saturating_sub(width) / 2);
Rect::new(x, rect.y, width, rect.height)
}
pub fn run(control: Arc<Mutex<Control>>, stats: Arc<Mutex<Stats>>, info: SessionInfo) {
let run_seconds = control.lock().ok().and_then(|c| c.run_seconds).unwrap_or(0);
if !std::io::stdin().is_terminal() {
let started = Instant::now();
loop {
let done = control
.lock()
.map(|c| {
c.quit || run_seconds > 0 && started.elapsed().as_secs() >= run_seconds as u64
})
.unwrap_or(true);
if done {
if let Ok(mut c) = control.lock() {
c.quit = true;
}
break;
}
std::thread::sleep(Duration::from_millis(50));
}
return;
}
enable_raw_mode().expect("failed to enable raw mode");
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...");
}