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:
commit
b0d685a110
25 changed files with 6243 additions and 0 deletions
69
src/audio/capture.rs
Normal file
69
src/audio/capture.rs
Normal 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
18
src/audio/devices.rs
Normal 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
191
src/audio/mod.rs
Normal 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
75
src/audio/playback.rs
Normal 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
40
src/audio/record.rs
Normal 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
197
src/audio/virtual_mic.rs
Normal 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
64
src/config.rs
Normal 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
430
src/dsp/chain.rs
Normal 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
605
src/dsp/effects.rs
Normal 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
12
src/dsp/formant.rs
Normal 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
64
src/dsp/gate.rs
Normal 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
7
src/dsp/mod.rs
Normal 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
416
src/dsp/pitch.rs
Normal 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
298
src/dsp/presets.rs
Normal 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
35
src/dsp/stats.rs
Normal 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
113
src/main.rs
Normal 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
12
src/ui/mod.rs
Normal 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
826
src/ui/tui.rs
Normal 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...");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue