Restructure project: 1 file = 1 responsibility, max 4 files/folder, 250 lines/file
This commit is contained in:
parent
72769f4615
commit
a6032b2d18
49 changed files with 3408 additions and 3492 deletions
|
|
@ -35,9 +35,9 @@ mod audio {
|
||||||
#[path = "../src/dsp/mod.rs"]
|
#[path = "../src/dsp/mod.rs"]
|
||||||
mod dsp;
|
mod dsp;
|
||||||
|
|
||||||
use dsp::chain::{Control, Processor};
|
use dsp::chain::Processor;
|
||||||
use dsp::pitch::PitchShifter;
|
use dsp::control::{Control, Stats};
|
||||||
use dsp::stats::Stats;
|
use dsp::shift::PitchShifter;
|
||||||
|
|
||||||
const FS: u32 = 48000;
|
const FS: u32 = 48000;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
use anyhow::{Context, Result};
|
|
||||||
use cpal::traits::{DeviceTrait, HostTrait};
|
|
||||||
use cpal::{
|
|
||||||
Device, FromSample, Host, Sample, SampleFormat, SizedSample, Stream, StreamConfig,
|
|
||||||
SupportedStreamConfig,
|
|
||||||
};
|
|
||||||
use ringbuf::traits::Producer;
|
|
||||||
|
|
||||||
const PREFERRED_INPUTS: [&str; 3] = ["pipewire", "pulse", "default"];
|
|
||||||
|
|
||||||
pub fn find_input(host: &Host, name: Option<&str>) -> Result<Device> {
|
|
||||||
match name {
|
|
||||||
Some(name) => host
|
|
||||||
.input_devices()
|
|
||||||
.context("failed to enumerate input devices")?
|
|
||||||
.find(|d| d.name().map(|n| n == name).unwrap_or(false))
|
|
||||||
.with_context(|| format!("input device {name:?} not found")),
|
|
||||||
None => {
|
|
||||||
let mut devices = host
|
|
||||||
.input_devices()
|
|
||||||
.context("failed to enumerate input devices")?;
|
|
||||||
if let Some(device) = devices.find(|d| {
|
|
||||||
d.name()
|
|
||||||
.map(|n| PREFERRED_INPUTS.iter().any(|p| n == *p))
|
|
||||||
.unwrap_or(false)
|
|
||||||
}) {
|
|
||||||
return Ok(device);
|
|
||||||
}
|
|
||||||
host.default_input_device()
|
|
||||||
.context("no default input device found")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Open the input stream. Captured samples are converted to `f32` (interleaved
|
|
||||||
/// frames) and pushed into `tx`.
|
|
||||||
pub fn start<P>(device: &Device, supported: &SupportedStreamConfig, tx: P) -> Result<Stream>
|
|
||||||
where
|
|
||||||
P: Producer<Item = f32> + Send + 'static,
|
|
||||||
{
|
|
||||||
let config: StreamConfig = supported.config();
|
|
||||||
match supported.sample_format() {
|
|
||||||
SampleFormat::F32 => build::<f32, P>(device, &config, tx),
|
|
||||||
SampleFormat::F64 => build::<f64, P>(device, &config, tx),
|
|
||||||
SampleFormat::I8 => build::<i8, P>(device, &config, tx),
|
|
||||||
SampleFormat::I16 => build::<i16, P>(device, &config, tx),
|
|
||||||
SampleFormat::I32 => build::<i32, P>(device, &config, tx),
|
|
||||||
SampleFormat::I64 => build::<i64, P>(device, &config, tx),
|
|
||||||
SampleFormat::U8 => build::<u8, P>(device, &config, tx),
|
|
||||||
SampleFormat::U16 => build::<u16, P>(device, &config, tx),
|
|
||||||
SampleFormat::U32 => build::<u32, P>(device, &config, tx),
|
|
||||||
SampleFormat::U64 => build::<u64, P>(device, &config, tx),
|
|
||||||
format => anyhow::bail!("unsupported input sample format: {format:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build<T, P>(device: &Device, config: &StreamConfig, mut tx: P) -> Result<Stream>
|
|
||||||
where
|
|
||||||
T: SizedSample,
|
|
||||||
f32: FromSample<T>,
|
|
||||||
P: Producer<Item = f32> + Send + 'static,
|
|
||||||
{
|
|
||||||
let channels = config.channels as usize;
|
|
||||||
let mut frame = vec![0f32; channels];
|
|
||||||
let err_fn = |err| eprintln!("capture stream error: {err}");
|
|
||||||
|
|
||||||
device
|
|
||||||
.build_input_stream(
|
|
||||||
config,
|
|
||||||
move |data: &[T], _: &cpal::InputCallbackInfo| {
|
|
||||||
for chunk in data.chunks(channels) {
|
|
||||||
for (slot, sample) in frame.iter_mut().zip(chunk) {
|
|
||||||
*slot = f32::from_sample(*sample);
|
|
||||||
}
|
|
||||||
let _ = tx.push_slice(&frame);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
err_fn,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.map_err(|e| anyhow::anyhow!("failed to build input stream: {e}"))
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
use anyhow::Result;
|
|
||||||
use cpal::traits::{DeviceTrait, HostTrait};
|
|
||||||
|
|
||||||
pub fn print_all() -> Result<()> {
|
|
||||||
let host = cpal::default_host();
|
|
||||||
|
|
||||||
println!("Input devices:");
|
|
||||||
for device in host.input_devices()? {
|
|
||||||
println!(" {}", device.name().unwrap_or_else(|_| "<unnamed>".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("Output devices:");
|
|
||||||
for device in host.output_devices()? {
|
|
||||||
println!(" {}", device.name().unwrap_or_else(|_| "<unnamed>".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
pub mod capture;
|
|
||||||
pub mod devices;
|
|
||||||
pub mod playback;
|
|
||||||
pub mod record;
|
pub mod record;
|
||||||
|
pub mod stream;
|
||||||
pub mod virtual_mic;
|
pub mod virtual_mic;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use cpal::traits::{DeviceTrait, StreamTrait};
|
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||||
use cpal::{SampleRate, StreamConfig, SupportedStreamConfig, SupportedStreamConfigRange};
|
use cpal::{SampleRate, StreamConfig, SupportedStreamConfig, SupportedStreamConfigRange};
|
||||||
use ringbuf::traits::Split;
|
use ringbuf::traits::Split;
|
||||||
use ringbuf::HeapRb;
|
use ringbuf::HeapRb;
|
||||||
|
|
@ -16,6 +14,20 @@ use crate::config::AppConfig;
|
||||||
use crate::dsp;
|
use crate::dsp;
|
||||||
use crate::ui;
|
use crate::ui;
|
||||||
|
|
||||||
|
/// Print available input/output devices.
|
||||||
|
pub fn print_all() -> Result<()> {
|
||||||
|
let host = cpal::default_host();
|
||||||
|
println!("Input devices:");
|
||||||
|
for device in host.input_devices()? {
|
||||||
|
println!(" {}", device.name().unwrap_or_else(|_| "<unnamed>".into()));
|
||||||
|
}
|
||||||
|
println!("Output devices:");
|
||||||
|
for device in host.output_devices()? {
|
||||||
|
println!(" {}", device.name().unwrap_or_else(|_| "<unnamed>".into()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Higher = better quality for our purposes.
|
/// Higher = better quality for our purposes.
|
||||||
fn format_rank(format: cpal::SampleFormat) -> u8 {
|
fn format_rank(format: cpal::SampleFormat) -> u8 {
|
||||||
match format {
|
match format {
|
||||||
|
|
@ -63,8 +75,8 @@ pub fn run(cfg: &AppConfig) -> Result<()> {
|
||||||
virtual_mic::VirtualMic::wait_default_sink("vois.rs");
|
virtual_mic::VirtualMic::wait_default_sink("vois.rs");
|
||||||
}
|
}
|
||||||
|
|
||||||
let input_device = capture::find_input(&host, cfg.input.as_deref())?;
|
let input_device = stream::find_input(&host, cfg.input.as_deref())?;
|
||||||
let output_device = playback::find_output(&host, cfg.output.as_deref())?;
|
let output_device = stream::find_output(&host, cfg.output.as_deref())?;
|
||||||
|
|
||||||
let in_supported = input_device
|
let in_supported = input_device
|
||||||
.default_input_config()
|
.default_input_config()
|
||||||
|
|
@ -136,18 +148,18 @@ pub fn run(cfg: &AppConfig) -> Result<()> {
|
||||||
let (in_prod, in_cons) = HeapRb::<f32>::new(ring_cap).split();
|
let (in_prod, in_cons) = HeapRb::<f32>::new(ring_cap).split();
|
||||||
let (out_prod, out_cons) = HeapRb::<f32>::new(ring_cap).split();
|
let (out_prod, out_cons) = HeapRb::<f32>::new(ring_cap).split();
|
||||||
|
|
||||||
let input_stream = capture::start(&input_device, &in_supported, in_prod)?;
|
let input_stream = stream::start_input(&input_device, &in_supported, in_prod)?;
|
||||||
let output_stream = playback::start(&output_device, &out_supported, out_cons)?;
|
let output_stream = stream::start_output(&output_device, &out_supported, out_cons)?;
|
||||||
|
|
||||||
let in_ch = in_cfg.channels as usize;
|
let in_ch = in_cfg.channels as usize;
|
||||||
let out_ch = out_cfg.channels as usize;
|
let out_ch = out_cfg.channels as usize;
|
||||||
|
|
||||||
let control = Arc::new(Mutex::new(cfg.to_control()));
|
let control = Arc::new(Mutex::new(cfg.to_control()));
|
||||||
let stats = Arc::new(Mutex::new(dsp::stats::Stats::default()));
|
let stats = Arc::new(Mutex::new(dsp::control::Stats::default()));
|
||||||
let worker_control = Arc::clone(&control);
|
let worker_control = Arc::clone(&control);
|
||||||
let worker_stats = Arc::clone(&stats);
|
let worker_stats = Arc::clone(&stats);
|
||||||
let worker = thread::spawn(move || {
|
let worker = thread::spawn(move || {
|
||||||
dsp::chain::run_worker(
|
dsp::chain::worker::run_worker(
|
||||||
in_cons,
|
in_cons,
|
||||||
out_prod,
|
out_prod,
|
||||||
in_ch,
|
in_ch,
|
||||||
|
|
@ -186,7 +198,7 @@ pub fn run(cfg: &AppConfig) -> Result<()> {
|
||||||
// Keep the streams alive while the UI blocks.
|
// Keep the streams alive while the UI blocks.
|
||||||
let _streams = (input_stream, output_stream);
|
let _streams = (input_stream, output_stream);
|
||||||
|
|
||||||
ui::tui::run(control, stats, info);
|
ui::run(control, stats, info);
|
||||||
|
|
||||||
if let Some((stop, handle, prev)) = cleanup {
|
if let Some((stop, handle, prev)) = cleanup {
|
||||||
stop.store(true, std::sync::atomic::Ordering::Relaxed);
|
stop.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
use anyhow::{Context, Result};
|
|
||||||
use cpal::traits::{DeviceTrait, HostTrait};
|
|
||||||
use cpal::{
|
|
||||||
Device, FromSample, Host, SampleFormat, SizedSample, Stream, StreamConfig,
|
|
||||||
SupportedStreamConfig,
|
|
||||||
};
|
|
||||||
use ringbuf::traits::Consumer;
|
|
||||||
|
|
||||||
const PREFERRED_OUTPUTS: [&str; 3] = ["pipewire", "pulse", "default"];
|
|
||||||
|
|
||||||
pub fn find_output(host: &Host, name: Option<&str>) -> Result<Device> {
|
|
||||||
match name {
|
|
||||||
Some(name) => host
|
|
||||||
.output_devices()
|
|
||||||
.context("failed to enumerate output devices")?
|
|
||||||
.find(|d| d.name().map(|n| n == name).unwrap_or(false))
|
|
||||||
.with_context(|| format!("output device {name:?} not found")),
|
|
||||||
None => {
|
|
||||||
let mut devices = host
|
|
||||||
.output_devices()
|
|
||||||
.context("failed to enumerate output devices")?;
|
|
||||||
if let Some(device) = devices.find(|d| {
|
|
||||||
d.name()
|
|
||||||
.map(|n| PREFERRED_OUTPUTS.iter().any(|p| n == *p))
|
|
||||||
.unwrap_or(false)
|
|
||||||
}) {
|
|
||||||
return Ok(device);
|
|
||||||
}
|
|
||||||
host.default_output_device()
|
|
||||||
.context("no default output device found")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Open the output stream. Pulls interleaved `f32` frames from `rx`; fills
|
|
||||||
/// with silence when the DSP worker has nothing ready yet.
|
|
||||||
pub fn start<C>(device: &Device, supported: &SupportedStreamConfig, rx: C) -> Result<Stream>
|
|
||||||
where
|
|
||||||
C: Consumer<Item = f32> + Send + 'static,
|
|
||||||
{
|
|
||||||
let config: StreamConfig = supported.config();
|
|
||||||
match supported.sample_format() {
|
|
||||||
SampleFormat::F32 => build::<f32, C>(device, &config, rx),
|
|
||||||
SampleFormat::F64 => build::<f64, C>(device, &config, rx),
|
|
||||||
SampleFormat::I8 => build::<i8, C>(device, &config, rx),
|
|
||||||
SampleFormat::I16 => build::<i16, C>(device, &config, rx),
|
|
||||||
SampleFormat::I32 => build::<i32, C>(device, &config, rx),
|
|
||||||
SampleFormat::I64 => build::<i64, C>(device, &config, rx),
|
|
||||||
SampleFormat::U8 => build::<u8, C>(device, &config, rx),
|
|
||||||
SampleFormat::U16 => build::<u16, C>(device, &config, rx),
|
|
||||||
SampleFormat::U32 => build::<u32, C>(device, &config, rx),
|
|
||||||
SampleFormat::U64 => build::<u64, C>(device, &config, rx),
|
|
||||||
format => anyhow::bail!("unsupported output sample format: {format:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build<T, C>(device: &Device, config: &StreamConfig, mut rx: C) -> Result<Stream>
|
|
||||||
where
|
|
||||||
T: SizedSample + FromSample<f32>,
|
|
||||||
C: Consumer<Item = f32> + Send + 'static,
|
|
||||||
{
|
|
||||||
let mut scratch = vec![0f32; 2048];
|
|
||||||
let err_fn = |err| eprintln!("playback stream error: {err}");
|
|
||||||
|
|
||||||
device
|
|
||||||
.build_output_stream(
|
|
||||||
config,
|
|
||||||
move |data: &mut [T], _: &cpal::OutputCallbackInfo| {
|
|
||||||
let mut written = 0usize;
|
|
||||||
while written < data.len() {
|
|
||||||
let want = (data.len() - written).min(scratch.len());
|
|
||||||
let n = rx.pop_slice(&mut scratch[..want]);
|
|
||||||
if n == 0 {
|
|
||||||
data[written..].fill(T::from_sample(0.0f32));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let out = &mut data[written..written + n];
|
|
||||||
for (out_sample, in_sample) in out.iter_mut().zip(&scratch[..n]) {
|
|
||||||
*out_sample = T::from_sample(*in_sample);
|
|
||||||
}
|
|
||||||
written += n;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
err_fn,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.map_err(|e| anyhow::anyhow!("failed to build output stream: {e}"))
|
|
||||||
}
|
|
||||||
165
src/audio/stream.rs
Normal file
165
src/audio/stream.rs
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
//! Audio device selection and stream creation (capture + playback).
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use cpal::traits::{DeviceTrait, HostTrait};
|
||||||
|
use cpal::{
|
||||||
|
Device, FromSample, Host, Sample, SampleFormat, SizedSample, Stream, StreamConfig,
|
||||||
|
SupportedStreamConfig,
|
||||||
|
};
|
||||||
|
use ringbuf::traits::{Consumer, Producer};
|
||||||
|
|
||||||
|
const PREFERRED_INPUTS: [&str; 3] = ["pipewire", "pulse", "default"];
|
||||||
|
const PREFERRED_OUTPUTS: [&str; 3] = ["pipewire", "pulse", "default"];
|
||||||
|
|
||||||
|
fn pick(
|
||||||
|
devices: impl Iterator<Item = Device>,
|
||||||
|
preferred: &[&str],
|
||||||
|
default: Option<Device>,
|
||||||
|
) -> Option<Device> {
|
||||||
|
let mut devices = devices;
|
||||||
|
devices
|
||||||
|
.find(|d| {
|
||||||
|
d.name()
|
||||||
|
.map(|n| preferred.contains(&n.as_str()))
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.or(default)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_input(host: &Host, name: Option<&str>) -> Result<Device> {
|
||||||
|
match name {
|
||||||
|
Some(name) => host
|
||||||
|
.input_devices()
|
||||||
|
.context("failed to enumerate input devices")?
|
||||||
|
.find(|d| d.name().map(|n| n == name).unwrap_or(false))
|
||||||
|
.with_context(|| format!("input device {name:?} not found")),
|
||||||
|
None => pick(
|
||||||
|
host.input_devices()
|
||||||
|
.context("failed to enumerate input devices")?,
|
||||||
|
&PREFERRED_INPUTS,
|
||||||
|
host.default_input_device(),
|
||||||
|
)
|
||||||
|
.context("no default input device found"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_output(host: &Host, name: Option<&str>) -> Result<Device> {
|
||||||
|
match name {
|
||||||
|
Some(name) => host
|
||||||
|
.output_devices()
|
||||||
|
.context("failed to enumerate output devices")?
|
||||||
|
.find(|d| d.name().map(|n| n == name).unwrap_or(false))
|
||||||
|
.with_context(|| format!("output device {name:?} not found")),
|
||||||
|
None => pick(
|
||||||
|
host.output_devices()
|
||||||
|
.context("failed to enumerate output devices")?,
|
||||||
|
&PREFERRED_OUTPUTS,
|
||||||
|
host.default_output_device(),
|
||||||
|
)
|
||||||
|
.context("no default output device found"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the input stream. Samples are converted to `f32` (interleaved frames)
|
||||||
|
/// and pushed into `tx`.
|
||||||
|
pub fn start_input<P>(device: &Device, supported: &SupportedStreamConfig, tx: P) -> Result<Stream>
|
||||||
|
where
|
||||||
|
P: Producer<Item = f32> + Send + 'static,
|
||||||
|
{
|
||||||
|
let config: StreamConfig = supported.config();
|
||||||
|
match supported.sample_format() {
|
||||||
|
SampleFormat::F32 => build_input::<f32, P>(device, &config, tx),
|
||||||
|
SampleFormat::F64 => build_input::<f64, P>(device, &config, tx),
|
||||||
|
SampleFormat::I8 => build_input::<i8, P>(device, &config, tx),
|
||||||
|
SampleFormat::I16 => build_input::<i16, P>(device, &config, tx),
|
||||||
|
SampleFormat::I32 => build_input::<i32, P>(device, &config, tx),
|
||||||
|
SampleFormat::I64 => build_input::<i64, P>(device, &config, tx),
|
||||||
|
SampleFormat::U8 => build_input::<u8, P>(device, &config, tx),
|
||||||
|
SampleFormat::U16 => build_input::<u16, P>(device, &config, tx),
|
||||||
|
SampleFormat::U32 => build_input::<u32, P>(device, &config, tx),
|
||||||
|
SampleFormat::U64 => build_input::<u64, P>(device, &config, tx),
|
||||||
|
format => anyhow::bail!("unsupported input sample format: {format:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_input<T, P>(device: &Device, config: &StreamConfig, mut tx: P) -> Result<Stream>
|
||||||
|
where
|
||||||
|
T: SizedSample,
|
||||||
|
f32: FromSample<T>,
|
||||||
|
P: Producer<Item = f32> + Send + 'static,
|
||||||
|
{
|
||||||
|
let channels = config.channels as usize;
|
||||||
|
let mut frame = vec![0f32; channels];
|
||||||
|
let err_fn = |err| eprintln!("capture stream error: {err}");
|
||||||
|
|
||||||
|
device
|
||||||
|
.build_input_stream(
|
||||||
|
config,
|
||||||
|
move |data: &[T], _: &cpal::InputCallbackInfo| {
|
||||||
|
for chunk in data.chunks(channels) {
|
||||||
|
for (slot, sample) in frame.iter_mut().zip(chunk) {
|
||||||
|
*slot = f32::from_sample(*sample);
|
||||||
|
}
|
||||||
|
let _ = tx.push_slice(&frame);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
err_fn,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map_err(|e| anyhow::anyhow!("failed to build input stream: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the output stream. Pulls interleaved `f32` frames from `rx`; fills
|
||||||
|
/// with silence when nothing is ready.
|
||||||
|
pub fn start_output<C>(device: &Device, supported: &SupportedStreamConfig, rx: C) -> Result<Stream>
|
||||||
|
where
|
||||||
|
C: Consumer<Item = f32> + Send + 'static,
|
||||||
|
{
|
||||||
|
let config: StreamConfig = supported.config();
|
||||||
|
match supported.sample_format() {
|
||||||
|
SampleFormat::F32 => build_output::<f32, C>(device, &config, rx),
|
||||||
|
SampleFormat::F64 => build_output::<f64, C>(device, &config, rx),
|
||||||
|
SampleFormat::I8 => build_output::<i8, C>(device, &config, rx),
|
||||||
|
SampleFormat::I16 => build_output::<i16, C>(device, &config, rx),
|
||||||
|
SampleFormat::I32 => build_output::<i32, C>(device, &config, rx),
|
||||||
|
SampleFormat::I64 => build_output::<i64, C>(device, &config, rx),
|
||||||
|
SampleFormat::U8 => build_output::<u8, C>(device, &config, rx),
|
||||||
|
SampleFormat::U16 => build_output::<u16, C>(device, &config, rx),
|
||||||
|
SampleFormat::U32 => build_output::<u32, C>(device, &config, rx),
|
||||||
|
SampleFormat::U64 => build_output::<u64, C>(device, &config, rx),
|
||||||
|
format => anyhow::bail!("unsupported output sample format: {format:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_output<T, C>(device: &Device, config: &StreamConfig, mut rx: C) -> Result<Stream>
|
||||||
|
where
|
||||||
|
T: SizedSample + FromSample<f32>,
|
||||||
|
C: Consumer<Item = f32> + Send + 'static,
|
||||||
|
{
|
||||||
|
let mut scratch = vec![0f32; 2048];
|
||||||
|
let err_fn = |err| eprintln!("playback stream error: {err}");
|
||||||
|
|
||||||
|
device
|
||||||
|
.build_output_stream(
|
||||||
|
config,
|
||||||
|
move |data: &mut [T], _: &cpal::OutputCallbackInfo| {
|
||||||
|
let mut written = 0usize;
|
||||||
|
while written < data.len() {
|
||||||
|
let want = (data.len() - written).min(scratch.len());
|
||||||
|
let n = rx.pop_slice(&mut scratch[..want]);
|
||||||
|
if n == 0 {
|
||||||
|
data[written..].fill(T::from_sample(0.0f32));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let out = &mut data[written..written + n];
|
||||||
|
for (o, i) in out.iter_mut().zip(&scratch[..n]) {
|
||||||
|
*o = T::from_sample(*i);
|
||||||
|
}
|
||||||
|
written += n;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
err_fn,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map_err(|e| anyhow::anyhow!("failed to build output stream: {e}"))
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,3 @@
|
||||||
// Virtual microphone setup.
|
|
||||||
//
|
|
||||||
// Linux/FreeBSD (PipeWire/PulseAudio): creates a "vois.rs" null sink and
|
|
||||||
// remaps its monitor source to "vois.rs", keeps the app's stream routed there
|
|
||||||
// and restores the default sink on exit.
|
|
||||||
//
|
|
||||||
// Other platforms (Windows WASAPI, macOS CoreAudio): no built-in virtual mic.
|
|
||||||
// Point the app's output at a virtual cable (e.g. VB-CABLE "CABLE Input" on
|
|
||||||
// Windows, BlackHole on macOS) with `-o` and select it in the voice app.
|
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
||||||
use anyhow::{bail, Context};
|
use anyhow::{bail, Context};
|
||||||
|
|
@ -15,15 +5,8 @@ use anyhow::{bail, Context};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::thread;
|
use std::{thread, time::Duration};
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
pub struct VirtualMic;
|
pub struct VirtualMic;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Linux / FreeBSD implementation (PipeWire / PulseAudio + pactl)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
||||||
fn wireplumber_conf() -> std::path::PathBuf {
|
fn wireplumber_conf() -> std::path::PathBuf {
|
||||||
let base = std::env::var("XDG_CONFIG_HOME")
|
let base = std::env::var("XDG_CONFIG_HOME")
|
||||||
|
|
@ -37,12 +20,10 @@ fn wireplumber_conf() -> std::path::PathBuf {
|
||||||
.join("wireplumber.conf.d")
|
.join("wireplumber.conf.d")
|
||||||
.join("51-vois.conf")
|
.join("51-vois.conf")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
||||||
impl VirtualMic {
|
impl VirtualMic {
|
||||||
pub fn setup() -> Result<Option<String>> {
|
pub fn setup() -> Result<Option<String>> {
|
||||||
let pactl = "pactl";
|
let pactl = "pactl";
|
||||||
|
|
||||||
let sinks = Command::new(pactl)
|
let sinks = Command::new(pactl)
|
||||||
.args(["list", "short", "sinks"])
|
.args(["list", "short", "sinks"])
|
||||||
.output()
|
.output()
|
||||||
|
|
@ -51,7 +32,6 @@ impl VirtualMic {
|
||||||
bail!("`pactl list short sinks` failed");
|
bail!("`pactl list short sinks` failed");
|
||||||
}
|
}
|
||||||
let sinks_out = String::from_utf8_lossy(&sinks.stdout);
|
let sinks_out = String::from_utf8_lossy(&sinks.stdout);
|
||||||
|
|
||||||
if !has_entry(&sinks_out, "vois.rs") {
|
if !has_entry(&sinks_out, "vois.rs") {
|
||||||
let out = Command::new(pactl)
|
let out = Command::new(pactl)
|
||||||
.args([
|
.args([
|
||||||
|
|
@ -69,7 +49,6 @@ impl VirtualMic {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let sources = Command::new(pactl)
|
let sources = Command::new(pactl)
|
||||||
.args(["list", "short", "sources"])
|
.args(["list", "short", "sources"])
|
||||||
.output()
|
.output()
|
||||||
|
|
@ -78,7 +57,6 @@ impl VirtualMic {
|
||||||
bail!("`pactl list short sources` failed");
|
bail!("`pactl list short sources` failed");
|
||||||
}
|
}
|
||||||
let sources_out = String::from_utf8_lossy(&sources.stdout);
|
let sources_out = String::from_utf8_lossy(&sources.stdout);
|
||||||
|
|
||||||
if !has_entry(&sources_out, "vois.rs") {
|
if !has_entry(&sources_out, "vois.rs") {
|
||||||
let out = Command::new(pactl)
|
let out = Command::new(pactl)
|
||||||
.args([
|
.args([
|
||||||
|
|
@ -96,25 +74,14 @@ impl VirtualMic {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if Self::ensure_wireplumber_fix() {
|
if Self::ensure_wireplumber_fix() {
|
||||||
// First run: the fix needs a WirePlumber restart to apply.
|
|
||||||
Self::restart_wireplumber();
|
Self::restart_wireplumber();
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Some("vois.rs".to_string()))
|
Ok(Some("vois.rs".to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// WirePlumber remembers the target of each stream and restores it, so a
|
|
||||||
/// stream that once played to the speakers keeps going there even after the
|
|
||||||
/// default sink is switched. Disabling that makes ALSA streams follow the
|
|
||||||
/// default sink again. Returns true if a config was (re)written.
|
|
||||||
pub fn ensure_wireplumber_fix() -> bool {
|
pub fn ensure_wireplumber_fix() -> bool {
|
||||||
let conf = wireplumber_conf();
|
let conf = wireplumber_conf();
|
||||||
let conf_text = "wireplumber.settings = {\n\
|
let conf_text = "wireplumber.settings = {\n node.stream.restore-target = false\n node.stream.restore-props = false\n}\n";
|
||||||
\x20 node.stream.restore-target = false\n\
|
|
||||||
\x20 node.stream.restore-props = false\n\
|
|
||||||
}\n";
|
|
||||||
if let Ok(existing) = std::fs::read_to_string(&conf) {
|
if let Ok(existing) = std::fs::read_to_string(&conf) {
|
||||||
if existing.contains("restore-target = false") {
|
if existing.contains("restore-target = false") {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -125,9 +92,6 @@ impl VirtualMic {
|
||||||
}
|
}
|
||||||
std::fs::write(&conf, conf_text).is_ok()
|
std::fs::write(&conf, conf_text).is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restart WirePlumber so a newly written config takes effect, and wait
|
|
||||||
/// until the audio server responds again.
|
|
||||||
pub fn restart_wireplumber() {
|
pub fn restart_wireplumber() {
|
||||||
let _ = Command::new("systemctl")
|
let _ = Command::new("systemctl")
|
||||||
.args(["--user", "restart", "wireplumber"])
|
.args(["--user", "restart", "wireplumber"])
|
||||||
|
|
@ -144,8 +108,6 @@ impl VirtualMic {
|
||||||
thread::sleep(Duration::from_millis(100));
|
thread::sleep(Duration::from_millis(100));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Current default sink name, if any.
|
|
||||||
pub fn get_default_sink() -> Option<String> {
|
pub fn get_default_sink() -> Option<String> {
|
||||||
let out = Command::new("pactl")
|
let out = Command::new("pactl")
|
||||||
.arg("get-default-sink")
|
.arg("get-default-sink")
|
||||||
|
|
@ -158,16 +120,11 @@ impl VirtualMic {
|
||||||
Some(name)
|
Some(name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_default_sink(name: &str) {
|
pub fn set_default_sink(name: &str) {
|
||||||
let _ = Command::new("pactl")
|
let _ = Command::new("pactl")
|
||||||
.args(["set-default-sink", name])
|
.args(["set-default-sink", name])
|
||||||
.output();
|
.output();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wait (up to 2 s) until the default sink is actually `name`. WirePlumber
|
|
||||||
/// applies `set-default-sink` asynchronously; if the app creates its output
|
|
||||||
/// node before that, the node latches onto the old default.
|
|
||||||
pub fn wait_default_sink(name: &str) {
|
pub fn wait_default_sink(name: &str) {
|
||||||
for _ in 0..40 {
|
for _ in 0..40 {
|
||||||
if Self::get_default_sink().as_deref() == Some(name) {
|
if Self::get_default_sink().as_deref() == Some(name) {
|
||||||
|
|
@ -176,15 +133,6 @@ impl VirtualMic {
|
||||||
thread::sleep(Duration::from_millis(50));
|
thread::sleep(Duration::from_millis(50));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Keep this app's streams (client name "vois") on the `vois.rs` sink and
|
|
||||||
/// every other app's streams off it (parked on `prev_sink`). Runs until
|
|
||||||
/// `stop`.
|
|
||||||
///
|
|
||||||
/// WirePlumber routes ALSA streams to the default sink, so if our output
|
|
||||||
/// node is created before the default is switched, it can end up on the
|
|
||||||
/// speakers; this thread pulls it back onto the virtual mic. Moving
|
|
||||||
/// streams explicitly sticks, so other apps' audio never leaks into it.
|
|
||||||
pub fn keep_others_off(stop: Arc<AtomicBool>, prev_sink: String) -> thread::JoinHandle<()> {
|
pub fn keep_others_off(stop: Arc<AtomicBool>, prev_sink: String) -> thread::JoinHandle<()> {
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
while !stop.load(Ordering::Relaxed) {
|
while !stop.load(Ordering::Relaxed) {
|
||||||
|
|
@ -195,12 +143,10 @@ impl VirtualMic {
|
||||||
for (id, sink, client) in &inputs {
|
for (id, sink, client) in &inputs {
|
||||||
let ours = names.get(client).map(|n| n == "vois").unwrap_or(false);
|
let ours = names.get(client).map(|n| n == "vois").unwrap_or(false);
|
||||||
if ours && sink != &lv {
|
if ours && sink != &lv {
|
||||||
// Our own stream drifted to the speakers.
|
|
||||||
let _ = Command::new("pactl")
|
let _ = Command::new("pactl")
|
||||||
.args(["move-sink-input", id, "vois.rs"])
|
.args(["move-sink-input", id, "vois.rs"])
|
||||||
.output();
|
.output();
|
||||||
} else if !ours && sink == &lv {
|
} else if !ours && sink == &lv {
|
||||||
// Other app leaked into the virtual mic.
|
|
||||||
let _ = Command::new("pactl")
|
let _ = Command::new("pactl")
|
||||||
.args(["move-sink-input", id, &pv])
|
.args(["move-sink-input", id, &pv])
|
||||||
.output();
|
.output();
|
||||||
|
|
@ -212,9 +158,6 @@ impl VirtualMic {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restore the default sink if the process is killed (SIGINT/SIGTERM),
|
|
||||||
/// so the user's audio isn't left pointing at the silent null sink.
|
|
||||||
pub fn restore_on_signal(prev: String) -> thread::JoinHandle<()> {
|
pub fn restore_on_signal(prev: String) -> thread::JoinHandle<()> {
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
let mut signals = match signal_hook::iterator::Signals::new([
|
let mut signals = match signal_hook::iterator::Signals::new([
|
||||||
|
|
@ -231,7 +174,6 @@ impl VirtualMic {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
||||||
fn sink_id_named(name: &str) -> Option<String> {
|
fn sink_id_named(name: &str) -> Option<String> {
|
||||||
let out = Command::new("pactl")
|
let out = Command::new("pactl")
|
||||||
|
|
@ -246,7 +188,6 @@ fn sink_id_named(name: &str) -> Option<String> {
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
||||||
fn client_names() -> Result<std::collections::HashMap<String, String>> {
|
fn client_names() -> Result<std::collections::HashMap<String, String>> {
|
||||||
let out = Command::new("pactl")
|
let out = Command::new("pactl")
|
||||||
|
|
@ -262,7 +203,6 @@ fn client_names() -> Result<std::collections::HashMap<String, String>> {
|
||||||
}
|
}
|
||||||
Ok(map)
|
Ok(map)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
||||||
fn sink_inputs() -> Result<Vec<(String, String, String)>> {
|
fn sink_inputs() -> Result<Vec<(String, String, String)>> {
|
||||||
let out = Command::new("pactl")
|
let out = Command::new("pactl")
|
||||||
|
|
@ -282,31 +222,21 @@ fn sink_inputs() -> Result<Vec<(String, String, String)>> {
|
||||||
}
|
}
|
||||||
Ok(inputs)
|
Ok(inputs)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
||||||
fn has_entry(list: &str, name: &str) -> bool {
|
fn has_entry(list: &str, name: &str) -> bool {
|
||||||
list.lines()
|
list.lines()
|
||||||
.any(|l| l.split_whitespace().nth(1).is_some_and(|n| n == name))
|
.any(|l| l.split_whitespace().nth(1).is_some_and(|n| n == name))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Other platforms: virtual mic is provided externally (VB-CABLE / BlackHole).
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[cfg(not(any(target_os = "linux", target_os = "freebsd")))]
|
#[cfg(not(any(target_os = "linux", target_os = "freebsd")))]
|
||||||
impl VirtualMic {
|
impl VirtualMic {
|
||||||
pub fn setup() -> Result<Option<String>> {
|
pub fn setup() -> Result<Option<String>> {
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_default_sink() -> Option<String> {
|
pub fn get_default_sink() -> Option<String> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_default_sink(_name: &str) {}
|
pub fn set_default_sink(_name: &str) {}
|
||||||
|
|
||||||
pub fn wait_default_sink(_name: &str) {}
|
pub fn wait_default_sink(_name: &str) {}
|
||||||
|
|
||||||
pub fn keep_others_off(stop: Arc<AtomicBool>, _prev_sink: String) -> thread::JoinHandle<()> {
|
pub fn keep_others_off(stop: Arc<AtomicBool>, _prev_sink: String) -> thread::JoinHandle<()> {
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
while !stop.load(Ordering::Relaxed) {
|
while !stop.load(Ordering::Relaxed) {
|
||||||
|
|
@ -314,7 +244,6 @@ impl VirtualMic {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn restore_on_signal(_prev: String) -> thread::JoinHandle<()> {
|
pub fn restore_on_signal(_prev: String) -> thread::JoinHandle<()> {
|
||||||
thread::spawn(|| {})
|
thread::spawn(|| {})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use crate::dsp::chain::Control;
|
use crate::dsp::control::Control;
|
||||||
use crate::dsp::presets::{presets, CustomPresetCfg, Preset};
|
use crate::dsp::preset::{presets, CustomPresetCfg, Preset};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
|
||||||
473
src/dsp/chain.rs
473
src/dsp/chain.rs
|
|
@ -1,473 +0,0 @@
|
||||||
use crate::audio::record;
|
|
||||||
use crate::dsp::effects::Effect;
|
|
||||||
use crate::dsp::formant::ratio_from_semitones;
|
|
||||||
use crate::dsp::gate::NoiseGate;
|
|
||||||
use crate::dsp::pitch::PitchShifter;
|
|
||||||
use crate::dsp::presets::{build_effects, presets};
|
|
||||||
use crate::dsp::stats::Stats;
|
|
||||||
use ringbuf::traits::{Consumer, Producer};
|
|
||||||
use std::sync::mpsc::Sender;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::thread;
|
|
||||||
use std::thread::JoinHandle;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
/// Processing mode, switchable at runtime (A/B compare).
|
|
||||||
#[derive(Clone, Copy, PartialEq)]
|
|
||||||
pub enum Mode {
|
|
||||||
/// Normal processing through the DSP chain.
|
|
||||||
Live,
|
|
||||||
/// Raw input straight to output (bypass) for A/B comparison.
|
|
||||||
Passthrough,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Mode {
|
|
||||||
pub fn toggle(&mut self) {
|
|
||||||
*self = match self {
|
|
||||||
Mode::Live => Mode::Passthrough,
|
|
||||||
Mode::Passthrough => Mode::Live,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Live-adjustable state, shared between the UI and the DSP worker.
|
|
||||||
pub struct Control {
|
|
||||||
pub preset_idx: usize,
|
|
||||||
/// Manual pitch adjustment added to the preset value (semitones).
|
|
||||||
pub pitch_delta: f32,
|
|
||||||
/// Manual formant adjustment added to the preset value (semitones).
|
|
||||||
pub formant_delta: f32,
|
|
||||||
pub gate_threshold_db: f32,
|
|
||||||
pub gain: f32,
|
|
||||||
pub mute: bool,
|
|
||||||
pub record: bool,
|
|
||||||
pub record_path: Option<String>,
|
|
||||||
pub run_seconds: Option<u32>,
|
|
||||||
/// Test tone frequency (Hz) mixed into the input; 0 = off.
|
|
||||||
pub tone_hz: f32,
|
|
||||||
/// Snap the voice to the nearest semitone (Auto-Tune style).
|
|
||||||
pub pitch_correct: bool,
|
|
||||||
pub mode: Mode,
|
|
||||||
pub quit: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Control {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
preset_idx: 0,
|
|
||||||
pitch_delta: 0.0,
|
|
||||||
formant_delta: 0.0,
|
|
||||||
gate_threshold_db: -100.0,
|
|
||||||
gain: 1.0,
|
|
||||||
mute: false,
|
|
||||||
record: false,
|
|
||||||
record_path: None,
|
|
||||||
run_seconds: None,
|
|
||||||
tone_hz: 0.0,
|
|
||||||
pitch_correct: false,
|
|
||||||
mode: Mode::Live,
|
|
||||||
quit: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Processor {
|
|
||||||
fs: u32,
|
|
||||||
control: Arc<Mutex<Control>>,
|
|
||||||
stats: Arc<Mutex<Stats>>,
|
|
||||||
shifter: Option<PitchShifter>,
|
|
||||||
shift_pending: Vec<f32>,
|
|
||||||
/// Smoothed Auto-Tune correction in semitones.
|
|
||||||
correction: f32,
|
|
||||||
gate: NoiseGate,
|
|
||||||
effects: Vec<Box<dyn Effect>>,
|
|
||||||
last_preset: usize,
|
|
||||||
recorder_tx: Option<Sender<Vec<f32>>>,
|
|
||||||
recorder_handle: Option<JoinHandle<()>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Processor {
|
|
||||||
pub fn new(fs: u32, control: Arc<Mutex<Control>>, stats: Arc<Mutex<Stats>>) -> Self {
|
|
||||||
Self {
|
|
||||||
fs,
|
|
||||||
control,
|
|
||||||
stats,
|
|
||||||
shifter: None,
|
|
||||||
shift_pending: Vec::new(),
|
|
||||||
correction: 0.0,
|
|
||||||
gate: NoiseGate::new(-60.0),
|
|
||||||
effects: Vec::new(),
|
|
||||||
last_preset: usize::MAX,
|
|
||||||
recorder_tx: None,
|
|
||||||
recorder_handle: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stop recording and wait for the WAV writer to finalize.
|
|
||||||
fn stop_recording(&mut self) {
|
|
||||||
self.recorder_tx = None;
|
|
||||||
if let Some(handle) = self.recorder_handle.take() {
|
|
||||||
let _ = handle.join();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Process one fixed-size mono block through the whole chain.
|
|
||||||
pub fn process_block(&mut self, mono: &mut [f32]) {
|
|
||||||
if mono.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let t0 = Instant::now();
|
|
||||||
|
|
||||||
// Input level (pre-processing).
|
|
||||||
let mut in_sum = 0.0;
|
|
||||||
for &s in mono.iter() {
|
|
||||||
in_sum += s * s;
|
|
||||||
}
|
|
||||||
let in_rms = (in_sum / mono.len() as f32).sqrt();
|
|
||||||
|
|
||||||
let (
|
|
||||||
preset_idx,
|
|
||||||
pitch,
|
|
||||||
formant,
|
|
||||||
gate_db,
|
|
||||||
gain,
|
|
||||||
preset_gain,
|
|
||||||
mute,
|
|
||||||
record,
|
|
||||||
mode,
|
|
||||||
record_path,
|
|
||||||
pitch_correct,
|
|
||||||
) = {
|
|
||||||
let ctrl = match self.control.lock() {
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(p) => p.into_inner(),
|
|
||||||
};
|
|
||||||
let preset = &presets()[ctrl.preset_idx.min(presets().len() - 1)];
|
|
||||||
(
|
|
||||||
ctrl.preset_idx.min(presets().len() - 1),
|
|
||||||
preset.pitch_semitones + ctrl.pitch_delta,
|
|
||||||
preset.formant_ratio * ratio_from_semitones(ctrl.formant_delta),
|
|
||||||
// User-set gate wins; otherwise fall back to the preset's gate.
|
|
||||||
if ctrl.gate_threshold_db > -90.0 {
|
|
||||||
ctrl.gate_threshold_db
|
|
||||||
} else {
|
|
||||||
preset.gate_db
|
|
||||||
},
|
|
||||||
ctrl.gain,
|
|
||||||
preset.gain,
|
|
||||||
ctrl.mute,
|
|
||||||
ctrl.record,
|
|
||||||
ctrl.mode,
|
|
||||||
ctrl.record_path.clone(),
|
|
||||||
ctrl.pitch_correct,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
if preset_idx != self.last_preset {
|
|
||||||
self.effects = build_effects(&presets()[preset_idx].effects, self.fs);
|
|
||||||
self.last_preset = preset_idx;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut shifter_active = (pitch - 0.0).abs() > 0.05 || (formant - 1.0).abs() > 0.01;
|
|
||||||
shifter_active |= pitch_correct;
|
|
||||||
|
|
||||||
if mute {
|
|
||||||
mono.fill(0.0);
|
|
||||||
} else if mode == Mode::Passthrough {
|
|
||||||
// A/B compare: raw input, only gain.
|
|
||||||
for s in mono.iter_mut() {
|
|
||||||
*s *= gain;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.gate.set_threshold_db(gate_db);
|
|
||||||
self.gate.process(mono);
|
|
||||||
|
|
||||||
if shifter_active {
|
|
||||||
if self.shifter.is_none() {
|
|
||||||
self.shifter = Some(PitchShifter::new(1024, 256, self.fs));
|
|
||||||
// Prime the extraction buffer with the vocoder's fixed latency
|
|
||||||
// (n - hop) so every input block yields a full output block.
|
|
||||||
self.shift_pending = vec![0.0; 1024 - 256];
|
|
||||||
}
|
|
||||||
let shifter = self.shifter.as_mut().unwrap();
|
|
||||||
|
|
||||||
// Auto-Tune: glide the manual pitch toward the nearest semitone
|
|
||||||
// of the detected input fundamental (smoothed so it doesn't
|
|
||||||
// wobble). Detection happens on the input frame, so the
|
|
||||||
// correction applies from the next block onward.
|
|
||||||
if pitch_correct {
|
|
||||||
let f0 = shifter.last_f0;
|
|
||||||
if (70.0..1200.0).contains(&f0) {
|
|
||||||
let current = 12.0 * (f0 / 440.0).log2();
|
|
||||||
let target = current.round() - current;
|
|
||||||
self.correction += (target - self.correction) * 0.25;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.correction = 0.0;
|
|
||||||
}
|
|
||||||
let effective = pitch + self.correction;
|
|
||||||
|
|
||||||
shifter.pitch_ratio = 2f32.powf(effective / 12.0);
|
|
||||||
shifter.formant_ratio = formant;
|
|
||||||
let mut out = Vec::with_capacity(mono.len());
|
|
||||||
shifter.process(mono, &mut out);
|
|
||||||
|
|
||||||
// The vocoder is a streaming delay line: per call it may emit a
|
|
||||||
// different number of samples than it consumed. Keep the excess
|
|
||||||
// in `shift_pending` and pull exactly `mono.len()` samples out,
|
|
||||||
// so the rest of the chain sees constant block sizes.
|
|
||||||
self.shift_pending.extend(out);
|
|
||||||
let take = mono.len().min(self.shift_pending.len());
|
|
||||||
mono[..take].copy_from_slice(&self.shift_pending[..take]);
|
|
||||||
mono[take..].fill(0.0);
|
|
||||||
self.shift_pending.drain(..take);
|
|
||||||
}
|
|
||||||
|
|
||||||
for effect in &mut self.effects {
|
|
||||||
effect.process(mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
for s in mono.iter_mut() {
|
|
||||||
*s *= preset_gain * gain;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if record {
|
|
||||||
if self.recorder_tx.is_none() {
|
|
||||||
if let Some(path) = record_path {
|
|
||||||
let (tx, handle) = record::spawn_writer(path, self.fs);
|
|
||||||
self.recorder_tx = Some(tx);
|
|
||||||
self.recorder_handle = Some(handle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(tx) = &self.recorder_tx {
|
|
||||||
let _ = tx.send(mono.to_vec());
|
|
||||||
}
|
|
||||||
} else if self.recorder_tx.is_some() {
|
|
||||||
self.stop_recording();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Output level + runtime stats.
|
|
||||||
let mut out_sum = 0.0;
|
|
||||||
for &s in mono.iter() {
|
|
||||||
out_sum += s * s;
|
|
||||||
}
|
|
||||||
let out_rms = (out_sum / mono.len() as f32).sqrt();
|
|
||||||
let elapsed = t0.elapsed().as_secs_f32();
|
|
||||||
let block_dur = mono.len() as f32 / self.fs as f32;
|
|
||||||
let cpu = if block_dur > 0.0 {
|
|
||||||
elapsed / block_dur * 100.0
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut st = match self.stats.lock() {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(p) => p.into_inner(),
|
|
||||||
};
|
|
||||||
st.input_rms = st.input_rms * 0.85 + in_rms * 0.15;
|
|
||||||
st.output_rms = st.output_rms * 0.85 + out_rms * 0.15;
|
|
||||||
st.peak_in = st.peak_in.max(in_rms);
|
|
||||||
st.peak_out = st.peak_out.max(out_rms);
|
|
||||||
st.blocks += 1;
|
|
||||||
st.samples += mono.len() as u64;
|
|
||||||
st.cpu_pct = st.cpu_pct * 0.9 + cpu * 0.1;
|
|
||||||
// DSP latency estimate: block buffering + vocoder frame-fill when active.
|
|
||||||
let latency_samples = if shifter_active { 768 } else { 0 } + 256;
|
|
||||||
st.dsp_latency_ms = latency_samples as f32 / self.fs as f32 * 1000.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// DSP worker thread: pulls captured audio, runs the chain, pushes the result
|
|
||||||
/// to the output.
|
|
||||||
pub fn run_worker<I, O>(
|
|
||||||
mut input: I,
|
|
||||||
mut output: O,
|
|
||||||
in_ch: usize,
|
|
||||||
out_ch: usize,
|
|
||||||
fs: u32,
|
|
||||||
control: Arc<Mutex<Control>>,
|
|
||||||
stats: Arc<Mutex<Stats>>,
|
|
||||||
) where
|
|
||||||
I: Consumer<Item = f32> + Send + 'static,
|
|
||||||
O: Producer<Item = f32> + Send + 'static,
|
|
||||||
{
|
|
||||||
let mut processor = Processor::new(fs, control, stats);
|
|
||||||
let mut scratch = vec![0f32; 8192];
|
|
||||||
let mut acc = Vec::with_capacity(8192);
|
|
||||||
let mut block = vec![0f32; 256];
|
|
||||||
let mut out = Vec::with_capacity(8192);
|
|
||||||
let mut total_blocks: u64 = 0;
|
|
||||||
let mut total_samples: u64 = 0;
|
|
||||||
let mut tone_phase: f32 = 0.0;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
if processor.control.lock().map(|c| c.quit).unwrap_or(false) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let n = input.pop_slice(&mut scratch);
|
|
||||||
if n == 0 {
|
|
||||||
thread::sleep(Duration::from_millis(1));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
total_samples += n as u64;
|
|
||||||
|
|
||||||
let frames = n / in_ch.max(1);
|
|
||||||
for f in 0..frames {
|
|
||||||
let mut mix = 0.0;
|
|
||||||
for c in 0..in_ch {
|
|
||||||
mix += scratch[f * in_ch + c];
|
|
||||||
}
|
|
||||||
acc.push(mix / in_ch as f32);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional test tone injected before the DSP chain.
|
|
||||||
let tone_hz = processor.control.lock().map(|c| c.tone_hz).unwrap_or(0.0);
|
|
||||||
if tone_hz > 0.0 {
|
|
||||||
let start = acc.len().saturating_sub(frames);
|
|
||||||
for v in acc.iter_mut().skip(start) {
|
|
||||||
*v += 0.5 * (2.0 * std::f32::consts::PI * tone_phase).sin();
|
|
||||||
tone_phase += tone_hz / fs as f32;
|
|
||||||
if tone_phase >= 1.0 {
|
|
||||||
tone_phase -= 1.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process in fixed-size blocks (multiple of the vocoder hop) so the
|
|
||||||
// pitch shifter always yields a full block. Smaller blocks cut the
|
|
||||||
// worst-case buffering latency; the shifter stream is unchanged.
|
|
||||||
while acc.len() >= 256 {
|
|
||||||
block.copy_from_slice(&acc[..256]);
|
|
||||||
acc.drain(..256);
|
|
||||||
total_blocks += 1;
|
|
||||||
|
|
||||||
processor.process_block(&mut block);
|
|
||||||
|
|
||||||
out.clear();
|
|
||||||
for v in &block {
|
|
||||||
for _ in 0..out_ch {
|
|
||||||
out.push(*v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let _ = output.push_slice(&out);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
eprintln!("dsp: processed {total_blocks} blocks / {total_samples} samples");
|
|
||||||
processor.stop_recording();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn rms(x: &[f32]) -> f32 {
|
|
||||||
let s: f32 = x.iter().map(|v| v * v).sum();
|
|
||||||
(s / x.len().max(1) as f32).sqrt()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn dominant_hz(x: &[f32], fs: u32) -> f32 {
|
|
||||||
use rustfft::num_complex::Complex;
|
|
||||||
use rustfft::FftPlanner;
|
|
||||||
let n = 4096;
|
|
||||||
if x.len() < n {
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
let mut planner = FftPlanner::<f32>::new();
|
|
||||||
let fft = planner.plan_fft_forward(n);
|
|
||||||
let mut buf: Vec<Complex<f32>> = x[..n].iter().map(|&s| Complex::new(s, 0.0)).collect();
|
|
||||||
fft.process(&mut buf);
|
|
||||||
let bins = n / 2;
|
|
||||||
let mags: Vec<f32> = (0..bins).map(|b| buf[b].norm()).collect();
|
|
||||||
let mut best = (0usize, 0.0f32);
|
|
||||||
for b in 20..(bins / 4) {
|
|
||||||
let mut s = 0.0;
|
|
||||||
for h in 1..=4 {
|
|
||||||
if b * h < bins {
|
|
||||||
s += mags[b * h];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if s > best.1 {
|
|
||||||
best = (b, s);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fs as f32 * best.0 as f32 / n as f32
|
|
||||||
}
|
|
||||||
|
|
||||||
fn preset_control(idx: usize) -> Arc<Mutex<Control>> {
|
|
||||||
Arc::new(Mutex::new(Control {
|
|
||||||
preset_idx: idx,
|
|
||||||
..Control::default()
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn girl_preset_produces_energy_on_sine() {
|
|
||||||
// Clean preset + manual pitch delta = pitch-only (no formant shift).
|
|
||||||
let control = Arc::new(Mutex::new(Control {
|
|
||||||
preset_idx: 0,
|
|
||||||
pitch_delta: 5.0,
|
|
||||||
..Control::default()
|
|
||||||
}));
|
|
||||||
let mut p = Processor::new(48000, control, Arc::new(Mutex::new(Stats::default())));
|
|
||||||
// Harmonic-rich tone (closer to voice than a pure sine).
|
|
||||||
let input: Vec<f32> = (0..96000)
|
|
||||||
.map(|i| {
|
|
||||||
let t = 220.0 * i as f32 / 48000.0;
|
|
||||||
let mut s = 0.0;
|
|
||||||
for h in 1..=12 {
|
|
||||||
s += (2.0 * std::f32::consts::PI * h as f32 * t).sin() / h as f32;
|
|
||||||
}
|
|
||||||
s
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let out = feed_blocks(&mut p, &input);
|
|
||||||
let r = rms(&out[4000..]);
|
|
||||||
assert!(r.is_finite() && r > 0.05, "girl sine rms too low: {r}");
|
|
||||||
|
|
||||||
// Pitch must actually shift: 220 Hz * 2^(5/12) ~= 293.7 Hz.
|
|
||||||
let est = dominant_hz(&out[20000..96000], 48000);
|
|
||||||
assert!(
|
|
||||||
(est - 293.7).abs() < 12.0,
|
|
||||||
"girl pitch expected ~293.7 Hz, got {est:.1} Hz"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn girl_preset_produces_energy_on_noise() {
|
|
||||||
let control = preset_control(1);
|
|
||||||
let mut p = Processor::new(48000, control, Arc::new(Mutex::new(Stats::default())));
|
|
||||||
let mut state = 0x12345678u32;
|
|
||||||
let mut input = Vec::new();
|
|
||||||
for _ in 0..96000 {
|
|
||||||
state ^= state << 13;
|
|
||||||
state ^= state >> 17;
|
|
||||||
state ^= state << 5;
|
|
||||||
input.push(state as f32 / u32::MAX as f32 * 2.0 - 1.0);
|
|
||||||
}
|
|
||||||
// Scale to ~mic-hiss level.
|
|
||||||
for v in input.iter_mut() {
|
|
||||||
*v *= 0.01;
|
|
||||||
}
|
|
||||||
let out = feed_blocks(&mut p, &input);
|
|
||||||
let r = rms(&out[4000..]);
|
|
||||||
assert!(r.is_finite() && r > 0.0005, "girl noise rms too low: {r}");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Feed input through the processor in the same fixed-size blocks the
|
|
||||||
/// real worker uses, collecting the output.
|
|
||||||
fn feed_blocks(p: &mut Processor, input: &[f32]) -> Vec<f32> {
|
|
||||||
let mut out_all = Vec::with_capacity(input.len());
|
|
||||||
for chunk in input.chunks(256) {
|
|
||||||
let mut block = chunk.to_vec();
|
|
||||||
block.resize(256, 0.0);
|
|
||||||
p.process_block(&mut block);
|
|
||||||
out_all.extend_from_slice(&block[..chunk.len()]);
|
|
||||||
}
|
|
||||||
out_all
|
|
||||||
}
|
|
||||||
}
|
|
||||||
230
src/dsp/chain/mod.rs
Normal file
230
src/dsp/chain/mod.rs
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
//! The DSP chain: one mono block through gate -> pitch/formant -> effects.
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
pub mod worker;
|
||||||
|
|
||||||
|
use crate::audio::record;
|
||||||
|
use crate::dsp::control::{Control, Mode, Stats};
|
||||||
|
use crate::dsp::formant::ratio_from_semitones;
|
||||||
|
use crate::dsp::fx::filter::NoiseGate;
|
||||||
|
use crate::dsp::fx::Effect;
|
||||||
|
use crate::dsp::preset::{build_effects, presets};
|
||||||
|
use crate::dsp::shift::PitchShifter;
|
||||||
|
use std::sync::mpsc::Sender;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::thread::JoinHandle;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
pub struct Processor {
|
||||||
|
fs: u32,
|
||||||
|
control: Arc<Mutex<Control>>,
|
||||||
|
stats: Arc<Mutex<Stats>>,
|
||||||
|
shifter: Option<PitchShifter>,
|
||||||
|
shift_pending: Vec<f32>,
|
||||||
|
/// Smoothed Auto-Tune correction in semitones.
|
||||||
|
correction: f32,
|
||||||
|
gate: NoiseGate,
|
||||||
|
effects: Vec<Box<dyn Effect>>,
|
||||||
|
last_preset: usize,
|
||||||
|
recorder_tx: Option<Sender<Vec<f32>>>,
|
||||||
|
recorder_handle: Option<JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Processor {
|
||||||
|
pub fn new(fs: u32, control: Arc<Mutex<Control>>, stats: Arc<Mutex<Stats>>) -> Self {
|
||||||
|
Self {
|
||||||
|
fs,
|
||||||
|
control,
|
||||||
|
stats,
|
||||||
|
shifter: None,
|
||||||
|
shift_pending: Vec::new(),
|
||||||
|
correction: 0.0,
|
||||||
|
gate: NoiseGate::new(-60.0),
|
||||||
|
effects: Vec::new(),
|
||||||
|
last_preset: usize::MAX,
|
||||||
|
recorder_tx: None,
|
||||||
|
recorder_handle: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop recording and wait for the WAV writer to finalize.
|
||||||
|
pub(crate) fn stop_recording(&mut self) {
|
||||||
|
self.recorder_tx = None;
|
||||||
|
if let Some(handle) = self.recorder_handle.take() {
|
||||||
|
let _ = handle.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process one fixed-size mono block through the whole chain.
|
||||||
|
pub fn process_block(&mut self, mono: &mut [f32]) {
|
||||||
|
if mono.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let t0 = Instant::now();
|
||||||
|
|
||||||
|
let mut in_sum = 0.0;
|
||||||
|
for &s in mono.iter() {
|
||||||
|
in_sum += s * s;
|
||||||
|
}
|
||||||
|
let in_rms = (in_sum / mono.len() as f32).sqrt();
|
||||||
|
|
||||||
|
let (
|
||||||
|
preset_idx,
|
||||||
|
pitch,
|
||||||
|
formant,
|
||||||
|
gate_db,
|
||||||
|
gain,
|
||||||
|
preset_gain,
|
||||||
|
mute,
|
||||||
|
record,
|
||||||
|
mode,
|
||||||
|
record_path,
|
||||||
|
pitch_correct,
|
||||||
|
) = {
|
||||||
|
let ctrl = match self.control.lock() {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(p) => p.into_inner(),
|
||||||
|
};
|
||||||
|
let preset = &presets()[ctrl.preset_idx.min(presets().len() - 1)];
|
||||||
|
(
|
||||||
|
ctrl.preset_idx.min(presets().len() - 1),
|
||||||
|
preset.pitch_semitones + ctrl.pitch_delta,
|
||||||
|
preset.formant_ratio * ratio_from_semitones(ctrl.formant_delta),
|
||||||
|
// User-set gate wins; otherwise fall back to the preset's gate.
|
||||||
|
if ctrl.gate_threshold_db > -90.0 {
|
||||||
|
ctrl.gate_threshold_db
|
||||||
|
} else {
|
||||||
|
preset.gate_db
|
||||||
|
},
|
||||||
|
ctrl.gain,
|
||||||
|
preset.gain,
|
||||||
|
ctrl.mute,
|
||||||
|
ctrl.record,
|
||||||
|
ctrl.mode,
|
||||||
|
ctrl.record_path.clone(),
|
||||||
|
ctrl.pitch_correct,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
if preset_idx != self.last_preset {
|
||||||
|
self.effects = build_effects(&presets()[preset_idx].effects, self.fs);
|
||||||
|
self.last_preset = preset_idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut shifter_active = (pitch - 0.0).abs() > 0.05 || (formant - 1.0).abs() > 0.01;
|
||||||
|
shifter_active |= pitch_correct;
|
||||||
|
|
||||||
|
if mute {
|
||||||
|
mono.fill(0.0);
|
||||||
|
} else if mode == Mode::Passthrough {
|
||||||
|
for s in mono.iter_mut() {
|
||||||
|
*s *= gain;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.gate.set_threshold_db(gate_db);
|
||||||
|
self.gate.process(mono);
|
||||||
|
|
||||||
|
if shifter_active {
|
||||||
|
self.shift(mono, pitch, formant, pitch_correct);
|
||||||
|
}
|
||||||
|
|
||||||
|
for effect in &mut self.effects {
|
||||||
|
effect.process(mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
for s in mono.iter_mut() {
|
||||||
|
*s *= preset_gain * gain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.handle_record(mono, record, record_path);
|
||||||
|
self.update_stats(mono, in_rms, t0, shifter_active);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pitch/formant shift with streaming OLA extraction.
|
||||||
|
fn shift(&mut self, mono: &mut [f32], pitch: f32, formant: f32, pitch_correct: bool) {
|
||||||
|
if self.shifter.is_none() {
|
||||||
|
self.shifter = Some(PitchShifter::new(1024, 256, self.fs));
|
||||||
|
// Prime with the vocoder's fixed latency so every input block
|
||||||
|
// yields a full output block.
|
||||||
|
self.shift_pending = vec![0.0; 1024 - 256];
|
||||||
|
}
|
||||||
|
let shifter = self.shifter.as_mut().unwrap();
|
||||||
|
|
||||||
|
// Auto-Tune: glide the manual pitch toward the nearest semitone of
|
||||||
|
// the detected input fundamental (smoothed so it doesn't wobble).
|
||||||
|
if pitch_correct {
|
||||||
|
let f0 = shifter.last_f0;
|
||||||
|
if (70.0..1200.0).contains(&f0) {
|
||||||
|
let current = 12.0 * (f0 / 440.0).log2();
|
||||||
|
let target = current.round() - current;
|
||||||
|
self.correction += (target - self.correction) * 0.25;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.correction = 0.0;
|
||||||
|
}
|
||||||
|
let effective = pitch + self.correction;
|
||||||
|
|
||||||
|
shifter.pitch_ratio = 2f32.powf(effective / 12.0);
|
||||||
|
shifter.formant_ratio = formant;
|
||||||
|
let mut out = Vec::with_capacity(mono.len());
|
||||||
|
shifter.process(mono, &mut out);
|
||||||
|
|
||||||
|
// The vocoder is a streaming delay line: it may emit a different
|
||||||
|
// number of samples than consumed. Buffer the excess and pull exactly
|
||||||
|
// `mono.len()` samples out.
|
||||||
|
self.shift_pending.extend(out);
|
||||||
|
let take = mono.len().min(self.shift_pending.len());
|
||||||
|
mono[..take].copy_from_slice(&self.shift_pending[..take]);
|
||||||
|
mono[take..].fill(0.0);
|
||||||
|
self.shift_pending.drain(..take);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_record(&mut self, mono: &[f32], record: bool, record_path: Option<String>) {
|
||||||
|
if record {
|
||||||
|
if self.recorder_tx.is_none() {
|
||||||
|
if let Some(path) = record_path {
|
||||||
|
let (tx, handle) = record::spawn_writer(path, self.fs);
|
||||||
|
self.recorder_tx = Some(tx);
|
||||||
|
self.recorder_handle = Some(handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(tx) = &self.recorder_tx {
|
||||||
|
let _ = tx.send(mono.to_vec());
|
||||||
|
}
|
||||||
|
} else if self.recorder_tx.is_some() {
|
||||||
|
self.stop_recording();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_stats(&mut self, mono: &[f32], in_rms: f32, t0: Instant, shifter_active: bool) {
|
||||||
|
let mut out_sum = 0.0;
|
||||||
|
for &s in mono.iter() {
|
||||||
|
out_sum += s * s;
|
||||||
|
}
|
||||||
|
let out_rms = (out_sum / mono.len() as f32).sqrt();
|
||||||
|
let elapsed = t0.elapsed().as_secs_f32();
|
||||||
|
let block_dur = mono.len() as f32 / self.fs as f32;
|
||||||
|
let cpu = if block_dur > 0.0 {
|
||||||
|
elapsed / block_dur * 100.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut st = match self.stats.lock() {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(p) => p.into_inner(),
|
||||||
|
};
|
||||||
|
st.input_rms = st.input_rms * 0.85 + in_rms * 0.15;
|
||||||
|
st.output_rms = st.output_rms * 0.85 + out_rms * 0.15;
|
||||||
|
st.peak_in = st.peak_in.max(in_rms);
|
||||||
|
st.peak_out = st.peak_out.max(out_rms);
|
||||||
|
st.blocks += 1;
|
||||||
|
st.samples += mono.len() as u64;
|
||||||
|
st.cpu_pct = st.cpu_pct * 0.9 + cpu * 0.1;
|
||||||
|
// DSP latency estimate: block buffering + vocoder frame-fill.
|
||||||
|
let latency_samples = if shifter_active { 768 } else { 0 } + 256;
|
||||||
|
st.dsp_latency_ms = latency_samples as f32 / self.fs as f32 * 1000.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
101
src/dsp/chain/tests.rs
Normal file
101
src/dsp/chain/tests.rs
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
use super::Processor;
|
||||||
|
use crate::dsp::control::{Control, Stats};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
fn rms(x: &[f32]) -> f32 {
|
||||||
|
let s: f32 = x.iter().map(|v| v * v).sum();
|
||||||
|
(s / x.len().max(1) as f32).sqrt()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dominant_hz(x: &[f32], fs: u32) -> f32 {
|
||||||
|
use rustfft::num_complex::Complex;
|
||||||
|
use rustfft::FftPlanner;
|
||||||
|
let n = 4096;
|
||||||
|
if x.len() < n {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let mut planner = FftPlanner::<f32>::new();
|
||||||
|
let fft = planner.plan_fft_forward(n);
|
||||||
|
let mut buf: Vec<Complex<f32>> = x[..n].iter().map(|&s| Complex::new(s, 0.0)).collect();
|
||||||
|
fft.process(&mut buf);
|
||||||
|
let bins = n / 2;
|
||||||
|
let mags: Vec<f32> = (0..bins).map(|b| buf[b].norm()).collect();
|
||||||
|
let mut best = (0usize, 0.0f32);
|
||||||
|
for b in 20..(bins / 4) {
|
||||||
|
let mut s = 0.0;
|
||||||
|
for h in 1..=4 {
|
||||||
|
if b * h < bins {
|
||||||
|
s += mags[b * h];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s > best.1 {
|
||||||
|
best = (b, s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fs as f32 * best.0 as f32 / n as f32
|
||||||
|
}
|
||||||
|
|
||||||
|
fn feed_blocks(p: &mut Processor, input: &[f32]) -> Vec<f32> {
|
||||||
|
let mut out_all = Vec::with_capacity(input.len());
|
||||||
|
for chunk in input.chunks(256) {
|
||||||
|
let mut block = chunk.to_vec();
|
||||||
|
block.resize(256, 0.0);
|
||||||
|
p.process_block(&mut block);
|
||||||
|
out_all.extend_from_slice(&block[..chunk.len()]);
|
||||||
|
}
|
||||||
|
out_all
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn girl_preset_produces_energy_on_sine() {
|
||||||
|
let control = Arc::new(Mutex::new(Control {
|
||||||
|
preset_idx: 0,
|
||||||
|
pitch_delta: 5.0,
|
||||||
|
..Control::default()
|
||||||
|
}));
|
||||||
|
let mut p = Processor::new(48000, control, Arc::new(Mutex::new(Stats::default())));
|
||||||
|
// Harmonic-rich tone (closer to voice than a pure sine).
|
||||||
|
let input: Vec<f32> = (0..96000)
|
||||||
|
.map(|i| {
|
||||||
|
let t = 220.0 * i as f32 / 48000.0;
|
||||||
|
let mut s = 0.0;
|
||||||
|
for h in 1..=12 {
|
||||||
|
s += (2.0 * std::f32::consts::PI * h as f32 * t).sin() / h as f32;
|
||||||
|
}
|
||||||
|
s
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let out = feed_blocks(&mut p, &input);
|
||||||
|
let r = rms(&out[4000..]);
|
||||||
|
assert!(r.is_finite() && r > 0.05, "girl sine rms too low: {r}");
|
||||||
|
|
||||||
|
// Pitch must actually shift: 220 Hz * 2^(5/12) ~= 293.7 Hz.
|
||||||
|
let est = dominant_hz(&out[20000..96000], 48000);
|
||||||
|
assert!(
|
||||||
|
(est - 293.7).abs() < 12.0,
|
||||||
|
"girl pitch expected ~293.7 Hz, got {est:.1} Hz"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn girl_preset_produces_energy_on_noise() {
|
||||||
|
let control = Arc::new(Mutex::new(Control {
|
||||||
|
preset_idx: 1,
|
||||||
|
..Control::default()
|
||||||
|
}));
|
||||||
|
let mut p = Processor::new(48000, control, Arc::new(Mutex::new(Stats::default())));
|
||||||
|
let mut state = 0x12345678u32;
|
||||||
|
let mut input = Vec::new();
|
||||||
|
for _ in 0..96000 {
|
||||||
|
state ^= state << 13;
|
||||||
|
state ^= state >> 17;
|
||||||
|
state ^= state << 5;
|
||||||
|
input.push(state as f32 / u32::MAX as f32 * 2.0 - 1.0);
|
||||||
|
}
|
||||||
|
for v in input.iter_mut() {
|
||||||
|
*v *= 0.01;
|
||||||
|
}
|
||||||
|
let out = feed_blocks(&mut p, &input);
|
||||||
|
let r = rms(&out[4000..]);
|
||||||
|
assert!(r.is_finite() && r > 0.0005, "girl noise rms too low: {r}");
|
||||||
|
}
|
||||||
86
src/dsp/chain/worker.rs
Normal file
86
src/dsp/chain/worker.rs
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
//! The DSP worker thread: pulls captured audio, runs the chain, pushes output.
|
||||||
|
|
||||||
|
use super::Processor;
|
||||||
|
use crate::dsp::control::{Control, Stats};
|
||||||
|
use ringbuf::traits::{Consumer, Producer};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
pub fn run_worker<I, O>(
|
||||||
|
mut input: I,
|
||||||
|
mut output: O,
|
||||||
|
in_ch: usize,
|
||||||
|
out_ch: usize,
|
||||||
|
fs: u32,
|
||||||
|
control: Arc<Mutex<Control>>,
|
||||||
|
stats: Arc<Mutex<Stats>>,
|
||||||
|
) where
|
||||||
|
I: Consumer<Item = f32> + Send + 'static,
|
||||||
|
O: Producer<Item = f32> + Send + 'static,
|
||||||
|
{
|
||||||
|
let mut processor = Processor::new(fs, control, stats);
|
||||||
|
let mut scratch = vec![0f32; 8192];
|
||||||
|
let mut acc = Vec::with_capacity(8192);
|
||||||
|
let mut block = vec![0f32; 256];
|
||||||
|
let mut out = Vec::with_capacity(8192);
|
||||||
|
let mut total_blocks: u64 = 0;
|
||||||
|
let mut total_samples: u64 = 0;
|
||||||
|
let mut tone_phase: f32 = 0.0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if processor.control.lock().map(|c| c.quit).unwrap_or(false) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let n = input.pop_slice(&mut scratch);
|
||||||
|
if n == 0 {
|
||||||
|
thread::sleep(Duration::from_millis(1));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
total_samples += n as u64;
|
||||||
|
|
||||||
|
let frames = n / in_ch.max(1);
|
||||||
|
for f in 0..frames {
|
||||||
|
let mut mix = 0.0;
|
||||||
|
for c in 0..in_ch {
|
||||||
|
mix += scratch[f * in_ch + c];
|
||||||
|
}
|
||||||
|
acc.push(mix / in_ch as f32);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional test tone injected before the DSP chain.
|
||||||
|
let tone_hz = processor.control.lock().map(|c| c.tone_hz).unwrap_or(0.0);
|
||||||
|
if tone_hz > 0.0 {
|
||||||
|
let start = acc.len().saturating_sub(frames);
|
||||||
|
for v in acc.iter_mut().skip(start) {
|
||||||
|
*v += 0.5 * (2.0 * std::f32::consts::PI * tone_phase).sin();
|
||||||
|
tone_phase += tone_hz / fs as f32;
|
||||||
|
if tone_phase >= 1.0 {
|
||||||
|
tone_phase -= 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process in fixed-size blocks (multiple of the vocoder hop) so the
|
||||||
|
// pitch shifter always yields a full block.
|
||||||
|
while acc.len() >= 256 {
|
||||||
|
block.copy_from_slice(&acc[..256]);
|
||||||
|
acc.drain(..256);
|
||||||
|
total_blocks += 1;
|
||||||
|
|
||||||
|
processor.process_block(&mut block);
|
||||||
|
|
||||||
|
out.clear();
|
||||||
|
for v in &block {
|
||||||
|
for _ in 0..out_ch {
|
||||||
|
out.push(*v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = output.push_slice(&out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!("dsp: processed {total_blocks} blocks / {total_samples} samples");
|
||||||
|
processor.stop_recording();
|
||||||
|
}
|
||||||
95
src/dsp/control.rs
Normal file
95
src/dsp/control.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
//! Shared runtime state: UI control knobs and DSP statistics.
|
||||||
|
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
/// Processing mode, switchable at runtime (A/B compare).
|
||||||
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
|
pub enum Mode {
|
||||||
|
/// Normal processing through the DSP chain.
|
||||||
|
Live,
|
||||||
|
/// Raw input straight to output (bypass) for A/B comparison.
|
||||||
|
Passthrough,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Mode {
|
||||||
|
pub fn toggle(&mut self) {
|
||||||
|
*self = match self {
|
||||||
|
Mode::Live => Mode::Passthrough,
|
||||||
|
Mode::Passthrough => Mode::Live,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live-adjustable state, shared between the UI and the DSP worker.
|
||||||
|
pub struct Control {
|
||||||
|
pub preset_idx: usize,
|
||||||
|
/// Manual pitch adjustment added to the preset value (semitones).
|
||||||
|
pub pitch_delta: f32,
|
||||||
|
/// Manual formant adjustment added to the preset value (semitones).
|
||||||
|
pub formant_delta: f32,
|
||||||
|
pub gate_threshold_db: f32,
|
||||||
|
pub gain: f32,
|
||||||
|
pub mute: bool,
|
||||||
|
pub record: bool,
|
||||||
|
pub record_path: Option<String>,
|
||||||
|
pub run_seconds: Option<u32>,
|
||||||
|
/// Test tone frequency (Hz) mixed into the input; 0 = off.
|
||||||
|
pub tone_hz: f32,
|
||||||
|
/// Snap the voice to the nearest semitone (Auto-Tune style).
|
||||||
|
pub pitch_correct: bool,
|
||||||
|
pub mode: Mode,
|
||||||
|
pub quit: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Control {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
preset_idx: 0,
|
||||||
|
pitch_delta: 0.0,
|
||||||
|
formant_delta: 0.0,
|
||||||
|
gate_threshold_db: -100.0,
|
||||||
|
gain: 1.0,
|
||||||
|
mute: false,
|
||||||
|
record: false,
|
||||||
|
record_path: None,
|
||||||
|
run_seconds: None,
|
||||||
|
tone_hz: 0.0,
|
||||||
|
pitch_correct: false,
|
||||||
|
mode: Mode::Live,
|
||||||
|
quit: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runtime statistics produced by the DSP worker and shown in the TUI.
|
||||||
|
pub struct Stats {
|
||||||
|
/// Smoothed input level (RMS, linear).
|
||||||
|
pub input_rms: f32,
|
||||||
|
/// Smoothed output level (RMS, linear).
|
||||||
|
pub output_rms: f32,
|
||||||
|
pub peak_in: f32,
|
||||||
|
pub peak_out: f32,
|
||||||
|
pub blocks: u64,
|
||||||
|
pub samples: u64,
|
||||||
|
/// Smoothed DSP load in percent of one core.
|
||||||
|
pub cpu_pct: f32,
|
||||||
|
/// Estimated DSP latency in milliseconds.
|
||||||
|
pub dsp_latency_ms: f32,
|
||||||
|
pub started: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Stats {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
input_rms: 0.0,
|
||||||
|
output_rms: 0.0,
|
||||||
|
peak_in: 0.0,
|
||||||
|
peak_out: 0.0,
|
||||||
|
blocks: 0,
|
||||||
|
samples: 0,
|
||||||
|
cpu_pct: 0.0,
|
||||||
|
dsp_latency_ms: 0.0,
|
||||||
|
started: Instant::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,750 +0,0 @@
|
||||||
//! Effects chain: small processing units applied to the mono stream after
|
|
||||||
//! pitch/formant shifting. Each effect mutates the block in place.
|
|
||||||
|
|
||||||
use std::f32::consts::PI;
|
|
||||||
|
|
||||||
pub trait Effect: Send {
|
|
||||||
fn process(&mut self, mono: &mut [f32]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Distortion (waveshaper)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
pub struct Distortion {
|
|
||||||
drive: f32,
|
|
||||||
output: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Distortion {
|
|
||||||
pub fn new(drive: f32, output: f32) -> Self {
|
|
||||||
Self { drive, output }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Effect for Distortion {
|
|
||||||
fn process(&mut self, mono: &mut [f32]) {
|
|
||||||
for s in mono.iter_mut() {
|
|
||||||
*s = (*s * self.drive).tanh() * self.output;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Bitcrusher (bit depth reduction + sample rate reduction)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
pub struct Bitcrush {
|
|
||||||
bits: u8,
|
|
||||||
sample_rate_div: u32,
|
|
||||||
/// Max boost applied to quiet input (auto-level normalization).
|
|
||||||
max_gain: f32,
|
|
||||||
/// Target peak level after normalization.
|
|
||||||
target: f32,
|
|
||||||
gain_smooth: f32,
|
|
||||||
hold: f32,
|
|
||||||
count: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Bitcrush {
|
|
||||||
/// `max_gain`/`target` normalize each block before quantization so the
|
|
||||||
/// effect works on quiet mics without clipping loud ones.
|
|
||||||
pub fn new(bits: u8, sample_rate_div: u32, max_gain: f32, target: f32) -> Self {
|
|
||||||
Self {
|
|
||||||
bits: bits.max(2),
|
|
||||||
sample_rate_div: sample_rate_div.max(1),
|
|
||||||
max_gain: max_gain.max(1.0),
|
|
||||||
target: target.clamp(0.1, 1.0),
|
|
||||||
gain_smooth: 1.0,
|
|
||||||
hold: 0.0,
|
|
||||||
count: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Effect for Bitcrush {
|
|
||||||
fn process(&mut self, mono: &mut [f32]) {
|
|
||||||
if mono.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let levels = (1i32 << (self.bits - 1)) as f32;
|
|
||||||
|
|
||||||
// Auto-level: bring the block peak up to `target` (with smoothing).
|
|
||||||
let peak = mono.iter().fold(0.0f32, |m, s| m.max(s.abs()));
|
|
||||||
let gain = if peak > 1e-6 {
|
|
||||||
(self.target / peak).clamp(1.0, self.max_gain)
|
|
||||||
} else {
|
|
||||||
self.max_gain
|
|
||||||
};
|
|
||||||
self.gain_smooth += (gain - self.gain_smooth) * 0.3;
|
|
||||||
let g = self.gain_smooth;
|
|
||||||
|
|
||||||
for s in mono.iter_mut() {
|
|
||||||
self.count = (self.count + 1) % self.sample_rate_div;
|
|
||||||
if self.count == 0 {
|
|
||||||
self.hold = *s;
|
|
||||||
}
|
|
||||||
let q = (self.hold * g * levels).round();
|
|
||||||
*s = (q / levels).clamp(-1.0, 1.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Freeverb-style reverb
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
struct Comb {
|
|
||||||
buf: Vec<f32>,
|
|
||||||
idx: usize,
|
|
||||||
filter: f32,
|
|
||||||
feedback: f32,
|
|
||||||
damp1: f32,
|
|
||||||
damp2: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Comb {
|
|
||||||
fn new(len: usize) -> Self {
|
|
||||||
Self {
|
|
||||||
buf: vec![0.0; len],
|
|
||||||
idx: 0,
|
|
||||||
filter: 0.0,
|
|
||||||
feedback: 0.84,
|
|
||||||
damp1: 0.5,
|
|
||||||
damp2: 0.5,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn process(&mut self, input: f32) -> f32 {
|
|
||||||
let output = self.buf[self.idx];
|
|
||||||
self.filter = output * self.damp2 + self.filter * self.damp1;
|
|
||||||
self.buf[self.idx] = input + self.filter * self.feedback;
|
|
||||||
self.idx += 1;
|
|
||||||
if self.idx >= self.buf.len() {
|
|
||||||
self.idx = 0;
|
|
||||||
}
|
|
||||||
output
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Allpass {
|
|
||||||
buf: Vec<f32>,
|
|
||||||
idx: usize,
|
|
||||||
feedback: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Allpass {
|
|
||||||
fn new(len: usize) -> Self {
|
|
||||||
Self {
|
|
||||||
buf: vec![0.0; len],
|
|
||||||
idx: 0,
|
|
||||||
feedback: 0.5,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn process(&mut self, input: f32) -> f32 {
|
|
||||||
let bufout = self.buf[self.idx];
|
|
||||||
self.buf[self.idx] = input + bufout * self.feedback;
|
|
||||||
self.idx += 1;
|
|
||||||
if self.idx >= self.buf.len() {
|
|
||||||
self.idx = 0;
|
|
||||||
}
|
|
||||||
bufout - input
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Reverb {
|
|
||||||
combs: Vec<Comb>,
|
|
||||||
allpasses: Vec<Allpass>,
|
|
||||||
wet: f32,
|
|
||||||
room: f32,
|
|
||||||
damp: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Reverb {
|
|
||||||
pub fn new(sample_rate: u32, room: f32, damp: f32, wet: f32) -> Self {
|
|
||||||
let scale = sample_rate as f32 / 44100.0;
|
|
||||||
let comb_lens = [1116, 1188, 1277, 1356, 1422, 1491, 1557, 1617];
|
|
||||||
let allpass_lens = [556, 441, 341, 225];
|
|
||||||
|
|
||||||
let combs: Vec<Comb> = comb_lens
|
|
||||||
.iter()
|
|
||||||
.map(|&l| Comb::new((l as f32 * scale) as usize))
|
|
||||||
.collect();
|
|
||||||
let allpasses: Vec<Allpass> = allpass_lens
|
|
||||||
.iter()
|
|
||||||
.map(|&l| Allpass::new((l as f32 * scale) as usize))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let mut reverb = Self {
|
|
||||||
combs,
|
|
||||||
allpasses,
|
|
||||||
wet: wet.clamp(0.0, 1.0),
|
|
||||||
room: room.clamp(0.0, 1.0),
|
|
||||||
damp: damp.clamp(0.0, 1.0),
|
|
||||||
};
|
|
||||||
reverb.apply_params();
|
|
||||||
reverb
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_params(&mut self) {
|
|
||||||
// Map room size 0..1 to feedback 0..~0.98.
|
|
||||||
let feedback = self.room * 0.88 + 0.10;
|
|
||||||
for comb in &mut self.combs {
|
|
||||||
comb.feedback = feedback;
|
|
||||||
comb.damp1 = self.damp;
|
|
||||||
comb.damp2 = 1.0 - self.damp;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Effect for Reverb {
|
|
||||||
fn process(&mut self, mono: &mut [f32]) {
|
|
||||||
let wet = self.wet;
|
|
||||||
let dry = 1.0 - wet;
|
|
||||||
for s in mono.iter_mut() {
|
|
||||||
let input = *s;
|
|
||||||
let mut acc = 0.0;
|
|
||||||
for comb in &mut self.combs {
|
|
||||||
acc += comb.process(input);
|
|
||||||
}
|
|
||||||
acc /= self.combs.len() as f32;
|
|
||||||
for ap in &mut self.allpasses {
|
|
||||||
acc = ap.process(acc);
|
|
||||||
}
|
|
||||||
*s = input * dry + acc * wet * 1.5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Chorus
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
pub struct Chorus {
|
|
||||||
delay: Vec<f32>,
|
|
||||||
idx: usize,
|
|
||||||
cap: usize,
|
|
||||||
depth: f32,
|
|
||||||
rate: f32,
|
|
||||||
wet: f32,
|
|
||||||
phase: f32,
|
|
||||||
fs: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Chorus {
|
|
||||||
pub fn new(sample_rate: u32, depth_ms: f32, rate_hz: f32, wet: f32) -> Self {
|
|
||||||
let depth = depth_ms / 1000.0 * sample_rate as f32;
|
|
||||||
let cap = (sample_rate as f32 * 0.1) as usize + (depth * 4.0) as usize;
|
|
||||||
Self {
|
|
||||||
delay: vec![0.0; cap],
|
|
||||||
idx: 0,
|
|
||||||
cap,
|
|
||||||
depth: depth.max(1.0),
|
|
||||||
rate: rate_hz,
|
|
||||||
wet: wet.clamp(0.0, 1.0),
|
|
||||||
phase: 0.0,
|
|
||||||
fs: sample_rate as f32,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn read(&self, offset: f32) -> f32 {
|
|
||||||
let mut pos = self.idx as f32 - offset;
|
|
||||||
if pos < 0.0 {
|
|
||||||
pos += self.cap as f32;
|
|
||||||
}
|
|
||||||
let i = pos.floor() as usize;
|
|
||||||
let j = (i + 1) % self.cap;
|
|
||||||
let t = pos - i as f32;
|
|
||||||
self.delay[i] * (1.0 - t) + self.delay[j] * t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Effect for Chorus {
|
|
||||||
fn process(&mut self, mono: &mut [f32]) {
|
|
||||||
let depth = self.depth;
|
|
||||||
let rate = self.rate;
|
|
||||||
let wet = self.wet;
|
|
||||||
let dry = 1.0 - wet;
|
|
||||||
let cap = self.cap as f32;
|
|
||||||
|
|
||||||
for s in mono.iter_mut() {
|
|
||||||
self.delay[self.idx] = *s;
|
|
||||||
let lfo = (2.0 * PI * self.phase).sin();
|
|
||||||
|
|
||||||
// Two slightly dephased voices.
|
|
||||||
let d1 = depth * (0.35 + 0.35 * lfo);
|
|
||||||
let d2 = depth * (0.35 - 0.35 * lfo);
|
|
||||||
let v1 = self.read(d1);
|
|
||||||
let v2 = self.read(d2);
|
|
||||||
|
|
||||||
*s = *s * dry + (v1 + v2) * 0.5 * wet;
|
|
||||||
|
|
||||||
self.idx += 1;
|
|
||||||
if self.idx as f32 >= cap {
|
|
||||||
self.idx = 0;
|
|
||||||
}
|
|
||||||
self.phase += rate / self.fs;
|
|
||||||
if self.phase >= 1.0 {
|
|
||||||
self.phase -= 1.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Ring modulator
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
pub struct RingMod {
|
|
||||||
freq: f32,
|
|
||||||
depth: f32,
|
|
||||||
phase: f32,
|
|
||||||
fs: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RingMod {
|
|
||||||
pub fn new(sample_rate: u32, freq_hz: f32, depth: f32) -> Self {
|
|
||||||
Self {
|
|
||||||
freq: freq_hz,
|
|
||||||
depth: depth.clamp(0.0, 1.0),
|
|
||||||
phase: 0.0,
|
|
||||||
fs: sample_rate as f32,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Effect for RingMod {
|
|
||||||
fn process(&mut self, mono: &mut [f32]) {
|
|
||||||
let depth = self.depth;
|
|
||||||
for s in mono.iter_mut() {
|
|
||||||
let m = 1.0 - depth + depth * (2.0 * PI * self.phase).sin();
|
|
||||||
*s *= m;
|
|
||||||
self.phase += self.freq / self.fs;
|
|
||||||
if self.phase >= 1.0 {
|
|
||||||
self.phase -= 1.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Biquad filters (RBJ cookbook) for bandpass / narrow EQ
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
pub struct Biquad {
|
|
||||||
b0: f32,
|
|
||||||
b1: f32,
|
|
||||||
b2: f32,
|
|
||||||
a1: f32,
|
|
||||||
a2: f32,
|
|
||||||
z1: f32,
|
|
||||||
z2: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Biquad {
|
|
||||||
/// Constant-skirt bandpass.
|
|
||||||
pub fn bandpass(sample_rate: u32, f0: f32, q: f32) -> Self {
|
|
||||||
let w0 = 2.0 * PI * f0 / sample_rate as f32;
|
|
||||||
let alpha = w0.sin() / (2.0 * q);
|
|
||||||
let b0 = alpha;
|
|
||||||
let b1 = 0.0;
|
|
||||||
let b2 = -alpha;
|
|
||||||
let a0 = 1.0 + alpha;
|
|
||||||
let a1 = -2.0 * w0.cos();
|
|
||||||
let a2 = 1.0 - alpha;
|
|
||||||
Self::normalized(b0, b1, b2, a1, a2, a0)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalized(b0: f32, b1: f32, b2: f32, a1: f32, a2: f32, a0: f32) -> Self {
|
|
||||||
Self {
|
|
||||||
b0: b0 / a0,
|
|
||||||
b1: b1 / a0,
|
|
||||||
b2: b2 / a0,
|
|
||||||
a1: a1 / a0,
|
|
||||||
a2: a2 / a0,
|
|
||||||
z1: 0.0,
|
|
||||||
z2: 0.0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
pub fn tick(&mut self, x: f32) -> f32 {
|
|
||||||
let y = self.b0 * x + self.z1;
|
|
||||||
self.z1 = self.b1 * x - self.a1 * y + self.z2;
|
|
||||||
self.z2 = self.b2 * x - self.a2 * y;
|
|
||||||
y
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Bandpass {
|
|
||||||
filter: Biquad,
|
|
||||||
mix: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Bandpass {
|
|
||||||
pub fn new(sample_rate: u32, low_hz: f32, high_hz: f32, mix: f32) -> Self {
|
|
||||||
let f0 = (low_hz * high_hz).sqrt();
|
|
||||||
let q = f0 / (high_hz - low_hz).max(1.0);
|
|
||||||
Self {
|
|
||||||
filter: Biquad::bandpass(sample_rate, f0, q),
|
|
||||||
mix: mix.clamp(0.0, 1.0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Effect for Bandpass {
|
|
||||||
fn process(&mut self, mono: &mut [f32]) {
|
|
||||||
let mix = self.mix;
|
|
||||||
for s in mono.iter_mut() {
|
|
||||||
let y = self.filter.tick(*s);
|
|
||||||
*s = *s * (1.0 - mix) + y * mix;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// White noise generator
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
pub struct Noise {
|
|
||||||
gain: f32,
|
|
||||||
state: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Noise {
|
|
||||||
pub fn new(gain: f32) -> Self {
|
|
||||||
Self {
|
|
||||||
gain,
|
|
||||||
state: 0x9E3779B9,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn next(&mut self) -> f32 {
|
|
||||||
let mut x = self.state;
|
|
||||||
x ^= x << 13;
|
|
||||||
x ^= x >> 17;
|
|
||||||
x ^= x << 5;
|
|
||||||
self.state = x;
|
|
||||||
x as f32 / u32::MAX as f32 * 2.0 - 1.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Effect for Noise {
|
|
||||||
fn process(&mut self, mono: &mut [f32]) {
|
|
||||||
for s in mono.iter_mut() {
|
|
||||||
*s += self.next() * self.gain;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Compressor
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
pub struct Compressor {
|
|
||||||
threshold_db: f32,
|
|
||||||
ratio: f32,
|
|
||||||
makeup_db: f32,
|
|
||||||
env: f32,
|
|
||||||
gain: f32,
|
|
||||||
fs: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Compressor {
|
|
||||||
pub fn new(sample_rate: u32, threshold_db: f32, ratio: f32, makeup_db: f32) -> Self {
|
|
||||||
Self {
|
|
||||||
threshold_db,
|
|
||||||
ratio: ratio.max(1.0),
|
|
||||||
makeup_db,
|
|
||||||
env: 0.0,
|
|
||||||
gain: 1.0,
|
|
||||||
fs: sample_rate as f32,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Effect for Compressor {
|
|
||||||
fn process(&mut self, mono: &mut [f32]) {
|
|
||||||
let threshold = 10f32.powf(self.threshold_db / 20.0);
|
|
||||||
let makeup = 10f32.powf(self.makeup_db / 20.0);
|
|
||||||
let release = 1.0 / (self.fs * 0.1).max(1.0); // ~100 ms
|
|
||||||
let attack = 1.0 / (self.fs * 0.002).max(1.0); // ~2 ms
|
|
||||||
let inv_ratio = 1.0 - 1.0 / self.ratio;
|
|
||||||
|
|
||||||
for s in mono.iter_mut() {
|
|
||||||
let peak = s.abs();
|
|
||||||
self.env *= 1.0 - release;
|
|
||||||
if peak > self.env {
|
|
||||||
self.env = self.env + (peak - self.env) * attack;
|
|
||||||
}
|
|
||||||
|
|
||||||
let over_db = 20.0 * (self.env / threshold.max(1e-9)).log10();
|
|
||||||
let target_gain = if over_db > 0.0 {
|
|
||||||
let g_db = -over_db * inv_ratio;
|
|
||||||
10f32.powf(g_db / 20.0)
|
|
||||||
} else {
|
|
||||||
1.0
|
|
||||||
};
|
|
||||||
self.gain += (target_gain - self.gain) * attack;
|
|
||||||
*s *= self.gain * makeup;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Overdrive (soft tube-style saturation, unity gain at low levels)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
pub struct Overdrive {
|
|
||||||
drive: f32,
|
|
||||||
output: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Overdrive {
|
|
||||||
pub fn new(drive: f32, output: f32) -> Self {
|
|
||||||
Self {
|
|
||||||
drive: drive.max(0.01),
|
|
||||||
output,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Effect for Overdrive {
|
|
||||||
fn process(&mut self, mono: &mut [f32]) {
|
|
||||||
let d = self.drive;
|
|
||||||
let o = self.output;
|
|
||||||
for s in mono.iter_mut() {
|
|
||||||
*s = ((*s * d).atan() / d) * o;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tremolo (LFO amplitude modulation)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
pub struct Tremolo {
|
|
||||||
depth: f32,
|
|
||||||
rate: f32,
|
|
||||||
phase: f32,
|
|
||||||
fs: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Tremolo {
|
|
||||||
pub fn new(sample_rate: u32, depth: f32, rate_hz: f32) -> Self {
|
|
||||||
Self {
|
|
||||||
depth: depth.clamp(0.0, 1.0),
|
|
||||||
rate: rate_hz,
|
|
||||||
phase: 0.0,
|
|
||||||
fs: sample_rate as f32,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Effect for Tremolo {
|
|
||||||
fn process(&mut self, mono: &mut [f32]) {
|
|
||||||
for s in mono.iter_mut() {
|
|
||||||
let lfo = (2.0 * PI * self.phase).sin();
|
|
||||||
let amp = 1.0 - self.depth * (0.5 - 0.5 * lfo);
|
|
||||||
*s *= amp;
|
|
||||||
self.phase += self.rate / self.fs;
|
|
||||||
if self.phase >= 1.0 {
|
|
||||||
self.phase -= 1.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Flanger (short modulated delay with feedback)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
pub struct Flanger {
|
|
||||||
delay: Vec<f32>,
|
|
||||||
idx: usize,
|
|
||||||
cap: usize,
|
|
||||||
base: f32,
|
|
||||||
depth: f32,
|
|
||||||
rate: f32,
|
|
||||||
feedback: f32,
|
|
||||||
wet: f32,
|
|
||||||
phase: f32,
|
|
||||||
fs: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Flanger {
|
|
||||||
pub fn new(sample_rate: u32, depth_ms: f32, rate_hz: f32, feedback: f32, wet: f32) -> Self {
|
|
||||||
let depth = (depth_ms / 1000.0 * sample_rate as f32).max(1.0);
|
|
||||||
let cap = (sample_rate as f32 * 0.1) as usize + (depth * 2.0) as usize + 2;
|
|
||||||
Self {
|
|
||||||
delay: vec![0.0; cap],
|
|
||||||
idx: 0,
|
|
||||||
cap,
|
|
||||||
base: depth * 0.5,
|
|
||||||
depth: depth * 0.5,
|
|
||||||
rate: rate_hz,
|
|
||||||
feedback: feedback.clamp(0.0, 0.9),
|
|
||||||
wet: wet.clamp(0.0, 1.0),
|
|
||||||
phase: 0.0,
|
|
||||||
fs: sample_rate as f32,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn read(&self, offset: f32) -> f32 {
|
|
||||||
let mut pos = self.idx as f32 - offset;
|
|
||||||
if pos < 0.0 {
|
|
||||||
pos += self.cap as f32;
|
|
||||||
}
|
|
||||||
let i = pos.floor() as usize % self.cap;
|
|
||||||
let j = (i + 1) % self.cap;
|
|
||||||
let t = pos - pos.floor();
|
|
||||||
self.delay[i] * (1.0 - t) + self.delay[j] * t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Effect for Flanger {
|
|
||||||
fn process(&mut self, mono: &mut [f32]) {
|
|
||||||
let wet = self.wet;
|
|
||||||
let dry = 1.0 - wet;
|
|
||||||
for s in mono.iter_mut() {
|
|
||||||
let lfo = (2.0 * PI * self.phase).sin();
|
|
||||||
let offset = self.base + self.depth * lfo;
|
|
||||||
let delayed = self.read(offset);
|
|
||||||
let y = *s * dry + delayed * wet;
|
|
||||||
self.delay[self.idx] = *s + delayed * self.feedback;
|
|
||||||
*s = y;
|
|
||||||
self.idx = (self.idx + 1) % self.cap;
|
|
||||||
self.phase += self.rate / self.fs;
|
|
||||||
if self.phase >= 1.0 {
|
|
||||||
self.phase -= 1.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Channel vocoder (classic robot voice)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Debug, serde::Serialize, serde::Deserialize)]
|
|
||||||
pub enum CarrierKind {
|
|
||||||
Noise,
|
|
||||||
Saw,
|
|
||||||
Input,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ChannelVocoder {
|
|
||||||
bands: Vec<VocoderBand>,
|
|
||||||
carrier: CarrierKind,
|
|
||||||
bands_count: usize,
|
|
||||||
wet: f32,
|
|
||||||
/// Output makeup so vocoder presets stay loud without giant external gain.
|
|
||||||
makeup: f32,
|
|
||||||
carrier_hz: f32,
|
|
||||||
phase: f32,
|
|
||||||
noise_state: u32,
|
|
||||||
fs: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct VocoderBand {
|
|
||||||
analysis: Biquad,
|
|
||||||
synthesis: Biquad,
|
|
||||||
env: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ChannelVocoder {
|
|
||||||
pub fn new(
|
|
||||||
sample_rate: u32,
|
|
||||||
bands_count: usize,
|
|
||||||
carrier: CarrierKind,
|
|
||||||
carrier_hz: f32,
|
|
||||||
wet: f32,
|
|
||||||
) -> Self {
|
|
||||||
let fs = sample_rate as f32;
|
|
||||||
let low: f32 = 150.0;
|
|
||||||
let high: f32 = 4500.0;
|
|
||||||
let n = bands_count.max(2);
|
|
||||||
let mut bands = Vec::with_capacity(n);
|
|
||||||
for i in 0..n {
|
|
||||||
let f = low * (high / low).powf(i as f32 / (n - 1) as f32);
|
|
||||||
let f0: f32 = f;
|
|
||||||
let q = 8.0;
|
|
||||||
let analysis = Biquad::bandpass(sample_rate, f0, q);
|
|
||||||
let synthesis = Biquad::bandpass(sample_rate, f0, q);
|
|
||||||
bands.push(VocoderBand {
|
|
||||||
analysis,
|
|
||||||
synthesis,
|
|
||||||
env: 0.0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Self {
|
|
||||||
bands,
|
|
||||||
carrier,
|
|
||||||
bands_count: n,
|
|
||||||
wet: wet.clamp(0.0, 1.0),
|
|
||||||
makeup: 5.0,
|
|
||||||
carrier_hz: carrier_hz.max(20.0),
|
|
||||||
phase: 0.0,
|
|
||||||
noise_state: 0x2545F491,
|
|
||||||
fs,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn carrier_sample(&mut self, input: f32) -> f32 {
|
|
||||||
match self.carrier {
|
|
||||||
CarrierKind::Noise => {
|
|
||||||
let mut x = self.noise_state;
|
|
||||||
x ^= x << 13;
|
|
||||||
x ^= x >> 17;
|
|
||||||
x ^= x << 5;
|
|
||||||
self.noise_state = x;
|
|
||||||
x as f32 / u32::MAX as f32 * 2.0 - 1.0
|
|
||||||
}
|
|
||||||
CarrierKind::Saw => {
|
|
||||||
let s = self.phase * 2.0 - 1.0;
|
|
||||||
self.phase += self.carrier_hz / self.fs;
|
|
||||||
if self.phase >= 1.0 {
|
|
||||||
self.phase -= 1.0;
|
|
||||||
}
|
|
||||||
s
|
|
||||||
}
|
|
||||||
CarrierKind::Input => input,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Effect for ChannelVocoder {
|
|
||||||
fn process(&mut self, mono: &mut [f32]) {
|
|
||||||
let wet = self.wet;
|
|
||||||
let dry = 1.0 - wet;
|
|
||||||
let attack = 0.06;
|
|
||||||
let release = 0.0015;
|
|
||||||
let inv_n = 1.0 / self.bands_count as f32;
|
|
||||||
|
|
||||||
for s in mono.iter_mut() {
|
|
||||||
let input = *s;
|
|
||||||
let carrier = self.carrier_sample(input);
|
|
||||||
|
|
||||||
let mut acc = 0.0;
|
|
||||||
for band in &mut self.bands {
|
|
||||||
let a = band.analysis.tick(input);
|
|
||||||
let a_env = a.abs();
|
|
||||||
if a_env > band.env {
|
|
||||||
band.env += (a_env - band.env) * attack;
|
|
||||||
} else {
|
|
||||||
band.env += (a_env - band.env) * release;
|
|
||||||
}
|
|
||||||
acc += band.synthesis.tick(carrier) * band.env;
|
|
||||||
}
|
|
||||||
|
|
||||||
*s = input * dry + acc * wet * inv_n * self.makeup;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
101
src/dsp/fx/drive.rs
Normal file
101
src/dsp/fx/drive.rs
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
//! Drive effects: waveshapers and bit crusher.
|
||||||
|
|
||||||
|
use super::Effect;
|
||||||
|
|
||||||
|
pub struct Distortion {
|
||||||
|
drive: f32,
|
||||||
|
output: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Distortion {
|
||||||
|
pub fn new(drive: f32, output: f32) -> Self {
|
||||||
|
Self { drive, output }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Effect for Distortion {
|
||||||
|
fn process(&mut self, mono: &mut [f32]) {
|
||||||
|
for s in mono.iter_mut() {
|
||||||
|
*s = (*s * self.drive).tanh() * self.output;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Overdrive {
|
||||||
|
drive: f32,
|
||||||
|
output: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Overdrive {
|
||||||
|
pub fn new(drive: f32, output: f32) -> Self {
|
||||||
|
Self {
|
||||||
|
drive: drive.max(0.01),
|
||||||
|
output,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Effect for Overdrive {
|
||||||
|
fn process(&mut self, mono: &mut [f32]) {
|
||||||
|
let d = self.drive;
|
||||||
|
let o = self.output;
|
||||||
|
for s in mono.iter_mut() {
|
||||||
|
*s = ((*s * d).atan() / d) * o;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Bitcrush {
|
||||||
|
bits: u8,
|
||||||
|
sample_rate_div: u32,
|
||||||
|
/// Max boost applied to quiet input (auto-level normalization).
|
||||||
|
max_gain: f32,
|
||||||
|
/// Target peak level after normalization.
|
||||||
|
target: f32,
|
||||||
|
gain_smooth: f32,
|
||||||
|
hold: f32,
|
||||||
|
count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Bitcrush {
|
||||||
|
/// `max_gain`/`target` normalize each block before quantization so the
|
||||||
|
/// effect works on quiet mics without clipping loud ones.
|
||||||
|
pub fn new(bits: u8, sample_rate_div: u32, max_gain: f32, target: f32) -> Self {
|
||||||
|
Self {
|
||||||
|
bits: bits.max(2),
|
||||||
|
sample_rate_div: sample_rate_div.max(1),
|
||||||
|
max_gain: max_gain.max(1.0),
|
||||||
|
target: target.clamp(0.1, 1.0),
|
||||||
|
gain_smooth: 1.0,
|
||||||
|
hold: 0.0,
|
||||||
|
count: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Effect for Bitcrush {
|
||||||
|
fn process(&mut self, mono: &mut [f32]) {
|
||||||
|
if mono.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let levels = (1i32 << (self.bits - 1)) as f32;
|
||||||
|
|
||||||
|
let peak = mono.iter().fold(0.0f32, |m, s| m.max(s.abs()));
|
||||||
|
let gain = if peak > 1e-6 {
|
||||||
|
(self.target / peak).clamp(1.0, self.max_gain)
|
||||||
|
} else {
|
||||||
|
self.max_gain
|
||||||
|
};
|
||||||
|
self.gain_smooth += (gain - self.gain_smooth) * 0.3;
|
||||||
|
let g = self.gain_smooth;
|
||||||
|
|
||||||
|
for s in mono.iter_mut() {
|
||||||
|
self.count = (self.count + 1) % self.sample_rate_div;
|
||||||
|
if self.count == 0 {
|
||||||
|
self.hold = *s;
|
||||||
|
}
|
||||||
|
let q = (self.hold * g * levels).round();
|
||||||
|
*s = (q / levels).clamp(-1.0, 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/dsp/fx/filter/biquad.rs
Normal file
76
src/dsp/fx/filter/biquad.rs
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
//! Biquad filters (RBJ cookbook).
|
||||||
|
|
||||||
|
use super::super::Effect;
|
||||||
|
use std::f32::consts::PI;
|
||||||
|
|
||||||
|
pub struct Biquad {
|
||||||
|
b0: f32,
|
||||||
|
b1: f32,
|
||||||
|
b2: f32,
|
||||||
|
a1: f32,
|
||||||
|
a2: f32,
|
||||||
|
z1: f32,
|
||||||
|
z2: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Biquad {
|
||||||
|
/// Constant-skirt bandpass.
|
||||||
|
pub fn bandpass(sample_rate: u32, f0: f32, q: f32) -> Self {
|
||||||
|
let w0 = 2.0 * PI * f0 / sample_rate as f32;
|
||||||
|
let alpha = w0.sin() / (2.0 * q);
|
||||||
|
Self::normalized(
|
||||||
|
alpha,
|
||||||
|
0.0,
|
||||||
|
-alpha,
|
||||||
|
-2.0 * w0.cos(),
|
||||||
|
1.0 - alpha,
|
||||||
|
1.0 + alpha,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalized(b0: f32, b1: f32, b2: f32, a1: f32, a2: f32, a0: f32) -> Self {
|
||||||
|
Self {
|
||||||
|
b0: b0 / a0,
|
||||||
|
b1: b1 / a0,
|
||||||
|
b2: b2 / a0,
|
||||||
|
a1: a1 / a0,
|
||||||
|
a2: a2 / a0,
|
||||||
|
z1: 0.0,
|
||||||
|
z2: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn tick(&mut self, x: f32) -> f32 {
|
||||||
|
let y = self.b0 * x + self.z1;
|
||||||
|
self.z1 = self.b1 * x - self.a1 * y + self.z2;
|
||||||
|
self.z2 = self.b2 * x - self.a2 * y;
|
||||||
|
y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Bandpass {
|
||||||
|
filter: Biquad,
|
||||||
|
mix: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Bandpass {
|
||||||
|
pub fn new(sample_rate: u32, low_hz: f32, high_hz: f32, mix: f32) -> Self {
|
||||||
|
let f0 = (low_hz * high_hz).sqrt();
|
||||||
|
let q = f0 / (high_hz - low_hz).max(1.0);
|
||||||
|
Self {
|
||||||
|
filter: Biquad::bandpass(sample_rate, f0, q),
|
||||||
|
mix: mix.clamp(0.0, 1.0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Effect for Bandpass {
|
||||||
|
fn process(&mut self, mono: &mut [f32]) {
|
||||||
|
let mix = self.mix;
|
||||||
|
for s in mono.iter_mut() {
|
||||||
|
let y = self.filter.tick(*s);
|
||||||
|
*s = *s * (1.0 - mix) + y * mix;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
108
src/dsp/fx/filter/dynamics.rs
Normal file
108
src/dsp/fx/filter/dynamics.rs
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
//! Dynamics: compressor and noise gate.
|
||||||
|
|
||||||
|
use super::super::Effect;
|
||||||
|
|
||||||
|
pub struct Compressor {
|
||||||
|
threshold_db: f32,
|
||||||
|
ratio: f32,
|
||||||
|
makeup_db: f32,
|
||||||
|
env: f32,
|
||||||
|
gain: f32,
|
||||||
|
fs: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Compressor {
|
||||||
|
pub fn new(sample_rate: u32, threshold_db: f32, ratio: f32, makeup_db: f32) -> Self {
|
||||||
|
Self {
|
||||||
|
threshold_db,
|
||||||
|
ratio: ratio.max(1.0),
|
||||||
|
makeup_db,
|
||||||
|
env: 0.0,
|
||||||
|
gain: 1.0,
|
||||||
|
fs: sample_rate as f32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Effect for Compressor {
|
||||||
|
fn process(&mut self, mono: &mut [f32]) {
|
||||||
|
let threshold = 10f32.powf(self.threshold_db / 20.0);
|
||||||
|
let makeup = 10f32.powf(self.makeup_db / 20.0);
|
||||||
|
let release = 1.0 / (self.fs * 0.1).max(1.0);
|
||||||
|
let attack = 1.0 / (self.fs * 0.002).max(1.0);
|
||||||
|
let inv_ratio = 1.0 - 1.0 / self.ratio;
|
||||||
|
|
||||||
|
for s in mono.iter_mut() {
|
||||||
|
let peak = s.abs();
|
||||||
|
self.env *= 1.0 - release;
|
||||||
|
if peak > self.env {
|
||||||
|
self.env += (peak - self.env) * attack;
|
||||||
|
}
|
||||||
|
|
||||||
|
let over_db = 20.0 * (self.env / threshold.max(1e-9)).log10();
|
||||||
|
let target_gain = if over_db > 0.0 {
|
||||||
|
let g_db = -over_db * inv_ratio;
|
||||||
|
10f32.powf(g_db / 20.0)
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
self.gain += (target_gain - self.gain) * attack;
|
||||||
|
*s *= self.gain * makeup;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Noise gate: suppresses quiet segments with hysteresis and smoothing.
|
||||||
|
pub struct NoiseGate {
|
||||||
|
pub threshold_db: f32,
|
||||||
|
level: f32,
|
||||||
|
gain: f32,
|
||||||
|
open: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NoiseGate {
|
||||||
|
pub fn new(threshold_db: f32) -> Self {
|
||||||
|
Self {
|
||||||
|
threshold_db,
|
||||||
|
level: 0.0,
|
||||||
|
gain: 0.0,
|
||||||
|
open: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_threshold_db(&mut self, db: f32) {
|
||||||
|
self.threshold_db = db;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Effect for NoiseGate {
|
||||||
|
fn process(&mut self, mono: &mut [f32]) {
|
||||||
|
if mono.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut sum = 0.0;
|
||||||
|
for &s in mono.iter() {
|
||||||
|
sum += s * s;
|
||||||
|
}
|
||||||
|
let rms = (sum / mono.len() as f32).sqrt();
|
||||||
|
|
||||||
|
if rms > self.level {
|
||||||
|
self.level += (rms - self.level) * 0.5;
|
||||||
|
} else {
|
||||||
|
self.level += (rms - self.level) * 0.02;
|
||||||
|
}
|
||||||
|
|
||||||
|
let threshold = 10f32.powf(self.threshold_db / 20.0);
|
||||||
|
if self.level > threshold {
|
||||||
|
self.open = true;
|
||||||
|
} else if self.level < threshold * 0.5 {
|
||||||
|
self.open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let target = if self.open { 1.0 } else { 0.0 };
|
||||||
|
for s in mono.iter_mut() {
|
||||||
|
self.gain += (target - self.gain) * 0.15;
|
||||||
|
*s *= self.gain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/dsp/fx/filter/mod.rs
Normal file
7
src/dsp/fx/filter/mod.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
//! Filters and dynamics: biquad filters, compressor, noise gate.
|
||||||
|
|
||||||
|
pub mod biquad;
|
||||||
|
pub mod dynamics;
|
||||||
|
|
||||||
|
pub use biquad::{Bandpass, Biquad};
|
||||||
|
pub use dynamics::{Compressor, NoiseGate};
|
||||||
19
src/dsp/fx/mod.rs
Normal file
19
src/dsp/fx/mod.rs
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
//! Effects library: small processing units applied to the mono stream after
|
||||||
|
//! pitch/formant shifting. Each effect mutates the block in place.
|
||||||
|
|
||||||
|
mod drive;
|
||||||
|
mod modgen;
|
||||||
|
|
||||||
|
pub mod filter;
|
||||||
|
pub mod time;
|
||||||
|
pub mod vocoder;
|
||||||
|
|
||||||
|
pub use drive::{Bitcrush, Distortion, Overdrive};
|
||||||
|
pub use filter::{Bandpass, Compressor};
|
||||||
|
pub use modgen::{Noise, RingMod, Tremolo};
|
||||||
|
pub use time::{Chorus, Flanger, Reverb};
|
||||||
|
pub use vocoder::{CarrierKind, ChannelVocoder};
|
||||||
|
|
||||||
|
pub trait Effect: Send {
|
||||||
|
fn process(&mut self, mono: &mut [f32]);
|
||||||
|
}
|
||||||
100
src/dsp/fx/modgen.rs
Normal file
100
src/dsp/fx/modgen.rs
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
//! Generator/modulation effects: white noise, tremolo, ring modulation.
|
||||||
|
|
||||||
|
use super::Effect;
|
||||||
|
use std::f32::consts::PI;
|
||||||
|
|
||||||
|
pub struct Noise {
|
||||||
|
gain: f32,
|
||||||
|
state: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Noise {
|
||||||
|
pub fn new(gain: f32) -> Self {
|
||||||
|
Self {
|
||||||
|
gain,
|
||||||
|
state: 0x9E3779B9,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn next(&mut self) -> f32 {
|
||||||
|
let mut x = self.state;
|
||||||
|
x ^= x << 13;
|
||||||
|
x ^= x >> 17;
|
||||||
|
x ^= x << 5;
|
||||||
|
self.state = x;
|
||||||
|
x as f32 / u32::MAX as f32 * 2.0 - 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Effect for Noise {
|
||||||
|
fn process(&mut self, mono: &mut [f32]) {
|
||||||
|
for s in mono.iter_mut() {
|
||||||
|
*s += self.next() * self.gain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Tremolo {
|
||||||
|
depth: f32,
|
||||||
|
rate: f32,
|
||||||
|
phase: f32,
|
||||||
|
fs: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tremolo {
|
||||||
|
pub fn new(sample_rate: u32, depth: f32, rate_hz: f32) -> Self {
|
||||||
|
Self {
|
||||||
|
depth: depth.clamp(0.0, 1.0),
|
||||||
|
rate: rate_hz,
|
||||||
|
phase: 0.0,
|
||||||
|
fs: sample_rate as f32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Effect for Tremolo {
|
||||||
|
fn process(&mut self, mono: &mut [f32]) {
|
||||||
|
for s in mono.iter_mut() {
|
||||||
|
let lfo = (2.0 * PI * self.phase).sin();
|
||||||
|
let amp = 1.0 - self.depth * (0.5 - 0.5 * lfo);
|
||||||
|
*s *= amp;
|
||||||
|
self.phase += self.rate / self.fs;
|
||||||
|
if self.phase >= 1.0 {
|
||||||
|
self.phase -= 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RingMod {
|
||||||
|
freq: f32,
|
||||||
|
depth: f32,
|
||||||
|
phase: f32,
|
||||||
|
fs: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RingMod {
|
||||||
|
pub fn new(sample_rate: u32, freq_hz: f32, depth: f32) -> Self {
|
||||||
|
Self {
|
||||||
|
freq: freq_hz,
|
||||||
|
depth: depth.clamp(0.0, 1.0),
|
||||||
|
phase: 0.0,
|
||||||
|
fs: sample_rate as f32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Effect for RingMod {
|
||||||
|
fn process(&mut self, mono: &mut [f32]) {
|
||||||
|
let depth = self.depth;
|
||||||
|
for s in mono.iter_mut() {
|
||||||
|
let m = 1.0 - depth + depth * (2.0 * PI * self.phase).sin();
|
||||||
|
*s *= m;
|
||||||
|
self.phase += self.freq / self.fs;
|
||||||
|
if self.phase >= 1.0 {
|
||||||
|
self.phase -= 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
128
src/dsp/fx/time/delay.rs
Normal file
128
src/dsp/fx/time/delay.rs
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
//! Modulated delay lines: chorus and flanger.
|
||||||
|
|
||||||
|
use super::super::Effect;
|
||||||
|
use std::f32::consts::PI;
|
||||||
|
|
||||||
|
struct DelayLine {
|
||||||
|
buf: Vec<f32>,
|
||||||
|
idx: usize,
|
||||||
|
cap: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DelayLine {
|
||||||
|
fn new(cap: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
buf: vec![0.0; cap],
|
||||||
|
idx: 0,
|
||||||
|
cap,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn write(&mut self, value: f32) {
|
||||||
|
self.buf[self.idx] = value;
|
||||||
|
self.idx = (self.idx + 1) % self.cap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn read(&self, offset: f32) -> f32 {
|
||||||
|
let mut pos = self.idx as f32 - offset;
|
||||||
|
if pos < 0.0 {
|
||||||
|
pos += self.cap as f32;
|
||||||
|
}
|
||||||
|
let i = pos.floor() as usize % self.cap;
|
||||||
|
let j = (i + 1) % self.cap;
|
||||||
|
let t = pos - pos.floor();
|
||||||
|
self.buf[i] * (1.0 - t) + self.buf[j] * t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Chorus {
|
||||||
|
line: DelayLine,
|
||||||
|
depth: f32,
|
||||||
|
rate: f32,
|
||||||
|
wet: f32,
|
||||||
|
phase: f32,
|
||||||
|
fs: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Chorus {
|
||||||
|
pub fn new(sample_rate: u32, depth_ms: f32, rate_hz: f32, wet: f32) -> Self {
|
||||||
|
let depth = depth_ms / 1000.0 * sample_rate as f32;
|
||||||
|
let cap = (sample_rate as f32 * 0.1) as usize + (depth * 4.0) as usize;
|
||||||
|
Self {
|
||||||
|
line: DelayLine::new(cap),
|
||||||
|
depth: depth.max(1.0),
|
||||||
|
rate: rate_hz,
|
||||||
|
wet: wet.clamp(0.0, 1.0),
|
||||||
|
phase: 0.0,
|
||||||
|
fs: sample_rate as f32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Effect for Chorus {
|
||||||
|
fn process(&mut self, mono: &mut [f32]) {
|
||||||
|
let depth = self.depth;
|
||||||
|
let wet = self.wet;
|
||||||
|
let dry = 1.0 - wet;
|
||||||
|
for s in mono.iter_mut() {
|
||||||
|
self.line.write(*s);
|
||||||
|
let lfo = (2.0 * PI * self.phase).sin();
|
||||||
|
let v1 = self.line.read(depth * (0.35 + 0.35 * lfo));
|
||||||
|
let v2 = self.line.read(depth * (0.35 - 0.35 * lfo));
|
||||||
|
*s = *s * dry + (v1 + v2) * 0.5 * wet;
|
||||||
|
self.phase += self.rate / self.fs;
|
||||||
|
if self.phase >= 1.0 {
|
||||||
|
self.phase -= 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Flanger {
|
||||||
|
line: DelayLine,
|
||||||
|
base: f32,
|
||||||
|
depth: f32,
|
||||||
|
rate: f32,
|
||||||
|
feedback: f32,
|
||||||
|
wet: f32,
|
||||||
|
phase: f32,
|
||||||
|
fs: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Flanger {
|
||||||
|
pub fn new(sample_rate: u32, depth_ms: f32, rate_hz: f32, feedback: f32, wet: f32) -> Self {
|
||||||
|
let depth = (depth_ms / 1000.0 * sample_rate as f32).max(1.0);
|
||||||
|
let cap = (sample_rate as f32 * 0.1) as usize + (depth * 2.0) as usize + 2;
|
||||||
|
Self {
|
||||||
|
line: DelayLine::new(cap),
|
||||||
|
base: depth * 0.5,
|
||||||
|
depth: depth * 0.5,
|
||||||
|
rate: rate_hz,
|
||||||
|
feedback: feedback.clamp(0.0, 0.9),
|
||||||
|
wet: wet.clamp(0.0, 1.0),
|
||||||
|
phase: 0.0,
|
||||||
|
fs: sample_rate as f32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Effect for Flanger {
|
||||||
|
fn process(&mut self, mono: &mut [f32]) {
|
||||||
|
let wet = self.wet;
|
||||||
|
let dry = 1.0 - wet;
|
||||||
|
for s in mono.iter_mut() {
|
||||||
|
let lfo = (2.0 * PI * self.phase).sin();
|
||||||
|
let offset = self.base + self.depth * lfo;
|
||||||
|
let delayed = self.line.read(offset);
|
||||||
|
let y = *s * dry + delayed * wet;
|
||||||
|
self.line.write(*s + delayed * self.feedback);
|
||||||
|
*s = y;
|
||||||
|
self.phase += self.rate / self.fs;
|
||||||
|
if self.phase >= 1.0 {
|
||||||
|
self.phase -= 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/dsp/fx/time/mod.rs
Normal file
7
src/dsp/fx/time/mod.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
//! Time-based effects: reverb (Freeverb) and modulated delays (chorus, flanger).
|
||||||
|
|
||||||
|
pub mod delay;
|
||||||
|
pub mod reverb;
|
||||||
|
|
||||||
|
pub use delay::{Chorus, Flanger};
|
||||||
|
pub use reverb::Reverb;
|
||||||
127
src/dsp/fx/time/reverb.rs
Normal file
127
src/dsp/fx/time/reverb.rs
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
//! Freeverb-style reverb.
|
||||||
|
|
||||||
|
use super::super::Effect;
|
||||||
|
|
||||||
|
struct Comb {
|
||||||
|
buf: Vec<f32>,
|
||||||
|
idx: usize,
|
||||||
|
filter: f32,
|
||||||
|
feedback: f32,
|
||||||
|
damp1: f32,
|
||||||
|
damp2: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Comb {
|
||||||
|
fn new(len: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
buf: vec![0.0; len],
|
||||||
|
idx: 0,
|
||||||
|
filter: 0.0,
|
||||||
|
feedback: 0.84,
|
||||||
|
damp1: 0.5,
|
||||||
|
damp2: 0.5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn process(&mut self, input: f32) -> f32 {
|
||||||
|
let output = self.buf[self.idx];
|
||||||
|
self.filter = output * self.damp2 + self.filter * self.damp1;
|
||||||
|
self.buf[self.idx] = input + self.filter * self.feedback;
|
||||||
|
self.idx += 1;
|
||||||
|
if self.idx >= self.buf.len() {
|
||||||
|
self.idx = 0;
|
||||||
|
}
|
||||||
|
output
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Allpass {
|
||||||
|
buf: Vec<f32>,
|
||||||
|
idx: usize,
|
||||||
|
feedback: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Allpass {
|
||||||
|
fn new(len: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
buf: vec![0.0; len],
|
||||||
|
idx: 0,
|
||||||
|
feedback: 0.5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn process(&mut self, input: f32) -> f32 {
|
||||||
|
let bufout = self.buf[self.idx];
|
||||||
|
self.buf[self.idx] = input + bufout * self.feedback;
|
||||||
|
self.idx += 1;
|
||||||
|
if self.idx >= self.buf.len() {
|
||||||
|
self.idx = 0;
|
||||||
|
}
|
||||||
|
bufout - input
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Reverb {
|
||||||
|
combs: Vec<Comb>,
|
||||||
|
allpasses: Vec<Allpass>,
|
||||||
|
wet: f32,
|
||||||
|
room: f32,
|
||||||
|
damp: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Reverb {
|
||||||
|
pub fn new(sample_rate: u32, room: f32, damp: f32, wet: f32) -> Self {
|
||||||
|
let scale = sample_rate as f32 / 44100.0;
|
||||||
|
let comb_lens = [1116, 1188, 1277, 1356, 1422, 1491, 1557, 1617];
|
||||||
|
let allpass_lens = [556, 441, 341, 225];
|
||||||
|
|
||||||
|
let combs: Vec<Comb> = comb_lens
|
||||||
|
.iter()
|
||||||
|
.map(|&l| Comb::new((l as f32 * scale) as usize))
|
||||||
|
.collect();
|
||||||
|
let allpasses: Vec<Allpass> = allpass_lens
|
||||||
|
.iter()
|
||||||
|
.map(|&l| Allpass::new((l as f32 * scale) as usize))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut reverb = Self {
|
||||||
|
combs,
|
||||||
|
allpasses,
|
||||||
|
wet: wet.clamp(0.0, 1.0),
|
||||||
|
room: room.clamp(0.0, 1.0),
|
||||||
|
damp: damp.clamp(0.0, 1.0),
|
||||||
|
};
|
||||||
|
reverb.apply_params();
|
||||||
|
reverb
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_params(&mut self) {
|
||||||
|
let feedback = self.room * 0.88 + 0.10;
|
||||||
|
for comb in &mut self.combs {
|
||||||
|
comb.feedback = feedback;
|
||||||
|
comb.damp1 = self.damp;
|
||||||
|
comb.damp2 = 1.0 - self.damp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Effect for Reverb {
|
||||||
|
fn process(&mut self, mono: &mut [f32]) {
|
||||||
|
let wet = self.wet;
|
||||||
|
let dry = 1.0 - wet;
|
||||||
|
for s in mono.iter_mut() {
|
||||||
|
let input = *s;
|
||||||
|
let mut acc = 0.0;
|
||||||
|
for comb in &mut self.combs {
|
||||||
|
acc += comb.process(input);
|
||||||
|
}
|
||||||
|
acc /= self.combs.len() as f32;
|
||||||
|
for ap in &mut self.allpasses {
|
||||||
|
acc = ap.process(acc);
|
||||||
|
}
|
||||||
|
*s = input * dry + acc * wet * 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/dsp/fx/vocoder/carrier.rs
Normal file
8
src/dsp/fx/vocoder/carrier.rs
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
//! Vocoder carrier kinds and generators.
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Debug, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub enum CarrierKind {
|
||||||
|
Noise,
|
||||||
|
Saw,
|
||||||
|
Input,
|
||||||
|
}
|
||||||
112
src/dsp/fx/vocoder/channel.rs
Normal file
112
src/dsp/fx/vocoder/channel.rs
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
//! Channel vocoder: analysis filterbank + envelope followers + synthesis.
|
||||||
|
|
||||||
|
use super::super::Effect;
|
||||||
|
use super::carrier::CarrierKind;
|
||||||
|
use crate::dsp::fx::filter::Biquad;
|
||||||
|
|
||||||
|
pub struct ChannelVocoder {
|
||||||
|
bands: Vec<VocoderBand>,
|
||||||
|
carrier: CarrierKind,
|
||||||
|
bands_count: usize,
|
||||||
|
wet: f32,
|
||||||
|
makeup: f32,
|
||||||
|
carrier_hz: f32,
|
||||||
|
phase: f32,
|
||||||
|
noise_state: u32,
|
||||||
|
fs: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct VocoderBand {
|
||||||
|
analysis: Biquad,
|
||||||
|
synthesis: Biquad,
|
||||||
|
env: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChannelVocoder {
|
||||||
|
pub fn new(
|
||||||
|
sample_rate: u32,
|
||||||
|
bands_count: usize,
|
||||||
|
carrier: CarrierKind,
|
||||||
|
carrier_hz: f32,
|
||||||
|
wet: f32,
|
||||||
|
) -> Self {
|
||||||
|
let fs = sample_rate as f32;
|
||||||
|
let low: f32 = 150.0;
|
||||||
|
let high: f32 = 4500.0;
|
||||||
|
let n = bands_count.max(2);
|
||||||
|
let mut bands = Vec::with_capacity(n);
|
||||||
|
for i in 0..n {
|
||||||
|
let f = low * (high / low).powf(i as f32 / (n - 1) as f32);
|
||||||
|
let f0: f32 = f;
|
||||||
|
let q = 8.0;
|
||||||
|
bands.push(VocoderBand {
|
||||||
|
analysis: Biquad::bandpass(sample_rate, f0, q),
|
||||||
|
synthesis: Biquad::bandpass(sample_rate, f0, q),
|
||||||
|
env: 0.0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
bands,
|
||||||
|
carrier,
|
||||||
|
bands_count: n,
|
||||||
|
wet: wet.clamp(0.0, 1.0),
|
||||||
|
makeup: 5.0,
|
||||||
|
carrier_hz: carrier_hz.max(20.0),
|
||||||
|
phase: 0.0,
|
||||||
|
noise_state: 0x2545F491,
|
||||||
|
fs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn carrier_sample(&mut self, input: f32) -> f32 {
|
||||||
|
match self.carrier {
|
||||||
|
CarrierKind::Noise => {
|
||||||
|
let mut x = self.noise_state;
|
||||||
|
x ^= x << 13;
|
||||||
|
x ^= x >> 17;
|
||||||
|
x ^= x << 5;
|
||||||
|
self.noise_state = x;
|
||||||
|
x as f32 / u32::MAX as f32 * 2.0 - 1.0
|
||||||
|
}
|
||||||
|
CarrierKind::Saw => {
|
||||||
|
let s = self.phase * 2.0 - 1.0;
|
||||||
|
self.phase += self.carrier_hz / self.fs;
|
||||||
|
if self.phase >= 1.0 {
|
||||||
|
self.phase -= 1.0;
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
CarrierKind::Input => input,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Effect for ChannelVocoder {
|
||||||
|
fn process(&mut self, mono: &mut [f32]) {
|
||||||
|
let wet = self.wet;
|
||||||
|
let dry = 1.0 - wet;
|
||||||
|
let attack = 0.06;
|
||||||
|
let release = 0.0015;
|
||||||
|
let inv_n = 1.0 / self.bands_count as f32;
|
||||||
|
|
||||||
|
for s in mono.iter_mut() {
|
||||||
|
let input = *s;
|
||||||
|
let carrier = self.carrier_sample(input);
|
||||||
|
|
||||||
|
let mut acc = 0.0;
|
||||||
|
for band in &mut self.bands {
|
||||||
|
let a = band.analysis.tick(input);
|
||||||
|
let a_env = a.abs();
|
||||||
|
if a_env > band.env {
|
||||||
|
band.env += (a_env - band.env) * attack;
|
||||||
|
} else {
|
||||||
|
band.env += (a_env - band.env) * release;
|
||||||
|
}
|
||||||
|
acc += band.synthesis.tick(carrier) * band.env;
|
||||||
|
}
|
||||||
|
|
||||||
|
*s = input * dry + acc * wet * inv_n * self.makeup;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/dsp/fx/vocoder/mod.rs
Normal file
7
src/dsp/fx/vocoder/mod.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
//! Channel vocoder effect (classic robot voice).
|
||||||
|
|
||||||
|
pub mod carrier;
|
||||||
|
pub mod channel;
|
||||||
|
|
||||||
|
pub use carrier::CarrierKind;
|
||||||
|
pub use channel::ChannelVocoder;
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
//! Noise gate: suppresses quiet segments below the threshold with a short
|
|
||||||
//! attack / longer release and a 6 dB hysteresis to avoid chatter.
|
|
||||||
|
|
||||||
/// Convert dB to linear amplitude.
|
|
||||||
pub fn db_to_lin(db: f32) -> f32 {
|
|
||||||
10f32.powf(db / 20.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct NoiseGate {
|
|
||||||
pub threshold_db: f32,
|
|
||||||
level: f32,
|
|
||||||
gain: f32,
|
|
||||||
open: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl NoiseGate {
|
|
||||||
pub fn new(threshold_db: f32) -> Self {
|
|
||||||
Self {
|
|
||||||
threshold_db,
|
|
||||||
level: 0.0,
|
|
||||||
gain: 0.0,
|
|
||||||
open: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_threshold_db(&mut self, db: f32) {
|
|
||||||
self.threshold_db = db;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn process(&mut self, block: &mut [f32]) {
|
|
||||||
if block.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut sum = 0.0;
|
|
||||||
for &s in block.iter() {
|
|
||||||
sum += s * s;
|
|
||||||
}
|
|
||||||
let rms = (sum / block.len() as f32).sqrt();
|
|
||||||
|
|
||||||
// Envelope follower: fast attack, slow release.
|
|
||||||
if rms > self.level {
|
|
||||||
self.level += (rms - self.level) * 0.5;
|
|
||||||
} else {
|
|
||||||
self.level += (rms - self.level) * 0.02;
|
|
||||||
}
|
|
||||||
|
|
||||||
let threshold = db_to_lin(self.threshold_db);
|
|
||||||
|
|
||||||
// Hysteresis: open above threshold, close 6 dB below it.
|
|
||||||
if self.level > threshold {
|
|
||||||
self.open = true;
|
|
||||||
} else if self.level < threshold * 0.5 {
|
|
||||||
self.open = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
let target = if self.open { 1.0 } else { 0.0 };
|
|
||||||
for s in block.iter_mut() {
|
|
||||||
// Per-sample smoothing so the gate never clicks.
|
|
||||||
self.gain += (target - self.gain) * 0.15;
|
|
||||||
*s *= self.gain;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
pub mod chain;
|
pub mod chain;
|
||||||
pub mod effects;
|
pub mod control;
|
||||||
pub mod formant;
|
pub mod formant;
|
||||||
pub mod gate;
|
pub mod fx;
|
||||||
pub mod pitch;
|
pub mod preset;
|
||||||
pub mod presets;
|
pub mod shift;
|
||||||
pub mod stats;
|
|
||||||
|
|
|
||||||
454
src/dsp/pitch.rs
454
src/dsp/pitch.rs
|
|
@ -1,454 +0,0 @@
|
||||||
//! Real-time pitch shifting via phase vocoder.
|
|
||||||
//!
|
|
||||||
//! The phase vocoder resamples the spectral *residual* (fine structure) to
|
|
||||||
//! change pitch while keeping the spectral envelope (formants) intact, and
|
|
||||||
//! independently resamples the envelope to change formants. This gives us a
|
|
||||||
//! single FFT-based stage that drives both the pitch and formant controls.
|
|
||||||
//!
|
|
||||||
//! Latency is one FFT frame (default 1024 samples ≈ 21 ms @ 48 kHz).
|
|
||||||
//! Hann window with 75% overlap (hop = N/4) is a valid COLA window, so the
|
|
||||||
//! overlap-add reconstruction is lossless in the bypass case.
|
|
||||||
|
|
||||||
use rustfft::num_complex::Complex;
|
|
||||||
use rustfft::{Fft, FftPlanner};
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
/// Linear interpolation over `arr` at fractional position `pos`.
|
|
||||||
fn interpolate(arr: &[f32], pos: f32) -> f32 {
|
|
||||||
let max = arr.len() as f32 - 1.0;
|
|
||||||
let pos = pos.clamp(0.0, max);
|
|
||||||
let i = pos.floor() as usize;
|
|
||||||
let j = (i + 1).min(arr.len() - 1);
|
|
||||||
let t = pos - i as f32;
|
|
||||||
arr[i] * (1.0 - t) + arr[j] * t
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Wrap a phase difference into (-PI, PI].
|
|
||||||
fn princarg(x: f32) -> f32 {
|
|
||||||
x - 2.0 * std::f32::consts::PI * (x / (2.0 * std::f32::consts::PI)).round()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PitchShifter {
|
|
||||||
n: usize,
|
|
||||||
hop: usize,
|
|
||||||
|
|
||||||
window: Vec<f32>,
|
|
||||||
norm: f32,
|
|
||||||
|
|
||||||
fft: Arc<dyn Fft<f32>>,
|
|
||||||
ifft: Arc<dyn Fft<f32>>,
|
|
||||||
env_ifft: Arc<dyn Fft<f32>>,
|
|
||||||
env_fft: Arc<dyn Fft<f32>>,
|
|
||||||
|
|
||||||
in_buf: Vec<f32>,
|
|
||||||
out_buf: Vec<f32>,
|
|
||||||
|
|
||||||
spec: Vec<Complex<f32>>,
|
|
||||||
mag: Vec<f32>,
|
|
||||||
phase: Vec<f32>,
|
|
||||||
logmag: Vec<f32>,
|
|
||||||
env: Vec<f32>,
|
|
||||||
cep: Vec<Complex<f32>>,
|
|
||||||
prev_phase: Vec<f32>,
|
|
||||||
out_phase: Vec<f32>,
|
|
||||||
|
|
||||||
/// Quefrency bins kept by the cepstral lifter (spectral envelope cutoff).
|
|
||||||
lifter_len: usize,
|
|
||||||
|
|
||||||
/// Pitch factor: output pitch = input pitch * ratio (1.0 = unchanged).
|
|
||||||
pub pitch_ratio: f32,
|
|
||||||
/// Formant factor: output formants = input formants * ratio (1.0 = unchanged).
|
|
||||||
pub formant_ratio: f32,
|
|
||||||
|
|
||||||
fs: u32,
|
|
||||||
/// Detected fundamental of the most recent analysis frame (Hz, 0 = unvoiced).
|
|
||||||
pub last_f0: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PitchShifter {
|
|
||||||
pub fn new(fft_size: usize, hop: usize, sample_rate: u32) -> Self {
|
|
||||||
debug_assert!(fft_size.is_multiple_of(hop));
|
|
||||||
let frames = fft_size / hop;
|
|
||||||
|
|
||||||
let mut window = vec![0.0; fft_size];
|
|
||||||
for (i, w) in window.iter_mut().enumerate() {
|
|
||||||
*w =
|
|
||||||
0.5 - 0.5 * (2.0 * std::f32::consts::PI * i as f32 / (fft_size as f32 - 1.0)).cos();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Steady-state OLA normalization: every output sample is covered by
|
|
||||||
// `frames` overlapping windows; sum of w^2 over those offsets.
|
|
||||||
let mut norm = 0.0;
|
|
||||||
for i in 0..frames {
|
|
||||||
let idx = i * hop;
|
|
||||||
norm += window[idx] * window[idx];
|
|
||||||
}
|
|
||||||
debug_assert!(norm > 0.0);
|
|
||||||
|
|
||||||
let mut planner = FftPlanner::<f32>::new();
|
|
||||||
let fft = planner.plan_fft_forward(fft_size);
|
|
||||||
let ifft = planner.plan_fft_inverse(fft_size);
|
|
||||||
let env_ifft = planner.plan_fft_inverse(fft_size);
|
|
||||||
let env_fft = planner.plan_fft_forward(fft_size);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
n: fft_size,
|
|
||||||
hop,
|
|
||||||
window,
|
|
||||||
norm,
|
|
||||||
fft,
|
|
||||||
ifft,
|
|
||||||
env_ifft,
|
|
||||||
env_fft,
|
|
||||||
in_buf: Vec::new(),
|
|
||||||
out_buf: vec![0.0; fft_size],
|
|
||||||
spec: vec![Complex::new(0.0, 0.0); fft_size],
|
|
||||||
mag: vec![0.0; fft_size / 2 + 1],
|
|
||||||
phase: vec![0.0; fft_size / 2 + 1],
|
|
||||||
logmag: vec![0.0; fft_size / 2 + 1],
|
|
||||||
|
|
||||||
env: vec![0.0; fft_size / 2 + 1],
|
|
||||||
cep: vec![Complex::new(0.0, 0.0); fft_size],
|
|
||||||
prev_phase: vec![0.0; fft_size / 2 + 1],
|
|
||||||
out_phase: vec![0.0; fft_size / 2 + 1],
|
|
||||||
lifter_len: fft_size / 8,
|
|
||||||
pitch_ratio: 1.0,
|
|
||||||
formant_ratio: 1.0,
|
|
||||||
fs: sample_rate,
|
|
||||||
last_f0: 0.0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stream `input` through the shifter, appending the same number of
|
|
||||||
/// processed samples to `output` (length is preserved).
|
|
||||||
pub fn process(&mut self, input: &[f32], output: &mut Vec<f32>) {
|
|
||||||
for &sample in input {
|
|
||||||
self.in_buf.push(sample);
|
|
||||||
if self.in_buf.len() == self.n {
|
|
||||||
self.process_frame();
|
|
||||||
self.in_buf.drain(..self.hop);
|
|
||||||
output.extend_from_slice(&self.out_buf[..self.hop]);
|
|
||||||
self.out_buf.drain(..self.hop);
|
|
||||||
self.out_buf.resize(self.n, 0.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn process_frame(&mut self) {
|
|
||||||
let n2 = self.n / 2;
|
|
||||||
|
|
||||||
// Analysis: window + forward FFT.
|
|
||||||
for (i, c) in self.spec.iter_mut().enumerate() {
|
|
||||||
*c = Complex::new(self.in_buf[i] * self.window[i], 0.0);
|
|
||||||
}
|
|
||||||
self.fft.process(&mut self.spec);
|
|
||||||
|
|
||||||
for i in 0..=n2 {
|
|
||||||
let c = self.spec[i];
|
|
||||||
let m = c.norm();
|
|
||||||
self.mag[i] = m;
|
|
||||||
self.phase[i] = c.arg();
|
|
||||||
self.logmag[i] = (m + 1e-12).ln();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cepstral spectral-envelope estimation (used for formant control).
|
|
||||||
self.compute_envelope();
|
|
||||||
|
|
||||||
// Fundamental detection (harmonic product sum) for Auto-Tune.
|
|
||||||
self.detect_f0();
|
|
||||||
|
|
||||||
let pr = self.pitch_ratio;
|
|
||||||
let fr = self.formant_ratio;
|
|
||||||
let p_active = (pr - 1.0).abs() > 1e-4;
|
|
||||||
let f_active = (fr - 1.0).abs() > 1e-4;
|
|
||||||
|
|
||||||
if p_active || f_active {
|
|
||||||
// Phase propagation (Laroche & Dolson): track the instantaneous
|
|
||||||
// frequency of each input bin and advance the output phase so the
|
|
||||||
// reconstructed components stay coherent.
|
|
||||||
let hop = self.hop as f32;
|
|
||||||
let nf = self.n as f32;
|
|
||||||
let mut new_mag = vec![0.0; n2 + 1];
|
|
||||||
let mut new_phase = vec![0.0; n2 + 1];
|
|
||||||
for (b, nm) in new_mag.iter_mut().enumerate() {
|
|
||||||
let bf = b as f32;
|
|
||||||
|
|
||||||
// Source input bin for output bin b under the pitch shift.
|
|
||||||
let src = if p_active { bf / pr } else { bf };
|
|
||||||
let base = interpolate(&self.mag, src);
|
|
||||||
|
|
||||||
// Formants: re-weight the envelope so its shape is shifted by
|
|
||||||
// `fr` independent of pitch. After the pitch resample the
|
|
||||||
// envelope sits at E[bf/pr]; we move it to E[bf/fr].
|
|
||||||
if f_active {
|
|
||||||
let num = interpolate(&self.env, bf / fr);
|
|
||||||
let den = interpolate(&self.env, bf / pr);
|
|
||||||
let ratio = (num / den.max(1e-9)).clamp(0.3, 3.3);
|
|
||||||
*nm = base * ratio;
|
|
||||||
} else {
|
|
||||||
*nm = base;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Instantaneous frequency of the source bin (rad per frame).
|
|
||||||
let si = src.round().clamp(0.0, n2 as f32) as usize;
|
|
||||||
let omega_src = 2.0 * std::f32::consts::PI * si as f32 / nf;
|
|
||||||
let diff = princarg(self.phase[si] - self.prev_phase[si] - omega_src * hop);
|
|
||||||
let inst = omega_src + diff / hop;
|
|
||||||
|
|
||||||
// Output phase advances by the (pitch-shifted) source frequency.
|
|
||||||
let advance = if p_active {
|
|
||||||
inst * hop * pr
|
|
||||||
} else {
|
|
||||||
inst * hop
|
|
||||||
};
|
|
||||||
self.out_phase[b] += advance;
|
|
||||||
new_phase[b] = self.out_phase[b];
|
|
||||||
|
|
||||||
self.prev_phase[si] = self.phase[si];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rebuild the conjugate-symmetric spectrum.
|
|
||||||
for (b, (&nm, &ph)) in new_mag.iter().zip(new_phase.iter()).enumerate() {
|
|
||||||
self.spec[b] = Complex::from_polar(nm, ph);
|
|
||||||
if b > 0 && b < n2 {
|
|
||||||
self.spec[self.n - b] = Complex::from_polar(nm, -ph);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Synthesis: inverse FFT + window + overlap-add.
|
|
||||||
self.ifft.process(&mut self.spec);
|
|
||||||
let scale = 1.0 / (self.n as f32 * self.norm);
|
|
||||||
for i in 0..self.n {
|
|
||||||
let y = self.spec[i].re * self.window[i] * scale;
|
|
||||||
self.out_buf[i] += y;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fundamental frequency via harmonic product sum over the magnitude
|
|
||||||
/// spectrum. Sets `last_f0` (Hz), or 0.0 when the frame is unvoiced/weak.
|
|
||||||
fn detect_f0(&mut self) {
|
|
||||||
let n2 = self.n / 2;
|
|
||||||
let bin_hz = self.fs as f32 / self.n as f32;
|
|
||||||
// Search 70..1000 Hz, up to 5 harmonics.
|
|
||||||
let b_min = (70.0 / bin_hz).floor() as usize;
|
|
||||||
let b_max = (1000.0 / bin_hz).floor() as usize;
|
|
||||||
let mut best_bin = 0usize;
|
|
||||||
let mut best_score = 0.0f32;
|
|
||||||
for b in b_min..=b_max.min(n2 / 5) {
|
|
||||||
let mut score = 0.0;
|
|
||||||
for h in 1..=5 {
|
|
||||||
if b * h < n2 {
|
|
||||||
score += self.mag[b * h];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if score > best_score {
|
|
||||||
best_score = score;
|
|
||||||
best_bin = b;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Voicing threshold: fundamental bin must carry real energy.
|
|
||||||
let peak = self.mag[..n2].iter().cloned().fold(0.0f32, f32::max);
|
|
||||||
self.last_f0 = if best_bin > 0 && self.mag[best_bin] > peak * 0.05 && best_score > 0.0 {
|
|
||||||
best_bin as f32 * bin_hz
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Spectral envelope via homomorphic (cepstral) filtering.
|
|
||||||
fn compute_envelope(&mut self) {
|
|
||||||
let n2 = self.n / 2;
|
|
||||||
|
|
||||||
// Conjugate-symmetric spectrum of log-magnitude -> real cepstrum.
|
|
||||||
for i in 0..=n2 {
|
|
||||||
let l = self.logmag[i];
|
|
||||||
self.cep[i] = Complex::new(l, 0.0);
|
|
||||||
if i > 0 && i < n2 {
|
|
||||||
self.cep[self.n - i] = Complex::new(l, 0.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.env_ifft.process(&mut self.cep);
|
|
||||||
let inv = 1.0 / self.n as f32;
|
|
||||||
|
|
||||||
// Lifter: keep low quefrencies (smooth envelope), zero the rest.
|
|
||||||
for (i, c) in self.cep.iter_mut().enumerate() {
|
|
||||||
let keep = if i <= self.lifter_len { 1.0 } else { 0.0 };
|
|
||||||
c.re *= keep * inv;
|
|
||||||
c.im = 0.0;
|
|
||||||
}
|
|
||||||
self.env_fft.process(&mut self.cep);
|
|
||||||
|
|
||||||
for i in 0..=n2 {
|
|
||||||
self.env[i] = self.cep[i].re.exp().max(1e-6);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn sine(freq: f32, fs: u32, samples: usize) -> Vec<f32> {
|
|
||||||
(0..samples)
|
|
||||||
.map(|i| (2.0 * std::f32::consts::PI * freq * i as f32 / fs as f32).sin())
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Harmonic-rich tone (sawtooth): narrow pitch lines + broad envelope,
|
|
||||||
/// closer to a real voice than a pure sine.
|
|
||||||
fn saw(freq: f32, fs: u32, samples: usize) -> Vec<f32> {
|
|
||||||
(0..samples)
|
|
||||||
.map(|i| {
|
|
||||||
let t = freq * i as f32 / fs as f32;
|
|
||||||
let mut s = 0.0;
|
|
||||||
for h in 1..=12 {
|
|
||||||
s += ((2.0 * std::f32::consts::PI * h as f32 * t).sin()) / h as f32;
|
|
||||||
}
|
|
||||||
s
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rms(x: &[f32]) -> f32 {
|
|
||||||
let s: f32 = x.iter().map(|v| v * v).sum();
|
|
||||||
(s / x.len().max(1) as f32).sqrt()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn dominant_hz(x: &[f32], fs: u32) -> f32 {
|
|
||||||
use rustfft::FftPlanner;
|
|
||||||
let n = 4096;
|
|
||||||
if x.len() < n {
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
let mut planner = FftPlanner::<f32>::new();
|
|
||||||
let fft = planner.plan_fft_forward(n);
|
|
||||||
let mut buf: Vec<Complex<f32>> = x[..n].iter().map(|&s| Complex::new(s, 0.0)).collect();
|
|
||||||
fft.process(&mut buf);
|
|
||||||
let bins = n / 2;
|
|
||||||
let mags: Vec<f32> = (0..bins).map(|b| buf[b].norm()).collect();
|
|
||||||
// Harmonic product sum: find the fundamental whose harmonics add up.
|
|
||||||
let mut best = (0usize, 0.0f32);
|
|
||||||
for b in 20..(bins / 4) {
|
|
||||||
let mut s = 0.0;
|
|
||||||
for h in 1..=4 {
|
|
||||||
if b * h < bins {
|
|
||||||
s += mags[b * h];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if s > best.1 {
|
|
||||||
best = (b, s);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fs as f32 * best.0 as f32 / n as f32
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn bypass_preserves_signal() {
|
|
||||||
let mut sh = PitchShifter::new(1024, 256, 48000);
|
|
||||||
let input = saw(220.0, 48000, 48000);
|
|
||||||
let mut out = Vec::new();
|
|
||||||
sh.process(&input, &mut out);
|
|
||||||
assert!(out.len() >= 47000, "out.len={}", out.len());
|
|
||||||
assert!(rms(&out[1000..]).is_finite());
|
|
||||||
assert!(
|
|
||||||
rms(&out[1000..]) > 0.05,
|
|
||||||
"bypass rms too low: {}",
|
|
||||||
rms(&out[1000..])
|
|
||||||
);
|
|
||||||
|
|
||||||
use rustfft::FftPlanner;
|
|
||||||
let n = 4096;
|
|
||||||
let seg = &out[2000..2000 + n];
|
|
||||||
let mut planner = FftPlanner::<f32>::new();
|
|
||||||
let fft = planner.plan_fft_forward(n);
|
|
||||||
let mut buf: Vec<Complex<f32>> = seg.iter().map(|&s| Complex::new(s, 0.0)).collect();
|
|
||||||
fft.process(&mut buf);
|
|
||||||
let mut peaks: Vec<(usize, f32)> = (0..n / 2).map(|b| (b, buf[b].norm())).collect();
|
|
||||||
peaks.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
|
|
||||||
let top: Vec<String> = peaks
|
|
||||||
.iter()
|
|
||||||
.take(5)
|
|
||||||
.map(|(b, m)| format!("{:.0}Hz({m:.0})", b * 48000 / n))
|
|
||||||
.collect();
|
|
||||||
eprintln!("bypass peaks: {}", top.join(", "));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pitch_shift_produces_energy() {
|
|
||||||
let mut sh = PitchShifter::new(1024, 256, 48000);
|
|
||||||
sh.pitch_ratio = 2f32.powf(5.0 / 12.0);
|
|
||||||
let input = saw(220.0, 48000, 48000);
|
|
||||||
let mut out = Vec::new();
|
|
||||||
sh.process(&input, &mut out);
|
|
||||||
assert!(
|
|
||||||
out.iter().take(20000).all(|v| v.is_finite()),
|
|
||||||
"non-finite output"
|
|
||||||
);
|
|
||||||
let r = rms(&out[1000..20000]);
|
|
||||||
assert!(r > 0.05, "pitch-shifted rms too low: {r}");
|
|
||||||
|
|
||||||
let est = dominant_hz(&out[1000..20000], 48000);
|
|
||||||
assert!(
|
|
||||||
(est - 293.7).abs() < 8.0,
|
|
||||||
"expected ~293.7 Hz for 220*2^(5/12), got {est:.1} Hz"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn block_feed_matches_chain() {
|
|
||||||
// Mimics dsp::chain's block-by-block feeding with a pending buffer.
|
|
||||||
let mut sh = PitchShifter::new(1024, 256, 48000);
|
|
||||||
sh.pitch_ratio = 2f32.powf(5.0 / 12.0);
|
|
||||||
let input = sine(220.0, 48000, 48000);
|
|
||||||
let block = 2560;
|
|
||||||
let mut pending = Vec::new();
|
|
||||||
let mut out_all = Vec::new();
|
|
||||||
for chunk in input.chunks(block) {
|
|
||||||
let mut out = Vec::new();
|
|
||||||
sh.process(chunk, &mut out);
|
|
||||||
pending.extend(out);
|
|
||||||
let take = chunk.len().min(pending.len());
|
|
||||||
let mut mono = vec![0.0; chunk.len()];
|
|
||||||
mono[..take].copy_from_slice(&pending[..take]);
|
|
||||||
out_all.extend(mono);
|
|
||||||
pending.drain(..take);
|
|
||||||
}
|
|
||||||
assert!(out_all.iter().all(|v| v.is_finite()), "non-finite");
|
|
||||||
let r = rms(&out_all[4000..40000]);
|
|
||||||
assert!(r > 0.05, "block-feed rms too low: {r}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn one_shot_vs_block_fed() {
|
|
||||||
let mut a = PitchShifter::new(1024, 256, 48000);
|
|
||||||
a.pitch_ratio = 2f32.powf(5.0 / 12.0);
|
|
||||||
let input = saw(220.0, 48000, 48000);
|
|
||||||
let mut out_a = Vec::new();
|
|
||||||
a.process(&input, &mut out_a);
|
|
||||||
|
|
||||||
// Block-fed WITHOUT prime/pending, just concatenating shifter output.
|
|
||||||
let mut b = PitchShifter::new(1024, 256, 48000);
|
|
||||||
b.pitch_ratio = 2f32.powf(5.0 / 12.0);
|
|
||||||
let mut out_b = Vec::new();
|
|
||||||
for chunk in input.chunks(1024) {
|
|
||||||
b.process(chunk, &mut out_b);
|
|
||||||
}
|
|
||||||
let n = out_a.len().min(out_b.len());
|
|
||||||
assert!(n > 10000, "n={n}");
|
|
||||||
let mut max_diff = 0.0f32;
|
|
||||||
let mut idx = 0usize;
|
|
||||||
for i in 3000..n {
|
|
||||||
let d = (out_a[i] - out_b[i]).abs();
|
|
||||||
if d > max_diff {
|
|
||||||
max_diff = d;
|
|
||||||
idx = i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
eprintln!("max diff {max_diff} at {idx} of {n}");
|
|
||||||
assert!(
|
|
||||||
max_diff < 1e-3,
|
|
||||||
"block-fed differs from one-shot: max_diff={max_diff}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
80
src/dsp/preset/build.rs
Normal file
80
src/dsp/preset/build.rs
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
//! Effect construction: EffectSpec -> Box<dyn Effect>.
|
||||||
|
|
||||||
|
use super::EffectSpec;
|
||||||
|
use crate::dsp::fx::{self, Effect};
|
||||||
|
|
||||||
|
pub fn build_effects(specs: &[EffectSpec], sample_rate: u32) -> Vec<Box<dyn Effect>> {
|
||||||
|
specs.iter().map(|s| build_effect(s, sample_rate)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_effect(spec: &EffectSpec, sample_rate: u32) -> Box<dyn Effect> {
|
||||||
|
match spec {
|
||||||
|
EffectSpec::Distortion { drive, output } => Box::new(fx::Distortion::new(*drive, *output)),
|
||||||
|
EffectSpec::Overdrive { drive, output } => Box::new(fx::Overdrive::new(*drive, *output)),
|
||||||
|
EffectSpec::Bitcrush {
|
||||||
|
bits,
|
||||||
|
sample_rate_div,
|
||||||
|
max_gain,
|
||||||
|
target,
|
||||||
|
} => Box::new(fx::Bitcrush::new(
|
||||||
|
*bits,
|
||||||
|
*sample_rate_div,
|
||||||
|
*max_gain,
|
||||||
|
*target,
|
||||||
|
)),
|
||||||
|
EffectSpec::Reverb { room, damp, wet } => {
|
||||||
|
Box::new(fx::Reverb::new(sample_rate, *room, *damp, *wet))
|
||||||
|
}
|
||||||
|
EffectSpec::Chorus {
|
||||||
|
depth_ms,
|
||||||
|
rate_hz,
|
||||||
|
wet,
|
||||||
|
} => Box::new(fx::Chorus::new(sample_rate, *depth_ms, *rate_hz, *wet)),
|
||||||
|
EffectSpec::Flanger {
|
||||||
|
depth_ms,
|
||||||
|
rate_hz,
|
||||||
|
feedback,
|
||||||
|
wet,
|
||||||
|
} => Box::new(fx::Flanger::new(
|
||||||
|
sample_rate,
|
||||||
|
*depth_ms,
|
||||||
|
*rate_hz,
|
||||||
|
*feedback,
|
||||||
|
*wet,
|
||||||
|
)),
|
||||||
|
EffectSpec::Tremolo { depth, rate_hz } => {
|
||||||
|
Box::new(fx::Tremolo::new(sample_rate, *depth, *rate_hz))
|
||||||
|
}
|
||||||
|
EffectSpec::RingMod { freq_hz, depth } => {
|
||||||
|
Box::new(fx::RingMod::new(sample_rate, *freq_hz, *depth))
|
||||||
|
}
|
||||||
|
EffectSpec::Bandpass {
|
||||||
|
low_hz,
|
||||||
|
high_hz,
|
||||||
|
mix,
|
||||||
|
} => Box::new(fx::Bandpass::new(sample_rate, *low_hz, *high_hz, *mix)),
|
||||||
|
EffectSpec::Noise { gain } => Box::new(fx::Noise::new(*gain)),
|
||||||
|
EffectSpec::Compressor {
|
||||||
|
threshold_db,
|
||||||
|
ratio,
|
||||||
|
makeup_db,
|
||||||
|
} => Box::new(fx::Compressor::new(
|
||||||
|
sample_rate,
|
||||||
|
*threshold_db,
|
||||||
|
*ratio,
|
||||||
|
*makeup_db,
|
||||||
|
)),
|
||||||
|
EffectSpec::Vocoder {
|
||||||
|
bands,
|
||||||
|
carrier,
|
||||||
|
carrier_hz,
|
||||||
|
wet,
|
||||||
|
} => Box::new(fx::ChannelVocoder::new(
|
||||||
|
sample_rate,
|
||||||
|
*bands,
|
||||||
|
*carrier,
|
||||||
|
*carrier_hz,
|
||||||
|
*wet,
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
131
src/dsp/preset/mod.rs
Normal file
131
src/dsp/preset/mod.rs
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
//! Preset voice settings: pitch, formant, effect chain and gate.
|
||||||
|
//! Built at startup from built-ins plus custom presets from the config file.
|
||||||
|
|
||||||
|
pub mod build;
|
||||||
|
pub mod table_a;
|
||||||
|
pub mod table_b;
|
||||||
|
|
||||||
|
pub use build::build_effects;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
pub struct Preset {
|
||||||
|
pub name: String,
|
||||||
|
/// Pitch shift in semitones (positive = higher).
|
||||||
|
pub pitch_semitones: f32,
|
||||||
|
/// Formant ratio (1.0 = unchanged, >1 = higher/brighter).
|
||||||
|
pub formant_ratio: f32,
|
||||||
|
/// Effects applied in order after pitch/formant.
|
||||||
|
pub effects: Vec<EffectSpec>,
|
||||||
|
/// Output makeup gain so every preset sounds equally loud.
|
||||||
|
pub gain: f32,
|
||||||
|
/// Noise gate threshold for this preset (-100 = off).
|
||||||
|
pub gate_db: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
|
||||||
|
pub enum EffectSpec {
|
||||||
|
Distortion {
|
||||||
|
drive: f32,
|
||||||
|
output: f32,
|
||||||
|
},
|
||||||
|
Overdrive {
|
||||||
|
drive: f32,
|
||||||
|
output: f32,
|
||||||
|
},
|
||||||
|
Bitcrush {
|
||||||
|
bits: u8,
|
||||||
|
sample_rate_div: u32,
|
||||||
|
max_gain: f32,
|
||||||
|
target: f32,
|
||||||
|
},
|
||||||
|
Reverb {
|
||||||
|
room: f32,
|
||||||
|
damp: f32,
|
||||||
|
wet: f32,
|
||||||
|
},
|
||||||
|
Chorus {
|
||||||
|
depth_ms: f32,
|
||||||
|
rate_hz: f32,
|
||||||
|
wet: f32,
|
||||||
|
},
|
||||||
|
Flanger {
|
||||||
|
depth_ms: f32,
|
||||||
|
rate_hz: f32,
|
||||||
|
feedback: f32,
|
||||||
|
wet: f32,
|
||||||
|
},
|
||||||
|
Tremolo {
|
||||||
|
depth: f32,
|
||||||
|
rate_hz: f32,
|
||||||
|
},
|
||||||
|
RingMod {
|
||||||
|
freq_hz: f32,
|
||||||
|
depth: f32,
|
||||||
|
},
|
||||||
|
Bandpass {
|
||||||
|
low_hz: f32,
|
||||||
|
high_hz: f32,
|
||||||
|
mix: f32,
|
||||||
|
},
|
||||||
|
Noise {
|
||||||
|
gain: f32,
|
||||||
|
},
|
||||||
|
Compressor {
|
||||||
|
threshold_db: f32,
|
||||||
|
ratio: f32,
|
||||||
|
makeup_db: f32,
|
||||||
|
},
|
||||||
|
Vocoder {
|
||||||
|
bands: usize,
|
||||||
|
carrier: crate::dsp::fx::CarrierKind,
|
||||||
|
carrier_hz: f32,
|
||||||
|
wet: f32,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User-defined preset from the config file.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CustomPresetCfg {
|
||||||
|
pub pitch: Option<f32>,
|
||||||
|
pub formant: Option<f32>,
|
||||||
|
pub effects: Option<Vec<EffectSpec>>,
|
||||||
|
pub gate_db: Option<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
static PRESETS: OnceLock<Vec<Preset>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn builtin_presets() -> Vec<Preset> {
|
||||||
|
let mut all = table_a::table_a();
|
||||||
|
all.extend(table_b::table_b());
|
||||||
|
all
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register custom presets (from the config file). Call once at startup.
|
||||||
|
pub fn init_custom(customs: Vec<Preset>) {
|
||||||
|
let _ = PRESETS.get_or_init(|| {
|
||||||
|
let mut all = builtin_presets();
|
||||||
|
all.extend(customs);
|
||||||
|
all
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The full preset list (built-ins + customs).
|
||||||
|
pub fn presets() -> &'static [Preset] {
|
||||||
|
init_custom(Vec::new());
|
||||||
|
PRESETS.get_or_init(builtin_presets)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Preset {
|
||||||
|
pub fn find(name: &str) -> Option<usize> {
|
||||||
|
presets().iter().position(|p| {
|
||||||
|
p.name.eq_ignore_ascii_case(name)
|
||||||
|
|| p.name
|
||||||
|
.split('/')
|
||||||
|
.next()
|
||||||
|
.map(|s| s.trim().eq_ignore_ascii_case(name))
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
161
src/dsp/preset/table_a.rs
Normal file
161
src/dsp/preset/table_a.rs
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
//! Built-in preset table (part of the list).
|
||||||
|
|
||||||
|
use super::{EffectSpec, Preset};
|
||||||
|
use crate::dsp::fx::CarrierKind;
|
||||||
|
|
||||||
|
pub(crate) fn table_a() -> Vec<Preset> {
|
||||||
|
vec![
|
||||||
|
Preset {
|
||||||
|
name: "Clean".into(),
|
||||||
|
pitch_semitones: 0.0,
|
||||||
|
formant_ratio: 1.0,
|
||||||
|
effects: vec![],
|
||||||
|
gain: 1.1,
|
||||||
|
gate_db: -100.0,
|
||||||
|
},
|
||||||
|
Preset {
|
||||||
|
name: "Girl / Anime".into(),
|
||||||
|
pitch_semitones: 5.0,
|
||||||
|
formant_ratio: 1.25,
|
||||||
|
effects: vec![
|
||||||
|
EffectSpec::Chorus {
|
||||||
|
depth_ms: 4.0,
|
||||||
|
rate_hz: 0.6,
|
||||||
|
wet: 0.25,
|
||||||
|
},
|
||||||
|
EffectSpec::Overdrive {
|
||||||
|
drive: 1.4,
|
||||||
|
output: 1.0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
gain: 1.3,
|
||||||
|
gate_db: -45.0,
|
||||||
|
},
|
||||||
|
Preset {
|
||||||
|
name: "Boy".into(),
|
||||||
|
pitch_semitones: 2.0,
|
||||||
|
formant_ratio: 1.08,
|
||||||
|
effects: vec![EffectSpec::Compressor {
|
||||||
|
threshold_db: -20.0,
|
||||||
|
ratio: 3.0,
|
||||||
|
makeup_db: 4.0,
|
||||||
|
}],
|
||||||
|
gain: 1.1,
|
||||||
|
gate_db: -45.0,
|
||||||
|
},
|
||||||
|
Preset {
|
||||||
|
name: "Manly / Deep".into(),
|
||||||
|
pitch_semitones: -4.0,
|
||||||
|
formant_ratio: 0.78,
|
||||||
|
effects: vec![
|
||||||
|
EffectSpec::Compressor {
|
||||||
|
threshold_db: -20.0,
|
||||||
|
ratio: 3.0,
|
||||||
|
makeup_db: 2.0,
|
||||||
|
},
|
||||||
|
EffectSpec::Reverb {
|
||||||
|
room: 0.25,
|
||||||
|
damp: 0.5,
|
||||||
|
wet: 0.15,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
gain: 1.3,
|
||||||
|
gate_db: -40.0,
|
||||||
|
},
|
||||||
|
Preset {
|
||||||
|
name: "Demon".into(),
|
||||||
|
pitch_semitones: -9.0,
|
||||||
|
formant_ratio: 0.85,
|
||||||
|
effects: vec![
|
||||||
|
EffectSpec::Overdrive {
|
||||||
|
drive: 2.5,
|
||||||
|
output: 0.8,
|
||||||
|
},
|
||||||
|
EffectSpec::Reverb {
|
||||||
|
room: 0.5,
|
||||||
|
damp: 0.45,
|
||||||
|
wet: 0.3,
|
||||||
|
},
|
||||||
|
EffectSpec::Tremolo {
|
||||||
|
depth: 0.15,
|
||||||
|
rate_hz: 4.0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
gain: 1.0,
|
||||||
|
gate_db: -38.0,
|
||||||
|
},
|
||||||
|
Preset {
|
||||||
|
name: "Robot".into(),
|
||||||
|
pitch_semitones: 0.0,
|
||||||
|
formant_ratio: 1.0,
|
||||||
|
effects: vec![
|
||||||
|
EffectSpec::Vocoder {
|
||||||
|
bands: 16,
|
||||||
|
carrier: CarrierKind::Noise,
|
||||||
|
carrier_hz: 110.0,
|
||||||
|
wet: 1.0,
|
||||||
|
},
|
||||||
|
EffectSpec::Bitcrush {
|
||||||
|
bits: 8,
|
||||||
|
sample_rate_div: 2,
|
||||||
|
max_gain: 4.0,
|
||||||
|
target: 0.6,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
gain: 1.8,
|
||||||
|
gate_db: -40.0,
|
||||||
|
},
|
||||||
|
Preset {
|
||||||
|
name: "8-bit".into(),
|
||||||
|
pitch_semitones: 0.0,
|
||||||
|
formant_ratio: 1.0,
|
||||||
|
effects: vec![EffectSpec::Bitcrush {
|
||||||
|
bits: 5,
|
||||||
|
sample_rate_div: 4,
|
||||||
|
max_gain: 32.0,
|
||||||
|
target: 0.6,
|
||||||
|
}],
|
||||||
|
gain: 1.0,
|
||||||
|
gate_db: -45.0,
|
||||||
|
},
|
||||||
|
Preset {
|
||||||
|
name: "Alien".into(),
|
||||||
|
pitch_semitones: -2.0,
|
||||||
|
formant_ratio: 1.15,
|
||||||
|
effects: vec![
|
||||||
|
EffectSpec::RingMod {
|
||||||
|
freq_hz: 140.0,
|
||||||
|
depth: 0.5,
|
||||||
|
},
|
||||||
|
EffectSpec::Flanger {
|
||||||
|
depth_ms: 2.5,
|
||||||
|
rate_hz: 0.4,
|
||||||
|
feedback: 0.5,
|
||||||
|
wet: 0.5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
gain: 2.0,
|
||||||
|
gate_db: -40.0,
|
||||||
|
},
|
||||||
|
Preset {
|
||||||
|
name: "Radio".into(),
|
||||||
|
pitch_semitones: 0.0,
|
||||||
|
formant_ratio: 1.0,
|
||||||
|
effects: vec![
|
||||||
|
EffectSpec::Bandpass {
|
||||||
|
low_hz: 300.0,
|
||||||
|
high_hz: 3000.0,
|
||||||
|
mix: 0.9,
|
||||||
|
},
|
||||||
|
EffectSpec::Noise { gain: 0.04 },
|
||||||
|
EffectSpec::Compressor {
|
||||||
|
threshold_db: -24.0,
|
||||||
|
ratio: 4.0,
|
||||||
|
makeup_db: 6.0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
gain: 1.0,
|
||||||
|
gate_db: -40.0,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
195
src/dsp/preset/table_b.rs
Normal file
195
src/dsp/preset/table_b.rs
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
//! Built-in preset table (part of the list).
|
||||||
|
|
||||||
|
use super::{EffectSpec, Preset};
|
||||||
|
use crate::dsp::fx::CarrierKind;
|
||||||
|
|
||||||
|
pub(crate) fn table_b() -> Vec<Preset> {
|
||||||
|
vec![
|
||||||
|
Preset {
|
||||||
|
name: "Megaphone".into(),
|
||||||
|
pitch_semitones: 0.0,
|
||||||
|
formant_ratio: 1.0,
|
||||||
|
effects: vec![
|
||||||
|
EffectSpec::Distortion {
|
||||||
|
drive: 2.5,
|
||||||
|
output: 0.9,
|
||||||
|
},
|
||||||
|
EffectSpec::Bandpass {
|
||||||
|
low_hz: 800.0,
|
||||||
|
high_hz: 2600.0,
|
||||||
|
mix: 1.0,
|
||||||
|
},
|
||||||
|
EffectSpec::Compressor {
|
||||||
|
threshold_db: -18.0,
|
||||||
|
ratio: 5.0,
|
||||||
|
makeup_db: 4.0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
gain: 1.2,
|
||||||
|
gate_db: -38.0,
|
||||||
|
},
|
||||||
|
Preset {
|
||||||
|
name: "Ghost".into(),
|
||||||
|
pitch_semitones: -3.0,
|
||||||
|
formant_ratio: 1.05,
|
||||||
|
effects: vec![
|
||||||
|
EffectSpec::Reverb {
|
||||||
|
room: 0.85,
|
||||||
|
damp: 0.3,
|
||||||
|
wet: 0.6,
|
||||||
|
},
|
||||||
|
EffectSpec::Chorus {
|
||||||
|
depth_ms: 5.0,
|
||||||
|
rate_hz: 0.5,
|
||||||
|
wet: 0.3,
|
||||||
|
},
|
||||||
|
EffectSpec::Tremolo {
|
||||||
|
depth: 0.2,
|
||||||
|
rate_hz: 1.5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
gain: 0.80,
|
||||||
|
gate_db: -40.0,
|
||||||
|
},
|
||||||
|
Preset {
|
||||||
|
name: "Cyborg".into(),
|
||||||
|
pitch_semitones: 2.0,
|
||||||
|
formant_ratio: 1.0,
|
||||||
|
effects: vec![
|
||||||
|
EffectSpec::Vocoder {
|
||||||
|
bands: 16,
|
||||||
|
carrier: CarrierKind::Saw,
|
||||||
|
carrier_hz: 220.0,
|
||||||
|
wet: 0.9,
|
||||||
|
},
|
||||||
|
EffectSpec::Overdrive {
|
||||||
|
drive: 1.5,
|
||||||
|
output: 1.0,
|
||||||
|
},
|
||||||
|
EffectSpec::Bitcrush {
|
||||||
|
bits: 8,
|
||||||
|
sample_rate_div: 2,
|
||||||
|
max_gain: 4.0,
|
||||||
|
target: 0.6,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
gain: 1.8,
|
||||||
|
gate_db: -40.0,
|
||||||
|
},
|
||||||
|
Preset {
|
||||||
|
name: "Chipmunk".into(),
|
||||||
|
pitch_semitones: 7.0,
|
||||||
|
formant_ratio: 1.4,
|
||||||
|
effects: vec![EffectSpec::Chorus {
|
||||||
|
depth_ms: 3.0,
|
||||||
|
rate_hz: 0.8,
|
||||||
|
wet: 0.2,
|
||||||
|
}],
|
||||||
|
gain: 1.0,
|
||||||
|
gate_db: -45.0,
|
||||||
|
},
|
||||||
|
Preset {
|
||||||
|
name: "Helium".into(),
|
||||||
|
pitch_semitones: 10.0,
|
||||||
|
formant_ratio: 1.5,
|
||||||
|
effects: vec![EffectSpec::Chorus {
|
||||||
|
depth_ms: 5.0,
|
||||||
|
rate_hz: 0.6,
|
||||||
|
wet: 0.3,
|
||||||
|
}],
|
||||||
|
gain: 1.0,
|
||||||
|
gate_db: -45.0,
|
||||||
|
},
|
||||||
|
Preset {
|
||||||
|
name: "Vader".into(),
|
||||||
|
pitch_semitones: -10.0,
|
||||||
|
formant_ratio: 0.6,
|
||||||
|
effects: vec![
|
||||||
|
EffectSpec::Overdrive {
|
||||||
|
drive: 2.0,
|
||||||
|
output: 0.8,
|
||||||
|
},
|
||||||
|
EffectSpec::Reverb {
|
||||||
|
room: 0.6,
|
||||||
|
damp: 0.4,
|
||||||
|
wet: 0.3,
|
||||||
|
},
|
||||||
|
EffectSpec::Tremolo {
|
||||||
|
depth: 0.1,
|
||||||
|
rate_hz: 2.0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
gain: 1.4,
|
||||||
|
gate_db: -38.0,
|
||||||
|
},
|
||||||
|
Preset {
|
||||||
|
name: "Telephone".into(),
|
||||||
|
pitch_semitones: 0.0,
|
||||||
|
formant_ratio: 1.0,
|
||||||
|
effects: vec![
|
||||||
|
EffectSpec::Bandpass {
|
||||||
|
low_hz: 300.0,
|
||||||
|
high_hz: 3400.0,
|
||||||
|
mix: 1.0,
|
||||||
|
},
|
||||||
|
EffectSpec::Noise { gain: 0.02 },
|
||||||
|
EffectSpec::Compressor {
|
||||||
|
threshold_db: -22.0,
|
||||||
|
ratio: 4.0,
|
||||||
|
makeup_db: 8.0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
gain: 1.0,
|
||||||
|
gate_db: -42.0,
|
||||||
|
},
|
||||||
|
Preset {
|
||||||
|
name: "Walkie-Talkie".into(),
|
||||||
|
pitch_semitones: 0.0,
|
||||||
|
formant_ratio: 1.05,
|
||||||
|
effects: vec![
|
||||||
|
EffectSpec::Bandpass {
|
||||||
|
low_hz: 500.0,
|
||||||
|
high_hz: 2400.0,
|
||||||
|
mix: 1.0,
|
||||||
|
},
|
||||||
|
EffectSpec::Noise { gain: 0.05 },
|
||||||
|
EffectSpec::Compressor {
|
||||||
|
threshold_db: -20.0,
|
||||||
|
ratio: 4.0,
|
||||||
|
makeup_db: 4.0,
|
||||||
|
},
|
||||||
|
EffectSpec::Bitcrush {
|
||||||
|
bits: 8,
|
||||||
|
sample_rate_div: 2,
|
||||||
|
max_gain: 4.0,
|
||||||
|
target: 0.6,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
gain: 1.00,
|
||||||
|
gate_db: -40.0,
|
||||||
|
},
|
||||||
|
Preset {
|
||||||
|
name: "Growl".into(),
|
||||||
|
pitch_semitones: -5.0,
|
||||||
|
formant_ratio: 0.7,
|
||||||
|
effects: vec![
|
||||||
|
EffectSpec::Distortion {
|
||||||
|
drive: 4.0,
|
||||||
|
output: 0.6,
|
||||||
|
},
|
||||||
|
EffectSpec::Compressor {
|
||||||
|
threshold_db: -18.0,
|
||||||
|
ratio: 4.0,
|
||||||
|
makeup_db: 4.0,
|
||||||
|
},
|
||||||
|
EffectSpec::Reverb {
|
||||||
|
room: 0.3,
|
||||||
|
damp: 0.5,
|
||||||
|
wet: 0.2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
gain: 1.0,
|
||||||
|
gate_db: -38.0,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -1,543 +0,0 @@
|
||||||
//! Preset voice settings: pitch, formant and an effect chain.
|
|
||||||
//! The list is built at startup from built-in presets plus any custom presets
|
|
||||||
//! defined in `~/.config/vois/config.toml`.
|
|
||||||
|
|
||||||
use crate::dsp::effects::{self, CarrierKind, Effect};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::sync::OnceLock;
|
|
||||||
|
|
||||||
pub struct Preset {
|
|
||||||
pub name: String,
|
|
||||||
/// Pitch shift in semitones (positive = higher).
|
|
||||||
pub pitch_semitones: f32,
|
|
||||||
/// Formant ratio (1.0 = unchanged, >1 = higher/brighter).
|
|
||||||
pub formant_ratio: f32,
|
|
||||||
/// Effects applied in order after pitch/formant.
|
|
||||||
pub effects: Vec<EffectSpec>,
|
|
||||||
/// Output makeup gain so every preset sounds equally loud.
|
|
||||||
pub gain: f32,
|
|
||||||
/// Noise gate threshold for this preset (-100 = off).
|
|
||||||
pub gate_db: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
|
|
||||||
pub enum EffectSpec {
|
|
||||||
Distortion {
|
|
||||||
drive: f32,
|
|
||||||
output: f32,
|
|
||||||
},
|
|
||||||
Overdrive {
|
|
||||||
drive: f32,
|
|
||||||
output: f32,
|
|
||||||
},
|
|
||||||
Bitcrush {
|
|
||||||
bits: u8,
|
|
||||||
sample_rate_div: u32,
|
|
||||||
max_gain: f32,
|
|
||||||
target: f32,
|
|
||||||
},
|
|
||||||
Reverb {
|
|
||||||
room: f32,
|
|
||||||
damp: f32,
|
|
||||||
wet: f32,
|
|
||||||
},
|
|
||||||
Chorus {
|
|
||||||
depth_ms: f32,
|
|
||||||
rate_hz: f32,
|
|
||||||
wet: f32,
|
|
||||||
},
|
|
||||||
Flanger {
|
|
||||||
depth_ms: f32,
|
|
||||||
rate_hz: f32,
|
|
||||||
feedback: f32,
|
|
||||||
wet: f32,
|
|
||||||
},
|
|
||||||
Tremolo {
|
|
||||||
depth: f32,
|
|
||||||
rate_hz: f32,
|
|
||||||
},
|
|
||||||
RingMod {
|
|
||||||
freq_hz: f32,
|
|
||||||
depth: f32,
|
|
||||||
},
|
|
||||||
Bandpass {
|
|
||||||
low_hz: f32,
|
|
||||||
high_hz: f32,
|
|
||||||
mix: f32,
|
|
||||||
},
|
|
||||||
Noise {
|
|
||||||
gain: f32,
|
|
||||||
},
|
|
||||||
Compressor {
|
|
||||||
threshold_db: f32,
|
|
||||||
ratio: f32,
|
|
||||||
makeup_db: f32,
|
|
||||||
},
|
|
||||||
Vocoder {
|
|
||||||
bands: usize,
|
|
||||||
carrier: CarrierKind,
|
|
||||||
carrier_hz: f32,
|
|
||||||
wet: f32,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/// User-defined preset from the config file.
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct CustomPresetCfg {
|
|
||||||
pub pitch: Option<f32>,
|
|
||||||
pub formant: Option<f32>,
|
|
||||||
pub effects: Option<Vec<EffectSpec>>,
|
|
||||||
pub gate_db: Option<f32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
static PRESETS: OnceLock<Vec<Preset>> = OnceLock::new();
|
|
||||||
|
|
||||||
fn builtin_presets() -> Vec<Preset> {
|
|
||||||
vec![
|
|
||||||
Preset {
|
|
||||||
name: "Clean".into(),
|
|
||||||
pitch_semitones: 0.0,
|
|
||||||
formant_ratio: 1.0,
|
|
||||||
effects: vec![],
|
|
||||||
gain: 1.1,
|
|
||||||
gate_db: -100.0,
|
|
||||||
},
|
|
||||||
Preset {
|
|
||||||
name: "Girl / Anime".into(),
|
|
||||||
pitch_semitones: 5.0,
|
|
||||||
formant_ratio: 1.25,
|
|
||||||
effects: vec![
|
|
||||||
EffectSpec::Chorus {
|
|
||||||
depth_ms: 4.0,
|
|
||||||
rate_hz: 0.6,
|
|
||||||
wet: 0.25,
|
|
||||||
},
|
|
||||||
EffectSpec::Overdrive {
|
|
||||||
drive: 1.4,
|
|
||||||
output: 1.0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
gain: 1.3,
|
|
||||||
gate_db: -45.0,
|
|
||||||
},
|
|
||||||
Preset {
|
|
||||||
name: "Boy".into(),
|
|
||||||
pitch_semitones: 2.0,
|
|
||||||
formant_ratio: 1.08,
|
|
||||||
effects: vec![EffectSpec::Compressor {
|
|
||||||
threshold_db: -20.0,
|
|
||||||
ratio: 3.0,
|
|
||||||
makeup_db: 4.0,
|
|
||||||
}],
|
|
||||||
gain: 1.1,
|
|
||||||
gate_db: -45.0,
|
|
||||||
},
|
|
||||||
Preset {
|
|
||||||
name: "Manly / Deep".into(),
|
|
||||||
pitch_semitones: -4.0,
|
|
||||||
formant_ratio: 0.78,
|
|
||||||
effects: vec![
|
|
||||||
EffectSpec::Compressor {
|
|
||||||
threshold_db: -20.0,
|
|
||||||
ratio: 3.0,
|
|
||||||
makeup_db: 2.0,
|
|
||||||
},
|
|
||||||
EffectSpec::Reverb {
|
|
||||||
room: 0.25,
|
|
||||||
damp: 0.5,
|
|
||||||
wet: 0.15,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
gain: 1.3,
|
|
||||||
gate_db: -40.0,
|
|
||||||
},
|
|
||||||
Preset {
|
|
||||||
name: "Demon".into(),
|
|
||||||
pitch_semitones: -9.0,
|
|
||||||
formant_ratio: 0.85,
|
|
||||||
effects: vec![
|
|
||||||
EffectSpec::Overdrive {
|
|
||||||
drive: 2.5,
|
|
||||||
output: 0.8,
|
|
||||||
},
|
|
||||||
EffectSpec::Reverb {
|
|
||||||
room: 0.5,
|
|
||||||
damp: 0.45,
|
|
||||||
wet: 0.3,
|
|
||||||
},
|
|
||||||
EffectSpec::Tremolo {
|
|
||||||
depth: 0.15,
|
|
||||||
rate_hz: 4.0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
gain: 1.0,
|
|
||||||
gate_db: -38.0,
|
|
||||||
},
|
|
||||||
Preset {
|
|
||||||
name: "Robot".into(),
|
|
||||||
pitch_semitones: 0.0,
|
|
||||||
formant_ratio: 1.0,
|
|
||||||
effects: vec![
|
|
||||||
EffectSpec::Vocoder {
|
|
||||||
bands: 16,
|
|
||||||
carrier: CarrierKind::Noise,
|
|
||||||
carrier_hz: 110.0,
|
|
||||||
wet: 1.0,
|
|
||||||
},
|
|
||||||
EffectSpec::Bitcrush {
|
|
||||||
bits: 8,
|
|
||||||
sample_rate_div: 2,
|
|
||||||
max_gain: 4.0,
|
|
||||||
target: 0.6,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
gain: 1.8,
|
|
||||||
gate_db: -40.0,
|
|
||||||
},
|
|
||||||
Preset {
|
|
||||||
name: "8-bit".into(),
|
|
||||||
pitch_semitones: 0.0,
|
|
||||||
formant_ratio: 1.0,
|
|
||||||
effects: vec![EffectSpec::Bitcrush {
|
|
||||||
bits: 5,
|
|
||||||
sample_rate_div: 4,
|
|
||||||
max_gain: 32.0,
|
|
||||||
target: 0.6,
|
|
||||||
}],
|
|
||||||
gain: 1.0,
|
|
||||||
gate_db: -45.0,
|
|
||||||
},
|
|
||||||
Preset {
|
|
||||||
name: "Alien".into(),
|
|
||||||
pitch_semitones: -2.0,
|
|
||||||
formant_ratio: 1.15,
|
|
||||||
effects: vec![
|
|
||||||
EffectSpec::RingMod {
|
|
||||||
freq_hz: 140.0,
|
|
||||||
depth: 0.5,
|
|
||||||
},
|
|
||||||
EffectSpec::Flanger {
|
|
||||||
depth_ms: 2.5,
|
|
||||||
rate_hz: 0.4,
|
|
||||||
feedback: 0.5,
|
|
||||||
wet: 0.5,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
gain: 2.0,
|
|
||||||
gate_db: -40.0,
|
|
||||||
},
|
|
||||||
Preset {
|
|
||||||
name: "Radio".into(),
|
|
||||||
pitch_semitones: 0.0,
|
|
||||||
formant_ratio: 1.0,
|
|
||||||
effects: vec![
|
|
||||||
EffectSpec::Bandpass {
|
|
||||||
low_hz: 300.0,
|
|
||||||
high_hz: 3000.0,
|
|
||||||
mix: 0.9,
|
|
||||||
},
|
|
||||||
EffectSpec::Noise { gain: 0.04 },
|
|
||||||
EffectSpec::Compressor {
|
|
||||||
threshold_db: -24.0,
|
|
||||||
ratio: 4.0,
|
|
||||||
makeup_db: 6.0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
gain: 1.0,
|
|
||||||
gate_db: -40.0,
|
|
||||||
},
|
|
||||||
Preset {
|
|
||||||
name: "Megaphone".into(),
|
|
||||||
pitch_semitones: 0.0,
|
|
||||||
formant_ratio: 1.0,
|
|
||||||
effects: vec![
|
|
||||||
EffectSpec::Distortion {
|
|
||||||
drive: 2.5,
|
|
||||||
output: 0.9,
|
|
||||||
},
|
|
||||||
EffectSpec::Bandpass {
|
|
||||||
low_hz: 800.0,
|
|
||||||
high_hz: 2600.0,
|
|
||||||
mix: 1.0,
|
|
||||||
},
|
|
||||||
EffectSpec::Compressor {
|
|
||||||
threshold_db: -18.0,
|
|
||||||
ratio: 5.0,
|
|
||||||
makeup_db: 4.0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
gain: 1.2,
|
|
||||||
gate_db: -38.0,
|
|
||||||
},
|
|
||||||
Preset {
|
|
||||||
name: "Ghost".into(),
|
|
||||||
pitch_semitones: -3.0,
|
|
||||||
formant_ratio: 1.05,
|
|
||||||
effects: vec![
|
|
||||||
EffectSpec::Reverb {
|
|
||||||
room: 0.85,
|
|
||||||
damp: 0.3,
|
|
||||||
wet: 0.6,
|
|
||||||
},
|
|
||||||
EffectSpec::Chorus {
|
|
||||||
depth_ms: 5.0,
|
|
||||||
rate_hz: 0.5,
|
|
||||||
wet: 0.3,
|
|
||||||
},
|
|
||||||
EffectSpec::Tremolo {
|
|
||||||
depth: 0.2,
|
|
||||||
rate_hz: 1.5,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
gain: 0.80,
|
|
||||||
gate_db: -40.0,
|
|
||||||
},
|
|
||||||
Preset {
|
|
||||||
name: "Cyborg".into(),
|
|
||||||
pitch_semitones: 2.0,
|
|
||||||
formant_ratio: 1.0,
|
|
||||||
effects: vec![
|
|
||||||
EffectSpec::Vocoder {
|
|
||||||
bands: 16,
|
|
||||||
carrier: CarrierKind::Saw,
|
|
||||||
carrier_hz: 220.0,
|
|
||||||
wet: 0.9,
|
|
||||||
},
|
|
||||||
EffectSpec::Overdrive {
|
|
||||||
drive: 1.5,
|
|
||||||
output: 1.0,
|
|
||||||
},
|
|
||||||
EffectSpec::Bitcrush {
|
|
||||||
bits: 8,
|
|
||||||
sample_rate_div: 2,
|
|
||||||
max_gain: 4.0,
|
|
||||||
target: 0.6,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
gain: 1.8,
|
|
||||||
gate_db: -40.0,
|
|
||||||
},
|
|
||||||
Preset {
|
|
||||||
name: "Chipmunk".into(),
|
|
||||||
pitch_semitones: 7.0,
|
|
||||||
formant_ratio: 1.4,
|
|
||||||
effects: vec![EffectSpec::Chorus {
|
|
||||||
depth_ms: 3.0,
|
|
||||||
rate_hz: 0.8,
|
|
||||||
wet: 0.2,
|
|
||||||
}],
|
|
||||||
gain: 1.0,
|
|
||||||
gate_db: -45.0,
|
|
||||||
},
|
|
||||||
Preset {
|
|
||||||
name: "Helium".into(),
|
|
||||||
pitch_semitones: 10.0,
|
|
||||||
formant_ratio: 1.5,
|
|
||||||
effects: vec![EffectSpec::Chorus {
|
|
||||||
depth_ms: 5.0,
|
|
||||||
rate_hz: 0.6,
|
|
||||||
wet: 0.3,
|
|
||||||
}],
|
|
||||||
gain: 1.0,
|
|
||||||
gate_db: -45.0,
|
|
||||||
},
|
|
||||||
Preset {
|
|
||||||
name: "Vader".into(),
|
|
||||||
pitch_semitones: -10.0,
|
|
||||||
formant_ratio: 0.6,
|
|
||||||
effects: vec![
|
|
||||||
EffectSpec::Overdrive {
|
|
||||||
drive: 2.0,
|
|
||||||
output: 0.8,
|
|
||||||
},
|
|
||||||
EffectSpec::Reverb {
|
|
||||||
room: 0.6,
|
|
||||||
damp: 0.4,
|
|
||||||
wet: 0.3,
|
|
||||||
},
|
|
||||||
EffectSpec::Tremolo {
|
|
||||||
depth: 0.1,
|
|
||||||
rate_hz: 2.0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
gain: 1.4,
|
|
||||||
gate_db: -38.0,
|
|
||||||
},
|
|
||||||
Preset {
|
|
||||||
name: "Telephone".into(),
|
|
||||||
pitch_semitones: 0.0,
|
|
||||||
formant_ratio: 1.0,
|
|
||||||
effects: vec![
|
|
||||||
EffectSpec::Bandpass {
|
|
||||||
low_hz: 300.0,
|
|
||||||
high_hz: 3400.0,
|
|
||||||
mix: 1.0,
|
|
||||||
},
|
|
||||||
EffectSpec::Noise { gain: 0.02 },
|
|
||||||
EffectSpec::Compressor {
|
|
||||||
threshold_db: -22.0,
|
|
||||||
ratio: 4.0,
|
|
||||||
makeup_db: 8.0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
gain: 1.0,
|
|
||||||
gate_db: -42.0,
|
|
||||||
},
|
|
||||||
Preset {
|
|
||||||
name: "Walkie-Talkie".into(),
|
|
||||||
pitch_semitones: 0.0,
|
|
||||||
formant_ratio: 1.05,
|
|
||||||
effects: vec![
|
|
||||||
EffectSpec::Bandpass {
|
|
||||||
low_hz: 500.0,
|
|
||||||
high_hz: 2400.0,
|
|
||||||
mix: 1.0,
|
|
||||||
},
|
|
||||||
EffectSpec::Noise { gain: 0.05 },
|
|
||||||
EffectSpec::Compressor {
|
|
||||||
threshold_db: -20.0,
|
|
||||||
ratio: 4.0,
|
|
||||||
makeup_db: 4.0,
|
|
||||||
},
|
|
||||||
EffectSpec::Bitcrush {
|
|
||||||
bits: 8,
|
|
||||||
sample_rate_div: 2,
|
|
||||||
max_gain: 4.0,
|
|
||||||
target: 0.6,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
gain: 1.00,
|
|
||||||
gate_db: -40.0,
|
|
||||||
},
|
|
||||||
Preset {
|
|
||||||
name: "Growl".into(),
|
|
||||||
pitch_semitones: -5.0,
|
|
||||||
formant_ratio: 0.7,
|
|
||||||
effects: vec![
|
|
||||||
EffectSpec::Distortion {
|
|
||||||
drive: 4.0,
|
|
||||||
output: 0.6,
|
|
||||||
},
|
|
||||||
EffectSpec::Compressor {
|
|
||||||
threshold_db: -18.0,
|
|
||||||
ratio: 4.0,
|
|
||||||
makeup_db: 4.0,
|
|
||||||
},
|
|
||||||
EffectSpec::Reverb {
|
|
||||||
room: 0.3,
|
|
||||||
damp: 0.5,
|
|
||||||
wet: 0.2,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
gain: 1.0,
|
|
||||||
gate_db: -38.0,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn init_custom(customs: Vec<Preset>) {
|
|
||||||
let _ = PRESETS.get_or_init(|| {
|
|
||||||
let mut all = builtin_presets();
|
|
||||||
all.extend(customs);
|
|
||||||
all
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The full preset list (built-ins + customs).
|
|
||||||
pub fn presets() -> &'static [Preset] {
|
|
||||||
init_custom(Vec::new());
|
|
||||||
PRESETS.get_or_init(builtin_presets)
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Preset {
|
|
||||||
pub fn find(name: &str) -> Option<usize> {
|
|
||||||
presets().iter().position(|p| {
|
|
||||||
p.name.eq_ignore_ascii_case(name)
|
|
||||||
|| p.name
|
|
||||||
.split('/')
|
|
||||||
.next()
|
|
||||||
.map(|s| s.trim().eq_ignore_ascii_case(name))
|
|
||||||
.unwrap_or(false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build_effects(specs: &[EffectSpec], sample_rate: u32) -> Vec<Box<dyn Effect>> {
|
|
||||||
specs.iter().map(|s| build_effect(s, sample_rate)).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_effect(spec: &EffectSpec, sample_rate: u32) -> Box<dyn Effect> {
|
|
||||||
match spec {
|
|
||||||
EffectSpec::Distortion { drive, output } => {
|
|
||||||
Box::new(effects::Distortion::new(*drive, *output))
|
|
||||||
}
|
|
||||||
EffectSpec::Overdrive { drive, output } => {
|
|
||||||
Box::new(effects::Overdrive::new(*drive, *output))
|
|
||||||
}
|
|
||||||
EffectSpec::Bitcrush {
|
|
||||||
bits,
|
|
||||||
sample_rate_div,
|
|
||||||
max_gain,
|
|
||||||
target,
|
|
||||||
} => Box::new(effects::Bitcrush::new(
|
|
||||||
*bits,
|
|
||||||
*sample_rate_div,
|
|
||||||
*max_gain,
|
|
||||||
*target,
|
|
||||||
)),
|
|
||||||
EffectSpec::Reverb { room, damp, wet } => {
|
|
||||||
Box::new(effects::Reverb::new(sample_rate, *room, *damp, *wet))
|
|
||||||
}
|
|
||||||
EffectSpec::Chorus {
|
|
||||||
depth_ms,
|
|
||||||
rate_hz,
|
|
||||||
wet,
|
|
||||||
} => Box::new(effects::Chorus::new(sample_rate, *depth_ms, *rate_hz, *wet)),
|
|
||||||
EffectSpec::Flanger {
|
|
||||||
depth_ms,
|
|
||||||
rate_hz,
|
|
||||||
feedback,
|
|
||||||
wet,
|
|
||||||
} => Box::new(effects::Flanger::new(
|
|
||||||
sample_rate,
|
|
||||||
*depth_ms,
|
|
||||||
*rate_hz,
|
|
||||||
*feedback,
|
|
||||||
*wet,
|
|
||||||
)),
|
|
||||||
EffectSpec::Tremolo { depth, rate_hz } => {
|
|
||||||
Box::new(effects::Tremolo::new(sample_rate, *depth, *rate_hz))
|
|
||||||
}
|
|
||||||
EffectSpec::RingMod { freq_hz, depth } => {
|
|
||||||
Box::new(effects::RingMod::new(sample_rate, *freq_hz, *depth))
|
|
||||||
}
|
|
||||||
EffectSpec::Bandpass {
|
|
||||||
low_hz,
|
|
||||||
high_hz,
|
|
||||||
mix,
|
|
||||||
} => Box::new(effects::Bandpass::new(sample_rate, *low_hz, *high_hz, *mix)),
|
|
||||||
EffectSpec::Noise { gain } => Box::new(effects::Noise::new(*gain)),
|
|
||||||
EffectSpec::Compressor {
|
|
||||||
threshold_db,
|
|
||||||
ratio,
|
|
||||||
makeup_db,
|
|
||||||
} => Box::new(effects::Compressor::new(
|
|
||||||
sample_rate,
|
|
||||||
*threshold_db,
|
|
||||||
*ratio,
|
|
||||||
*makeup_db,
|
|
||||||
)),
|
|
||||||
EffectSpec::Vocoder {
|
|
||||||
bands,
|
|
||||||
carrier,
|
|
||||||
carrier_hz,
|
|
||||||
wet,
|
|
||||||
} => Box::new(effects::ChannelVocoder::new(
|
|
||||||
sample_rate,
|
|
||||||
*bands,
|
|
||||||
*carrier,
|
|
||||||
*carrier_hz,
|
|
||||||
*wet,
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
62
src/dsp/shift/analysis.rs
Normal file
62
src/dsp/shift/analysis.rs
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
//! Frame analysis: fundamental detection and cepstral envelope.
|
||||||
|
|
||||||
|
use super::shifter::PitchShifter;
|
||||||
|
use rustfft::num_complex::Complex;
|
||||||
|
|
||||||
|
impl PitchShifter {
|
||||||
|
/// Fundamental frequency via harmonic product sum. Sets `last_f0` (Hz),
|
||||||
|
/// or 0.0 when the frame is unvoiced/weak.
|
||||||
|
pub(crate) fn detect_f0(&mut self) {
|
||||||
|
let n2 = self.n / 2;
|
||||||
|
let bin_hz = self.fs as f32 / self.n as f32;
|
||||||
|
let b_min = (70.0 / bin_hz).floor() as usize;
|
||||||
|
let b_max = (1000.0 / bin_hz).floor() as usize;
|
||||||
|
let mut best_bin = 0usize;
|
||||||
|
let mut best_score = 0.0f32;
|
||||||
|
for b in b_min..=b_max.min(n2 / 5) {
|
||||||
|
let mut score = 0.0;
|
||||||
|
for h in 1..=5 {
|
||||||
|
if b * h < n2 {
|
||||||
|
score += self.mag[b * h];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if score > best_score {
|
||||||
|
best_score = score;
|
||||||
|
best_bin = b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let peak = self.mag[..n2].iter().cloned().fold(0.0f32, f32::max);
|
||||||
|
self.last_f0 = if best_bin > 0 && self.mag[best_bin] > peak * 0.05 && best_score > 0.0 {
|
||||||
|
best_bin as f32 * bin_hz
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spectral envelope via homomorphic (cepstral) filtering.
|
||||||
|
pub(crate) fn compute_envelope(&mut self) {
|
||||||
|
let n2 = self.n / 2;
|
||||||
|
|
||||||
|
for i in 0..=n2 {
|
||||||
|
let l = self.logmag[i];
|
||||||
|
self.cep[i] = Complex::new(l, 0.0);
|
||||||
|
if i > 0 && i < n2 {
|
||||||
|
self.cep[self.n - i] = Complex::new(l, 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.env_ifft.process(&mut self.cep);
|
||||||
|
let inv = 1.0 / self.n as f32;
|
||||||
|
|
||||||
|
// Lifter: keep low quefrencies (smooth envelope), zero the rest.
|
||||||
|
for (i, c) in self.cep.iter_mut().enumerate() {
|
||||||
|
let keep = if i <= self.lifter_len { 1.0 } else { 0.0 };
|
||||||
|
c.re *= keep * inv;
|
||||||
|
c.im = 0.0;
|
||||||
|
}
|
||||||
|
self.env_fft.process(&mut self.cep);
|
||||||
|
|
||||||
|
for i in 0..=n2 {
|
||||||
|
self.env[i] = self.cep[i].re.exp().max(1e-6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
91
src/dsp/shift/frame.rs
Normal file
91
src/dsp/shift/frame.rs
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
//! Per-frame FFT processing: pitch/formant resampling with phase propagation.
|
||||||
|
|
||||||
|
use super::shifter::PitchShifter;
|
||||||
|
use super::{interpolate, princarg};
|
||||||
|
use rustfft::num_complex::Complex;
|
||||||
|
|
||||||
|
impl PitchShifter {
|
||||||
|
pub(crate) fn process_frame(&mut self) {
|
||||||
|
let n2 = self.n / 2;
|
||||||
|
|
||||||
|
for (i, c) in self.spec.iter_mut().enumerate() {
|
||||||
|
*c = Complex::new(self.in_buf[i] * self.window[i], 0.0);
|
||||||
|
}
|
||||||
|
self.fft.process(&mut self.spec);
|
||||||
|
|
||||||
|
for i in 0..=n2 {
|
||||||
|
let c = self.spec[i];
|
||||||
|
let m = c.norm();
|
||||||
|
self.mag[i] = m;
|
||||||
|
self.phase[i] = c.arg();
|
||||||
|
self.logmag[i] = (m + 1e-12).ln();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.compute_envelope();
|
||||||
|
self.detect_f0();
|
||||||
|
|
||||||
|
let pr = self.pitch_ratio;
|
||||||
|
let fr = self.formant_ratio;
|
||||||
|
let p_active = (pr - 1.0).abs() > 1e-4;
|
||||||
|
let f_active = (fr - 1.0).abs() > 1e-4;
|
||||||
|
|
||||||
|
if p_active || f_active {
|
||||||
|
// Phase propagation (Laroche & Dolson): track the instantaneous
|
||||||
|
// frequency of each input bin so reconstructed components stay
|
||||||
|
// coherent across frames.
|
||||||
|
let hop = self.hop as f32;
|
||||||
|
let nf = self.n as f32;
|
||||||
|
let mut new_mag = vec![0.0; n2 + 1];
|
||||||
|
let mut new_phase = vec![0.0; n2 + 1];
|
||||||
|
for (b, nm) in new_mag.iter_mut().enumerate() {
|
||||||
|
let bf = b as f32;
|
||||||
|
|
||||||
|
// Source input bin for output bin b under the pitch shift.
|
||||||
|
let src = if p_active { bf / pr } else { bf };
|
||||||
|
let base = interpolate(&self.mag, src);
|
||||||
|
|
||||||
|
// Formants: move the envelope shape by `fr` independently.
|
||||||
|
if f_active {
|
||||||
|
let num = interpolate(&self.env, bf / fr);
|
||||||
|
let den = interpolate(&self.env, bf / pr);
|
||||||
|
let ratio = (num / den.max(1e-9)).clamp(0.3, 3.3);
|
||||||
|
*nm = base * ratio;
|
||||||
|
} else {
|
||||||
|
*nm = base;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instantaneous frequency of the source bin (rad per frame).
|
||||||
|
let si = src.round().clamp(0.0, n2 as f32) as usize;
|
||||||
|
let omega_src = 2.0 * std::f32::consts::PI * si as f32 / nf;
|
||||||
|
let diff = princarg(self.phase[si] - self.prev_phase[si] - omega_src * hop);
|
||||||
|
let inst = omega_src + diff / hop;
|
||||||
|
|
||||||
|
let advance = if p_active {
|
||||||
|
inst * hop * pr
|
||||||
|
} else {
|
||||||
|
inst * hop
|
||||||
|
};
|
||||||
|
self.out_phase[b] += advance;
|
||||||
|
new_phase[b] = self.out_phase[b];
|
||||||
|
|
||||||
|
self.prev_phase[si] = self.phase[si];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild the conjugate-symmetric spectrum.
|
||||||
|
for (b, (&nm, &ph)) in new_mag.iter().zip(new_phase.iter()).enumerate() {
|
||||||
|
self.spec[b] = Complex::from_polar(nm, ph);
|
||||||
|
if b > 0 && b < n2 {
|
||||||
|
self.spec[self.n - b] = Complex::from_polar(nm, -ph);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Synthesis: inverse FFT + window + overlap-add.
|
||||||
|
self.ifft.process(&mut self.spec);
|
||||||
|
let scale = 1.0 / (self.n as f32 * self.norm);
|
||||||
|
for i in 0..self.n {
|
||||||
|
let y = self.spec[i].re * self.window[i] * scale;
|
||||||
|
self.out_buf[i] += y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
149
src/dsp/shift/mod.rs
Normal file
149
src/dsp/shift/mod.rs
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
//! Real-time pitch/formant shifting via phase vocoder.
|
||||||
|
//!
|
||||||
|
//! Resamples the spectral *residual* to change pitch while keeping the
|
||||||
|
//! envelope (formants), and independently resamples the envelope to change
|
||||||
|
//! formants. Latency is one FFT frame (~21 ms @ 48 kHz).
|
||||||
|
|
||||||
|
pub mod shifter;
|
||||||
|
|
||||||
|
mod analysis;
|
||||||
|
mod frame;
|
||||||
|
|
||||||
|
pub use shifter::PitchShifter;
|
||||||
|
|
||||||
|
/// Linear interpolation over `arr` at fractional position `pos`.
|
||||||
|
pub(crate) fn interpolate(arr: &[f32], pos: f32) -> f32 {
|
||||||
|
let max = arr.len() as f32 - 1.0;
|
||||||
|
let pos = pos.clamp(0.0, max);
|
||||||
|
let i = pos.floor() as usize;
|
||||||
|
let j = (i + 1).min(arr.len() - 1);
|
||||||
|
let t = pos - i as f32;
|
||||||
|
arr[i] * (1.0 - t) + arr[j] * t
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrap a phase difference into (-PI, PI].
|
||||||
|
pub(crate) fn princarg(x: f32) -> f32 {
|
||||||
|
x - 2.0 * std::f32::consts::PI * (x / (2.0 * std::f32::consts::PI)).round()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use rustfft::num_complex::Complex;
|
||||||
|
|
||||||
|
fn saw(freq: f32, fs: u32, samples: usize) -> Vec<f32> {
|
||||||
|
(0..samples)
|
||||||
|
.map(|i| {
|
||||||
|
let t = freq * i as f32 / fs as f32;
|
||||||
|
let mut s = 0.0;
|
||||||
|
for h in 1..=12 {
|
||||||
|
s += ((2.0 * std::f32::consts::PI * h as f32 * t).sin()) / h as f32;
|
||||||
|
}
|
||||||
|
s
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sine(freq: f32, fs: u32, samples: usize) -> Vec<f32> {
|
||||||
|
(0..samples)
|
||||||
|
.map(|i| (2.0 * std::f32::consts::PI * freq * i as f32 / fs as f32).sin())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rms(x: &[f32]) -> f32 {
|
||||||
|
let s: f32 = x.iter().map(|v| v * v).sum();
|
||||||
|
(s / x.len().max(1) as f32).sqrt()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dominant_hz(x: &[f32], fs: u32) -> f32 {
|
||||||
|
use rustfft::FftPlanner;
|
||||||
|
let n = 4096;
|
||||||
|
if x.len() < n {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let mut planner = FftPlanner::<f32>::new();
|
||||||
|
let fft = planner.plan_fft_forward(n);
|
||||||
|
let mut buf: Vec<Complex<f32>> = x[..n].iter().map(|&s| Complex::new(s, 0.0)).collect();
|
||||||
|
fft.process(&mut buf);
|
||||||
|
let bins = n / 2;
|
||||||
|
let mags: Vec<f32> = (0..bins).map(|b| buf[b].norm()).collect();
|
||||||
|
let mut best = (0usize, 0.0f32);
|
||||||
|
for b in 20..(bins / 4) {
|
||||||
|
let mut s = 0.0;
|
||||||
|
for h in 1..=4 {
|
||||||
|
if b * h < bins {
|
||||||
|
s += mags[b * h];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s > best.1 {
|
||||||
|
best = (b, s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fs as f32 * best.0 as f32 / n as f32
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pitch_shift_produces_energy() {
|
||||||
|
let mut sh = PitchShifter::new(1024, 256, 48000);
|
||||||
|
sh.pitch_ratio = 2f32.powf(5.0 / 12.0);
|
||||||
|
let input = saw(220.0, 48000, 48000);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
sh.process(&input, &mut out);
|
||||||
|
assert!(out.iter().take(20000).all(|v| v.is_finite()), "non-finite");
|
||||||
|
let r = rms(&out[1000..20000]);
|
||||||
|
assert!(r > 0.05, "rms too low: {r}");
|
||||||
|
let est = dominant_hz(&out[1000..20000], 48000);
|
||||||
|
assert!(
|
||||||
|
(est - 293.7).abs() < 8.0,
|
||||||
|
"expected ~293.7 Hz, got {est:.1}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn block_feed_matches_chain() {
|
||||||
|
let mut sh = PitchShifter::new(1024, 256, 48000);
|
||||||
|
sh.pitch_ratio = 2f32.powf(5.0 / 12.0);
|
||||||
|
let input = sine(220.0, 48000, 48000);
|
||||||
|
let mut pending = Vec::new();
|
||||||
|
let mut out_all = Vec::new();
|
||||||
|
for chunk in input.chunks(2560) {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
sh.process(chunk, &mut out);
|
||||||
|
pending.extend(out);
|
||||||
|
let take = chunk.len().min(pending.len());
|
||||||
|
let mut mono = vec![0.0; chunk.len()];
|
||||||
|
mono[..take].copy_from_slice(&pending[..take]);
|
||||||
|
out_all.extend(mono);
|
||||||
|
pending.drain(..take);
|
||||||
|
}
|
||||||
|
assert!(out_all.iter().all(|v| v.is_finite()), "non-finite");
|
||||||
|
let r = rms(&out_all[4000..40000]);
|
||||||
|
assert!(r > 0.05, "block-feed rms too low: {r}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_shot_vs_block_fed() {
|
||||||
|
let mut a = PitchShifter::new(1024, 256, 48000);
|
||||||
|
a.pitch_ratio = 2f32.powf(5.0 / 12.0);
|
||||||
|
let input = saw(220.0, 48000, 48000);
|
||||||
|
let mut out_a = Vec::new();
|
||||||
|
a.process(&input, &mut out_a);
|
||||||
|
|
||||||
|
let mut b = PitchShifter::new(1024, 256, 48000);
|
||||||
|
b.pitch_ratio = 2f32.powf(5.0 / 12.0);
|
||||||
|
let mut out_b = Vec::new();
|
||||||
|
for chunk in input.chunks(1024) {
|
||||||
|
b.process(chunk, &mut out_b);
|
||||||
|
}
|
||||||
|
let n = out_a.len().min(out_b.len());
|
||||||
|
assert!(n > 10000, "n={n}");
|
||||||
|
let mut max_diff = 0.0f32;
|
||||||
|
for i in 3000..n {
|
||||||
|
let d = (out_a[i] - out_b[i]).abs();
|
||||||
|
if d > max_diff {
|
||||||
|
max_diff = d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(max_diff < 1e-3, "differs: max_diff={max_diff}");
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/dsp/shift/shifter.rs
Normal file
104
src/dsp/shift/shifter.rs
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
//! The phase-vocoder shifter: streaming interface and buffers.
|
||||||
|
|
||||||
|
use rustfft::num_complex::Complex;
|
||||||
|
use rustfft::{Fft, FftPlanner};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub struct PitchShifter {
|
||||||
|
pub(crate) n: usize,
|
||||||
|
pub(crate) hop: usize,
|
||||||
|
pub(crate) window: Vec<f32>,
|
||||||
|
pub(crate) norm: f32,
|
||||||
|
pub(crate) fft: Arc<dyn Fft<f32>>,
|
||||||
|
pub(crate) ifft: Arc<dyn Fft<f32>>,
|
||||||
|
pub(crate) env_ifft: Arc<dyn Fft<f32>>,
|
||||||
|
pub(crate) env_fft: Arc<dyn Fft<f32>>,
|
||||||
|
pub(crate) in_buf: Vec<f32>,
|
||||||
|
pub(crate) out_buf: Vec<f32>,
|
||||||
|
pub(crate) spec: Vec<Complex<f32>>,
|
||||||
|
pub(crate) mag: Vec<f32>,
|
||||||
|
pub(crate) phase: Vec<f32>,
|
||||||
|
pub(crate) logmag: Vec<f32>,
|
||||||
|
pub(crate) env: Vec<f32>,
|
||||||
|
pub(crate) cep: Vec<Complex<f32>>,
|
||||||
|
pub(crate) prev_phase: Vec<f32>,
|
||||||
|
pub(crate) out_phase: Vec<f32>,
|
||||||
|
pub(crate) lifter_len: usize,
|
||||||
|
pub(crate) fs: u32,
|
||||||
|
|
||||||
|
/// Pitch factor: output pitch = input pitch * ratio (1.0 = unchanged).
|
||||||
|
pub pitch_ratio: f32,
|
||||||
|
/// Formant factor: output formants = input formants * ratio (1.0 = unchanged).
|
||||||
|
pub formant_ratio: f32,
|
||||||
|
/// Detected fundamental of the most recent frame (Hz, 0 = unvoiced).
|
||||||
|
pub last_f0: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PitchShifter {
|
||||||
|
pub fn new(fft_size: usize, hop: usize, sample_rate: u32) -> Self {
|
||||||
|
debug_assert!(fft_size.is_multiple_of(hop));
|
||||||
|
let frames = fft_size / hop;
|
||||||
|
|
||||||
|
let mut window = vec![0.0; fft_size];
|
||||||
|
for (i, w) in window.iter_mut().enumerate() {
|
||||||
|
*w =
|
||||||
|
0.5 - 0.5 * (2.0 * std::f32::consts::PI * i as f32 / (fft_size as f32 - 1.0)).cos();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Steady-state OLA normalization (75% overlap: every sample is covered
|
||||||
|
// by `frames` windows; sum of w^2 over those offsets).
|
||||||
|
let mut norm = 0.0;
|
||||||
|
for i in 0..frames {
|
||||||
|
let idx = i * hop;
|
||||||
|
norm += window[idx] * window[idx];
|
||||||
|
}
|
||||||
|
debug_assert!(norm > 0.0);
|
||||||
|
|
||||||
|
let mut planner = FftPlanner::<f32>::new();
|
||||||
|
let fft = planner.plan_fft_forward(fft_size);
|
||||||
|
let ifft = planner.plan_fft_inverse(fft_size);
|
||||||
|
let env_ifft = planner.plan_fft_inverse(fft_size);
|
||||||
|
let env_fft = planner.plan_fft_forward(fft_size);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
n: fft_size,
|
||||||
|
hop,
|
||||||
|
window,
|
||||||
|
norm,
|
||||||
|
fft,
|
||||||
|
ifft,
|
||||||
|
env_ifft,
|
||||||
|
env_fft,
|
||||||
|
in_buf: Vec::new(),
|
||||||
|
out_buf: vec![0.0; fft_size],
|
||||||
|
spec: vec![Complex::new(0.0, 0.0); fft_size],
|
||||||
|
mag: vec![0.0; fft_size / 2 + 1],
|
||||||
|
phase: vec![0.0; fft_size / 2 + 1],
|
||||||
|
logmag: vec![0.0; fft_size / 2 + 1],
|
||||||
|
env: vec![0.0; fft_size / 2 + 1],
|
||||||
|
cep: vec![Complex::new(0.0, 0.0); fft_size],
|
||||||
|
prev_phase: vec![0.0; fft_size / 2 + 1],
|
||||||
|
out_phase: vec![0.0; fft_size / 2 + 1],
|
||||||
|
lifter_len: fft_size / 8,
|
||||||
|
fs: sample_rate,
|
||||||
|
pitch_ratio: 1.0,
|
||||||
|
formant_ratio: 1.0,
|
||||||
|
last_f0: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream `input` through the shifter, appending processed samples to
|
||||||
|
/// `output` (same count in steady state, plus startup latency).
|
||||||
|
pub fn process(&mut self, input: &[f32], output: &mut Vec<f32>) {
|
||||||
|
for &sample in input {
|
||||||
|
self.in_buf.push(sample);
|
||||||
|
if self.in_buf.len() == self.n {
|
||||||
|
self.process_frame();
|
||||||
|
self.in_buf.drain(..self.hop);
|
||||||
|
output.extend_from_slice(&self.out_buf[..self.hop]);
|
||||||
|
self.out_buf.drain(..self.hop);
|
||||||
|
self.out_buf.resize(self.n, 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
//! Runtime statistics produced by the DSP worker and shown in the TUI.
|
|
||||||
|
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
pub struct Stats {
|
|
||||||
/// Smoothed input level (RMS, linear).
|
|
||||||
pub input_rms: f32,
|
|
||||||
/// Smoothed output level (RMS, linear).
|
|
||||||
pub output_rms: f32,
|
|
||||||
pub peak_in: f32,
|
|
||||||
pub peak_out: f32,
|
|
||||||
pub blocks: u64,
|
|
||||||
pub samples: u64,
|
|
||||||
/// Smoothed DSP load in percent of one core.
|
|
||||||
pub cpu_pct: f32,
|
|
||||||
/// Estimated DSP latency in milliseconds.
|
|
||||||
pub dsp_latency_ms: f32,
|
|
||||||
pub started: Instant,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Stats {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
input_rms: 0.0,
|
|
||||||
output_rms: 0.0,
|
|
||||||
peak_in: 0.0,
|
|
||||||
peak_out: 0.0,
|
|
||||||
blocks: 0,
|
|
||||||
samples: 0,
|
|
||||||
cpu_pct: 0.0,
|
|
||||||
dsp_latency_ms: 0.0,
|
|
||||||
started: Instant::now(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -79,10 +79,10 @@ fn main() -> Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
||||||
let file = FileConfig::load();
|
let file = FileConfig::load();
|
||||||
dsp::presets::init_custom(file.to_presets());
|
dsp::preset::init_custom(file.to_presets());
|
||||||
|
|
||||||
if cli.list {
|
if cli.list {
|
||||||
audio::devices::print_all()?;
|
audio::print_all()?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
231
src/ui/app.rs
Normal file
231
src/ui/app.rs
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
//! App state, the live snapshot and keyboard / mouse handling.
|
||||||
|
|
||||||
|
use crate::dsp::control::Stats;
|
||||||
|
use crate::dsp::control::{Control, Mode};
|
||||||
|
use crate::dsp::preset::presets;
|
||||||
|
use crate::ui::SessionInfo;
|
||||||
|
use crossterm::event::{KeyCode, KeyModifiers};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
|
pub(super) enum View {
|
||||||
|
Main,
|
||||||
|
Settings,
|
||||||
|
Help,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) struct App {
|
||||||
|
pub(super) control: Arc<Mutex<Control>>,
|
||||||
|
pub(super) stats: Arc<Mutex<Stats>>,
|
||||||
|
pub(super) info: SessionInfo,
|
||||||
|
pub(super) view: View,
|
||||||
|
pub(super) settings_cursor: usize,
|
||||||
|
pub(super) splash_until: Instant,
|
||||||
|
pub(super) started: Instant,
|
||||||
|
pub(super) running: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy of the live control values used by the render layer.
|
||||||
|
pub(super) struct Snap {
|
||||||
|
pub(super) preset_idx: usize,
|
||||||
|
pub(super) name: String,
|
||||||
|
pub(super) pitch: f32,
|
||||||
|
pub(super) formant: f32,
|
||||||
|
pub(super) gate: f32,
|
||||||
|
pub(super) gain: f32,
|
||||||
|
pub(super) mute: bool,
|
||||||
|
pub(super) record: bool,
|
||||||
|
pub(super) tone: bool,
|
||||||
|
pub(super) tune: bool,
|
||||||
|
pub(super) mode: Mode,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
/// Current live values as a plain snapshot for rendering.
|
||||||
|
pub(super) fn snapshot(&self) -> Snap {
|
||||||
|
self.control
|
||||||
|
.lock()
|
||||||
|
.map(|c| {
|
||||||
|
let p = &presets()[c.preset_idx.min(presets().len() - 1)];
|
||||||
|
Snap {
|
||||||
|
preset_idx: c.preset_idx.min(presets().len() - 1),
|
||||||
|
name: p.name.to_string(),
|
||||||
|
pitch: p.pitch_semitones + c.pitch_delta,
|
||||||
|
formant: p.formant_ratio * 2f32.powf(c.formant_delta / 12.0),
|
||||||
|
gate: if c.gate_threshold_db > -90.0 {
|
||||||
|
c.gate_threshold_db
|
||||||
|
} else {
|
||||||
|
p.gate_db
|
||||||
|
},
|
||||||
|
gain: c.gain,
|
||||||
|
mute: c.mute,
|
||||||
|
record: c.record,
|
||||||
|
tone: c.tone_hz > 0.0,
|
||||||
|
tune: c.pitch_correct,
|
||||||
|
mode: c.mode,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or(Snap {
|
||||||
|
preset_idx: 0,
|
||||||
|
name: "Clean".into(),
|
||||||
|
pitch: 0.0,
|
||||||
|
formant: 1.0,
|
||||||
|
gate: -100.0,
|
||||||
|
gain: 1.0,
|
||||||
|
mute: false,
|
||||||
|
record: false,
|
||||||
|
tone: false,
|
||||||
|
tune: false,
|
||||||
|
mode: Mode::Live,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rendered value of a settings row.
|
||||||
|
pub(super) fn setting_value(&self, idx: usize) -> String {
|
||||||
|
let s = self.snapshot();
|
||||||
|
match idx {
|
||||||
|
0 => s.name,
|
||||||
|
1 => format!("{:+.1} st", s.pitch),
|
||||||
|
2 => format!("{:.2}×", s.formant),
|
||||||
|
3 => {
|
||||||
|
if s.gate <= -90.0 {
|
||||||
|
"off".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{:.0} dB", s.gate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
4 => format!("{:+.1} dB", 20.0 * s.gain.max(1e-6).log10()),
|
||||||
|
5 => if s.mute { "ON" } else { "off" }.to_string(),
|
||||||
|
6 => match s.mode {
|
||||||
|
Mode::Live => "LIVE".to_string(),
|
||||||
|
Mode::Passthrough => "PASSTHROUGH".to_string(),
|
||||||
|
},
|
||||||
|
7 => if s.record { "recording" } else { "off" }.to_string(),
|
||||||
|
8 => if s.tune { "ON" } else { "off" }.to_string(),
|
||||||
|
_ => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adjust a settings row. `step` is negative/positive; toggles ignore it.
|
||||||
|
fn adjust(&self, idx: usize, step: i32) {
|
||||||
|
self.with_ctrl(|c| match idx {
|
||||||
|
0 => {
|
||||||
|
let n = presets().len() as i32;
|
||||||
|
c.preset_idx = ((c.preset_idx as i32 + step).rem_euclid(n)) as usize;
|
||||||
|
}
|
||||||
|
1 => c.pitch_delta += step as f32,
|
||||||
|
2 => c.formant_delta += step as f32,
|
||||||
|
3 => {
|
||||||
|
c.gate_threshold_db =
|
||||||
|
(c.gate_threshold_db + step as f32 * 2.0).clamp(-100.0, -20.0);
|
||||||
|
}
|
||||||
|
4 => {
|
||||||
|
let db = 20.0 * c.gain.max(1e-6).log10() + step as f32;
|
||||||
|
c.gain = 10f32.powf(db / 20.0).clamp(0.0316, 4.0); // -30 .. +12 dB
|
||||||
|
}
|
||||||
|
5 => c.mute = !c.mute,
|
||||||
|
6 => c.mode.toggle(),
|
||||||
|
7 => c.record = !c.record,
|
||||||
|
8 => c.pitch_correct = !c.pitch_correct,
|
||||||
|
_ => {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn on_key(&mut self, key: crossterm::event::KeyEvent) {
|
||||||
|
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||||
|
let quit = key.code == KeyCode::Char('q')
|
||||||
|
|| (ctrl && (key.code == KeyCode::Char('c') || key.code == KeyCode::Char('q')));
|
||||||
|
|
||||||
|
// Any key dismisses the splash.
|
||||||
|
if Instant::now() < self.splash_until {
|
||||||
|
self.splash_until = Instant::now();
|
||||||
|
if quit {
|
||||||
|
self.set_quit();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if quit {
|
||||||
|
self.set_quit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.view {
|
||||||
|
View::Help => {
|
||||||
|
if key.code == KeyCode::Esc
|
||||||
|
|| key.code == KeyCode::Char('h')
|
||||||
|
|| key.code == KeyCode::Char('H')
|
||||||
|
{
|
||||||
|
self.view = View::Main;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
View::Main => self.on_key_main(key),
|
||||||
|
View::Settings => self.on_key_settings(key),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_key_main(&mut self, key: crossterm::event::KeyEvent) {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Up => self.with_ctrl(|c| {
|
||||||
|
c.preset_idx = (c.preset_idx + presets().len() - 1) % presets().len()
|
||||||
|
}),
|
||||||
|
KeyCode::Down => {
|
||||||
|
self.with_ctrl(|c| c.preset_idx = (c.preset_idx + 1) % presets().len())
|
||||||
|
}
|
||||||
|
KeyCode::Left => self.adjust(1, -1),
|
||||||
|
KeyCode::Right => self.adjust(1, 1),
|
||||||
|
KeyCode::Char('s') | KeyCode::Char('S') | KeyCode::Tab => self.view = View::Settings,
|
||||||
|
KeyCode::Char('h') | KeyCode::Char('H') => self.view = View::Help,
|
||||||
|
KeyCode::Char('p') | KeyCode::Char('P') => {
|
||||||
|
self.with_ctrl(|c| c.preset_idx = (c.preset_idx + 1) % presets().len())
|
||||||
|
}
|
||||||
|
KeyCode::Char(d) if d.is_ascii_digit() => self.with_ctrl(|c| {
|
||||||
|
let n = d.to_digit(10).unwrap() as usize;
|
||||||
|
c.preset_idx = if n == 0 {
|
||||||
|
presets().len() - 1
|
||||||
|
} else {
|
||||||
|
(n - 1).min(presets().len() - 1)
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
KeyCode::Char('[') => self.adjust(1, -1),
|
||||||
|
KeyCode::Char(']') => self.adjust(1, 1),
|
||||||
|
KeyCode::Char('{') => self.adjust(2, -1),
|
||||||
|
KeyCode::Char('}') => self.adjust(2, 1),
|
||||||
|
KeyCode::Char('g') | KeyCode::Char('G') => self.adjust(3, -1),
|
||||||
|
KeyCode::Char('b') | KeyCode::Char('B') => self.adjust(6, 1),
|
||||||
|
KeyCode::Char('m') | KeyCode::Char('M') => self.adjust(5, 1),
|
||||||
|
KeyCode::Char('r') | KeyCode::Char('R') => self.adjust(7, 1),
|
||||||
|
KeyCode::Char('+') | KeyCode::Char('=') => self.adjust(4, 1),
|
||||||
|
KeyCode::Char('-') => self.adjust(4, -1),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_key_settings(&mut self, key: crossterm::event::KeyEvent) {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Up => self.settings_cursor = self.settings_cursor.saturating_sub(1),
|
||||||
|
KeyCode::Down => self.settings_cursor = (self.settings_cursor + 1).min(7),
|
||||||
|
KeyCode::Left => self.adjust(self.settings_cursor, -1),
|
||||||
|
KeyCode::Right => self.adjust(self.settings_cursor, 1),
|
||||||
|
KeyCode::Enter => self.adjust(self.settings_cursor, 1),
|
||||||
|
KeyCode::Esc | KeyCode::Char('s') | KeyCode::Char('S') | KeyCode::Tab => {
|
||||||
|
self.view = View::Main
|
||||||
|
}
|
||||||
|
KeyCode::Char('h') | KeyCode::Char('H') => self.view = View::Help,
|
||||||
|
KeyCode::Char('q') => {}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn set_quit(&mut self) {
|
||||||
|
self.with_ctrl(|c| c.quit = true);
|
||||||
|
self.running = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn with_ctrl(&self, f: impl FnOnce(&mut Control)) {
|
||||||
|
if let Ok(mut c) = self.control.lock() {
|
||||||
|
f(&mut c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,13 @@
|
||||||
pub mod tui;
|
//! Ratatui full-screen interface: `app` keeps state, `render` draws screens
|
||||||
|
//! and drives the main loop, `widgets` provides shared helpers, `screens`
|
||||||
|
//! builds the widgets for each screen.
|
||||||
|
|
||||||
|
pub mod app;
|
||||||
|
pub mod render;
|
||||||
|
pub mod screens;
|
||||||
|
pub mod widgets;
|
||||||
|
|
||||||
|
pub use render::run;
|
||||||
|
|
||||||
/// Static information about the audio session, shown in the TUI.
|
/// Static information about the audio session, shown in the TUI.
|
||||||
pub struct SessionInfo {
|
pub struct SessionInfo {
|
||||||
|
|
|
||||||
215
src/ui/render.rs
Normal file
215
src/ui/render.rs
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
//! Frame rendering for each screen, mouse input and the main TUI run loop.
|
||||||
|
|
||||||
|
use crate::dsp::control::Control;
|
||||||
|
use crate::dsp::control::Stats;
|
||||||
|
use crate::dsp::preset::presets;
|
||||||
|
use crate::ui::app::{App, View};
|
||||||
|
use crate::ui::screens::{
|
||||||
|
header_line, help, params_paragraph, presets_paragraph, settings_paragraph, splash,
|
||||||
|
stats_column, StatsSnap,
|
||||||
|
};
|
||||||
|
use crate::ui::widgets::{self, main_layout, settings_layout};
|
||||||
|
use crate::ui::SessionInfo;
|
||||||
|
use crossterm::event::{self, Event, MouseButton, MouseEvent, MouseEventKind};
|
||||||
|
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
|
||||||
|
use ratatui::backend::CrosstermBackend;
|
||||||
|
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||||
|
use ratatui::style::{Color, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::Paragraph;
|
||||||
|
use ratatui::{Frame, Terminal};
|
||||||
|
use std::io::{stdout, IsTerminal, Write};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
fn render(&mut self, f: &mut Frame) {
|
||||||
|
if Instant::now() < self.splash_until {
|
||||||
|
splash(f, f.area());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match self.view {
|
||||||
|
View::Main => self.render_main(f),
|
||||||
|
View::Settings => self.render_settings(f),
|
||||||
|
View::Help => help(f, f.area()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_main(&mut self, f: &mut Frame) {
|
||||||
|
let area = f.area();
|
||||||
|
let (head, body, foot) = main_layout(area);
|
||||||
|
self.render_header(f, head);
|
||||||
|
|
||||||
|
let s = self.snapshot();
|
||||||
|
let cols = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Percentage(38),
|
||||||
|
Constraint::Percentage(28),
|
||||||
|
Constraint::Percentage(34),
|
||||||
|
])
|
||||||
|
.split(body);
|
||||||
|
|
||||||
|
f.render_widget(presets_paragraph(s.preset_idx), cols[0]);
|
||||||
|
f.render_widget(params_paragraph(&s), cols[1]);
|
||||||
|
self.render_stats(f, cols[2]);
|
||||||
|
|
||||||
|
self.render_footer(
|
||||||
|
f,
|
||||||
|
foot,
|
||||||
|
" ↑↓ preset | ←→ pitch | S settings | H help | Q quit ",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_settings(&mut self, f: &mut Frame) {
|
||||||
|
let area = f.area();
|
||||||
|
let (head, list_rect, foot) = settings_layout(area);
|
||||||
|
self.render_header(f, head);
|
||||||
|
|
||||||
|
let values: Vec<String> = (0..9).map(|i| self.setting_value(i)).collect();
|
||||||
|
f.render_widget(settings_paragraph(&values, self.settings_cursor), list_rect);
|
||||||
|
|
||||||
|
self.render_footer(
|
||||||
|
f,
|
||||||
|
foot,
|
||||||
|
" ↑↓ select | ←→ change | Esc back | H help | Q quit ",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_header(&self, f: &mut Frame, area: Rect) {
|
||||||
|
f.render_widget(Paragraph::new(header_line(&self.snapshot())), area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_footer(&self, f: &mut Frame, area: Rect, hint: &str) {
|
||||||
|
let footer = Line::from(Span::styled(hint, Style::default().fg(Color::DarkGray)));
|
||||||
|
f.render_widget(Paragraph::new(footer), area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_stats(&self, f: &mut Frame, area: Rect) {
|
||||||
|
let snap = self
|
||||||
|
.stats
|
||||||
|
.lock()
|
||||||
|
.map(|s| StatsSnap {
|
||||||
|
in_db: widgets::db(s.input_rms),
|
||||||
|
out_db: widgets::db(s.output_rms),
|
||||||
|
peak_in: widgets::db(s.peak_in),
|
||||||
|
peak_out: widgets::db(s.peak_out),
|
||||||
|
cpu: s.cpu_pct,
|
||||||
|
latency: s.dsp_latency_ms,
|
||||||
|
blocks: s.blocks,
|
||||||
|
samples: s.samples,
|
||||||
|
uptime: s.started.elapsed().as_secs(),
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
stats_column(f, area, &self.info, &snap);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a mouse click onto the current screen (preset / settings row).
|
||||||
|
fn on_mouse(&mut self, mouse: MouseEvent) {
|
||||||
|
if Instant::now() < self.splash_until {
|
||||||
|
self.splash_until = Instant::now();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let MouseEventKind::Down(MouseButton::Left) = mouse.kind {
|
||||||
|
let area = crossterm::terminal::size()
|
||||||
|
.map(|(w, h)| Rect::new(0, 0, w, h))
|
||||||
|
.unwrap_or(Rect::new(0, 0, 100, 30));
|
||||||
|
match self.view {
|
||||||
|
View::Main => {
|
||||||
|
let presets_rect = main_layout(area).0;
|
||||||
|
if mouse.row >= presets_rect.y
|
||||||
|
&& mouse.row < presets_rect.y + presets_rect.height
|
||||||
|
{
|
||||||
|
let idx = (mouse.row - presets_rect.y) as usize;
|
||||||
|
if idx < presets().len() {
|
||||||
|
self.with_ctrl(|c| c.preset_idx = idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
View::Settings => {
|
||||||
|
let list_rect = settings_layout(area).0;
|
||||||
|
if mouse.row >= list_rect.y && mouse.row < list_rect.y + list_rect.height {
|
||||||
|
let idx = (mouse.row - list_rect.y) as usize;
|
||||||
|
if idx < 9 {
|
||||||
|
self.settings_cursor = idx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
View::Help => self.view = View::Main,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run headless until `run_seconds` elapse or `quit` is set.
|
||||||
|
fn headless_wait(control: &Arc<Mutex<Control>>, run_seconds: u32) {
|
||||||
|
let started = Instant::now();
|
||||||
|
loop {
|
||||||
|
let done = control
|
||||||
|
.lock()
|
||||||
|
.map(|c| c.quit || run_seconds > 0 && started.elapsed().as_secs() >= run_seconds as u64)
|
||||||
|
.unwrap_or(true);
|
||||||
|
if done {
|
||||||
|
if let Ok(mut c) = control.lock() {
|
||||||
|
c.quit = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(control: Arc<Mutex<Control>>, stats: Arc<Mutex<Stats>>, info: SessionInfo) {
|
||||||
|
let run_seconds = control.lock().ok().and_then(|c| c.run_seconds).unwrap_or(0);
|
||||||
|
|
||||||
|
if !std::io::stdin().is_terminal() || enable_raw_mode().is_err() {
|
||||||
|
// No real terminal: keep the audio running headless.
|
||||||
|
headless_wait(&control, run_seconds);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let _ = crossterm::execute!(stdout(), crossterm::event::EnableMouseCapture);
|
||||||
|
let mut terminal = match Terminal::new(CrosstermBackend::new(stdout())) {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => {
|
||||||
|
disable_raw_mode().ok();
|
||||||
|
eprintln!("tui init failed: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = terminal.clear();
|
||||||
|
|
||||||
|
let mut app = App {
|
||||||
|
control,
|
||||||
|
stats,
|
||||||
|
info,
|
||||||
|
view: View::Main,
|
||||||
|
settings_cursor: 0,
|
||||||
|
splash_until: Instant::now() + Duration::from_millis(1500),
|
||||||
|
started: Instant::now(),
|
||||||
|
running: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
while app.running {
|
||||||
|
let _ = terminal.draw(|f| app.render(f));
|
||||||
|
let _ = stdout().flush();
|
||||||
|
|
||||||
|
if event::poll(Duration::from_millis(50)).unwrap_or(false) {
|
||||||
|
if let Ok(ev) = event::read() {
|
||||||
|
match ev {
|
||||||
|
Event::Key(key) => app.on_key(key),
|
||||||
|
Event::Mouse(m) => app.on_mouse(m),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if run_seconds > 0 && app.started.elapsed().as_secs() >= run_seconds as u64 {
|
||||||
|
app.set_quit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = terminal.show_cursor();
|
||||||
|
let _ = crossterm::execute!(stdout(), crossterm::event::DisableMouseCapture);
|
||||||
|
disable_raw_mode().ok();
|
||||||
|
println!("shutting down...");
|
||||||
|
}
|
||||||
150
src/ui/screens/main.rs
Normal file
150
src/ui/screens/main.rs
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
//! Splash, help, header, preset and parameter widgets of the main screen.
|
||||||
|
|
||||||
|
use crate::dsp::control::Mode;
|
||||||
|
use crate::dsp::preset::presets;
|
||||||
|
use crate::ui::app::Snap;
|
||||||
|
use crate::ui::widgets::{block, centered_rect, p_row, HELP_TEXT, LOGO};
|
||||||
|
use ratatui::layout::{Alignment, Rect};
|
||||||
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::Paragraph;
|
||||||
|
use ratatui::Frame;
|
||||||
|
|
||||||
|
/// Splash screen shown for the first ~1.5s.
|
||||||
|
pub(crate) fn splash(f: &mut Frame, area: Rect) {
|
||||||
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
|
for (i, l) in LOGO.iter().enumerate() {
|
||||||
|
let color = match i {
|
||||||
|
0 | 1 => Color::Cyan,
|
||||||
|
2 | 3 => Color::Magenta,
|
||||||
|
4 | 5 => Color::Blue,
|
||||||
|
_ => Color::Gray,
|
||||||
|
};
|
||||||
|
lines.push(Line::styled(
|
||||||
|
l.to_string(),
|
||||||
|
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
lines.push(Line::from(
|
||||||
|
" press any key, click, or use the arrow keys ...",
|
||||||
|
));
|
||||||
|
lines.push(Line::styled(
|
||||||
|
" audio is already running",
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
));
|
||||||
|
let para = Paragraph::new(lines).alignment(Alignment::Center);
|
||||||
|
f.render_widget(para, centered_rect(70, 55, area));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Static key reference screen.
|
||||||
|
pub(crate) fn help(f: &mut Frame, area: Rect) {
|
||||||
|
let para = Paragraph::new(HELP_TEXT)
|
||||||
|
.style(Style::default().fg(Color::White))
|
||||||
|
.block(block(" vois · help "));
|
||||||
|
f.render_widget(para, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Top status line: mode, record / tone / tune / mute indicators.
|
||||||
|
pub(crate) fn header_line(s: &Snap) -> Line<'static> {
|
||||||
|
let mode_txt = match s.mode {
|
||||||
|
Mode::Live => "LIVE",
|
||||||
|
Mode::Passthrough => "A/B PASSTHROUGH",
|
||||||
|
};
|
||||||
|
let rec = if s.record { "● REC" } else { "" };
|
||||||
|
let tone = if s.tone { "● TONE" } else { "" };
|
||||||
|
let tune = if s.tune { "● TUNE" } else { "" };
|
||||||
|
let mute = if s.mute { "MUTED" } else { "" };
|
||||||
|
let b = Modifier::BOLD;
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" VOIS ", Style::default().fg(Color::Cyan).add_modifier(b)),
|
||||||
|
Span::styled(
|
||||||
|
"real-time voice changer",
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Gray)
|
||||||
|
.add_modifier(Modifier::ITALIC),
|
||||||
|
),
|
||||||
|
Span::raw(" · "),
|
||||||
|
Span::styled(mode_txt, Style::default().fg(Color::Yellow).add_modifier(b)),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(rec, Style::default().fg(Color::Red).add_modifier(b)),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(tone, Style::default().fg(Color::Blue)),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(tune, Style::default().fg(Color::Magenta)),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(mute, Style::default().fg(Color::Red).add_modifier(b)),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Preset list column, current one highlighted.
|
||||||
|
pub(crate) fn presets_paragraph(preset_idx: usize) -> Paragraph<'static> {
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
for (i, p) in presets().iter().enumerate() {
|
||||||
|
let selected = i == preset_idx;
|
||||||
|
let label = format!(
|
||||||
|
"{}{} {}",
|
||||||
|
if selected { "▶" } else { " " },
|
||||||
|
if selected { "" } else { " " },
|
||||||
|
p.name
|
||||||
|
);
|
||||||
|
let style = if selected {
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::Gray)
|
||||||
|
};
|
||||||
|
lines.push(Line::styled(label, style));
|
||||||
|
}
|
||||||
|
Paragraph::new(lines).block(block(" presets · ↑↓ to switch "))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parameters column built from the live values.
|
||||||
|
pub(crate) fn params_paragraph(s: &Snap) -> Paragraph<'static> {
|
||||||
|
let gate = if s.gate <= -90.0 {
|
||||||
|
"off".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{:.0} dB", s.gate)
|
||||||
|
};
|
||||||
|
let gain = 20.0 * s.gain.max(1e-6).log10();
|
||||||
|
let lines = vec![
|
||||||
|
p_row("preset", &s.name, Color::Yellow, "voice character"),
|
||||||
|
p_row(
|
||||||
|
"pitch",
|
||||||
|
&format!("{:+.1} st", s.pitch),
|
||||||
|
Color::Cyan,
|
||||||
|
"higher=girl, lower=deep",
|
||||||
|
),
|
||||||
|
p_row(
|
||||||
|
"formant",
|
||||||
|
&format!("{:.2}×", s.formant),
|
||||||
|
Color::Magenta,
|
||||||
|
"brightness/timbre",
|
||||||
|
),
|
||||||
|
p_row("gate", &gate, Color::Blue, "noise below this dB is cut"),
|
||||||
|
p_row(
|
||||||
|
"gain",
|
||||||
|
&format!("{gain:+.1} dB"),
|
||||||
|
Color::Green,
|
||||||
|
"output loudness",
|
||||||
|
),
|
||||||
|
p_row(
|
||||||
|
"mute",
|
||||||
|
if s.mute { "ON" } else { "off" },
|
||||||
|
if s.mute { Color::Red } else { Color::DarkGray },
|
||||||
|
"silence output",
|
||||||
|
),
|
||||||
|
p_row(
|
||||||
|
"tune",
|
||||||
|
if s.tune { "ON" } else { "off" },
|
||||||
|
if s.tune {
|
||||||
|
Color::Magenta
|
||||||
|
} else {
|
||||||
|
Color::DarkGray
|
||||||
|
},
|
||||||
|
"auto-tune: snap pitch",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
Paragraph::new(lines).block(block(" parameters · info "))
|
||||||
|
}
|
||||||
9
src/ui/screens/mod.rs
Normal file
9
src/ui/screens/mod.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
//! Widget builders for each screen, one file per screen.
|
||||||
|
|
||||||
|
pub(super) mod main;
|
||||||
|
pub(super) mod settings;
|
||||||
|
pub(super) mod stats;
|
||||||
|
|
||||||
|
pub(super) use main::{header_line, help, params_paragraph, presets_paragraph, splash};
|
||||||
|
pub(super) use settings::settings_paragraph;
|
||||||
|
pub(super) use stats::{stats_column, StatsSnap};
|
||||||
52
src/ui/screens/settings.rs
Normal file
52
src/ui/screens/settings.rs
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
//! Settings screen widget builder.
|
||||||
|
|
||||||
|
use crate::ui::widgets::{block, SETTING_INFO};
|
||||||
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::Paragraph;
|
||||||
|
|
||||||
|
/// Settings rows from rendered values and the current cursor row.
|
||||||
|
pub(crate) fn settings_paragraph(values: &[String], cursor: usize) -> Paragraph<'static> {
|
||||||
|
let labels = [
|
||||||
|
"Preset",
|
||||||
|
"Pitch",
|
||||||
|
"Formant",
|
||||||
|
"Gate (min)",
|
||||||
|
"Gain",
|
||||||
|
"Mute",
|
||||||
|
"Mode",
|
||||||
|
"Record",
|
||||||
|
"Pitch corr",
|
||||||
|
];
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
for (i, label) in labels.iter().enumerate() {
|
||||||
|
let selected = i == cursor;
|
||||||
|
let style = if selected {
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::Gray)
|
||||||
|
};
|
||||||
|
let info_style = Style::default().fg(Color::DarkGray);
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
format!(
|
||||||
|
"{}{} {:<12}",
|
||||||
|
if selected { "▶" } else { " " },
|
||||||
|
if selected { "" } else { " " },
|
||||||
|
label
|
||||||
|
),
|
||||||
|
style,
|
||||||
|
),
|
||||||
|
Span::styled(format!("{:<16}", values[i]), style),
|
||||||
|
Span::styled(SETTING_INFO[i], info_style),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
lines.push(Line::styled(
|
||||||
|
" use ↑ ↓ to select, ← → (or Enter) to change, Esc to go back",
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
));
|
||||||
|
Paragraph::new(lines).block(block(" settings "))
|
||||||
|
}
|
||||||
121
src/ui/screens/stats.rs
Normal file
121
src/ui/screens/stats.rs
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
//! Stats screen widget: peak meters, dsp load, rates, uptime, devices.
|
||||||
|
|
||||||
|
use crate::ui::widgets::{meter_ratio, meter_style};
|
||||||
|
use crate::ui::SessionInfo;
|
||||||
|
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||||
|
use ratatui::style::{Color, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Gauge, Paragraph};
|
||||||
|
use ratatui::Frame;
|
||||||
|
|
||||||
|
/// Snapshot of the DSP stats column, pre-computed by the render layer.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct StatsSnap {
|
||||||
|
pub(crate) in_db: f32,
|
||||||
|
pub(crate) out_db: f32,
|
||||||
|
pub(crate) peak_in: f32,
|
||||||
|
pub(crate) peak_out: f32,
|
||||||
|
pub(crate) cpu: f32,
|
||||||
|
pub(crate) latency: f32,
|
||||||
|
pub(crate) blocks: u64,
|
||||||
|
pub(crate) samples: u64,
|
||||||
|
pub(crate) uptime: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stats column: meters on top, text lines below.
|
||||||
|
pub(crate) fn stats_column(f: &mut Frame, area: Rect, info: &SessionInfo, s: &StatsSnap) {
|
||||||
|
let secs = (s.uptime as f32).max(0.1);
|
||||||
|
let lines = vec![
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" peak in ", Style::default().fg(Color::DarkGray)),
|
||||||
|
Span::styled(format!("{:+.0} dB", s.peak_in), meter_style(s.peak_in)),
|
||||||
|
Span::styled(" peak out ", Style::default().fg(Color::DarkGray)),
|
||||||
|
Span::styled(format!("{:+.0} dB", s.peak_out), meter_style(s.peak_out)),
|
||||||
|
]),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" dsp load ", Style::default().fg(Color::DarkGray)),
|
||||||
|
Span::styled(
|
||||||
|
format!("{:.1} %", s.cpu),
|
||||||
|
if s.cpu > 50.0 {
|
||||||
|
Style::default().fg(Color::Red)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::Green)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled("latency", Style::default().fg(Color::DarkGray)),
|
||||||
|
Span::raw(format!(" {:.0} ms", s.latency)),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" rate ", Style::default().fg(Color::DarkGray)),
|
||||||
|
Span::raw(format!(
|
||||||
|
"{} Hz {} ch → {} ch ({} → {})",
|
||||||
|
info.sample_rate, info.in_ch, info.out_ch, info.format_in, info.format_out
|
||||||
|
)),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" uptime ", Style::default().fg(Color::DarkGray)),
|
||||||
|
Span::raw(format!(
|
||||||
|
"{:02}:{:02}:{:02}",
|
||||||
|
s.uptime / 3600,
|
||||||
|
(s.uptime / 60) % 60,
|
||||||
|
s.uptime % 60
|
||||||
|
)),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" blocks ", Style::default().fg(Color::DarkGray)),
|
||||||
|
Span::raw(format!(
|
||||||
|
"{} ({:.0} blk/s)",
|
||||||
|
s.blocks,
|
||||||
|
s.samples as f32 / secs
|
||||||
|
)),
|
||||||
|
]),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
format!(" in: {}", info.input),
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
)),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
format!(" out: {}", info.output),
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
)),
|
||||||
|
];
|
||||||
|
|
||||||
|
let vert = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(2),
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Length(2),
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Min(0),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
let in_gauge = Gauge::default()
|
||||||
|
.ratio(meter_ratio(s.in_db))
|
||||||
|
.gauge_style(meter_style(s.in_db));
|
||||||
|
let out_gauge = Gauge::default()
|
||||||
|
.ratio(meter_ratio(s.out_db))
|
||||||
|
.gauge_style(meter_style(s.out_db));
|
||||||
|
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(Line::from(vec![
|
||||||
|
Span::raw(" in "),
|
||||||
|
Span::styled(format!("{:+.0} dB", s.in_db), meter_style(s.in_db)),
|
||||||
|
])),
|
||||||
|
vert[0],
|
||||||
|
);
|
||||||
|
f.render_widget(in_gauge, vert[1]);
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(Line::from(vec![
|
||||||
|
Span::raw(" out "),
|
||||||
|
Span::styled(format!("{:+.0} dB", s.out_db), meter_style(s.out_db)),
|
||||||
|
])),
|
||||||
|
vert[2],
|
||||||
|
);
|
||||||
|
f.render_widget(out_gauge, vert[3]);
|
||||||
|
f.render_widget(Paragraph::new(lines), vert[4]);
|
||||||
|
}
|
||||||
888
src/ui/tui.rs
888
src/ui/tui.rs
|
|
@ -1,888 +0,0 @@
|
||||||
//! Full-screen interactive TUI built on ratatui: splash, dashboard with
|
|
||||||
//! presets / parameters / levels / stats, and a settings screen with arrow-key
|
|
||||||
//! and mouse control.
|
|
||||||
|
|
||||||
use crate::dsp::chain::{Control, Mode};
|
|
||||||
use crate::dsp::presets::presets;
|
|
||||||
use crate::dsp::stats::Stats;
|
|
||||||
use crate::ui::SessionInfo;
|
|
||||||
use crossterm::event::{
|
|
||||||
self, Event, KeyCode, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
|
|
||||||
};
|
|
||||||
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
|
|
||||||
use ratatui::backend::CrosstermBackend;
|
|
||||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
|
||||||
use ratatui::style::{Color, Modifier, Style};
|
|
||||||
use ratatui::text::{Line, Span};
|
|
||||||
use ratatui::widgets::{Block, Borders, Gauge, Paragraph};
|
|
||||||
use ratatui::{Frame, Terminal};
|
|
||||||
use std::io::{stdout, IsTerminal, Write};
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
const LOGO: [&str; 7] = [
|
|
||||||
" ██╗ ██╗ ██████╗ ██╗███████╗ ",
|
|
||||||
" ██║ ██║██╔═══██╗██║██╔════╝ ",
|
|
||||||
" ██║ ██║██║ ██║██║███████╗ ",
|
|
||||||
" ╚██╗ ██╔╝██║ ██║██║╚════██║ ",
|
|
||||||
" ╚████╔╝ ╚██████╔╝██║███████║ ",
|
|
||||||
" ╚═══╝ ╚═════╝ ╚═╝╚══════╝ ",
|
|
||||||
" real-time voice changer ",
|
|
||||||
];
|
|
||||||
|
|
||||||
const SETTING_INFO: [&str; 9] = [
|
|
||||||
"voice character, previewed live",
|
|
||||||
"pitch: higher = girl/kid, lower = deep",
|
|
||||||
"formant: brightness / timbre of the voice",
|
|
||||||
"noise gate: audio below this dB is cut",
|
|
||||||
"output loudness, in dB",
|
|
||||||
"silence the output",
|
|
||||||
"LIVE = processed, PASSTHROUGH = raw input (A/B)",
|
|
||||||
"write processed audio to a WAV file",
|
|
||||||
"auto-tune: snap pitch to the nearest semitone",
|
|
||||||
];
|
|
||||||
|
|
||||||
const HELP_TEXT: &str = "\
|
|
||||||
KEY / MOUSE ACTION
|
|
||||||
───────────────────────────────────────────────
|
|
||||||
↑ ↓ (or mouse) select preset / settings row
|
|
||||||
← → change value
|
|
||||||
Enter toggle / apply
|
|
||||||
S / Tab toggle SETTINGS screen
|
|
||||||
Esc back to main screen
|
|
||||||
H toggle this help screen
|
|
||||||
Q / Ctrl+C quit
|
|
||||||
0 - 9 jump to preset
|
|
||||||
M mute R record
|
|
||||||
B A/B passthrough mode
|
|
||||||
";
|
|
||||||
|
|
||||||
const METER_FLOOR_DB: f32 = -60.0;
|
|
||||||
|
|
||||||
fn db(rms: f32) -> f32 {
|
|
||||||
if rms <= 0.0 {
|
|
||||||
METER_FLOOR_DB
|
|
||||||
} else {
|
|
||||||
(20.0 * rms.log10()).clamp(METER_FLOOR_DB, 6.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn meter_ratio(db: f32) -> f64 {
|
|
||||||
((db - METER_FLOOR_DB) / -METER_FLOOR_DB).clamp(0.0, 1.0) as f64
|
|
||||||
}
|
|
||||||
|
|
||||||
fn meter_style(level_db: f32) -> Style {
|
|
||||||
if level_db >= -6.0 {
|
|
||||||
Style::default().fg(Color::Red)
|
|
||||||
} else if level_db >= -20.0 {
|
|
||||||
Style::default().fg(Color::Yellow)
|
|
||||||
} else {
|
|
||||||
Style::default().fg(Color::Green)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq)]
|
|
||||||
enum View {
|
|
||||||
Main,
|
|
||||||
Settings,
|
|
||||||
Help,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct App {
|
|
||||||
control: Arc<Mutex<Control>>,
|
|
||||||
stats: Arc<Mutex<Stats>>,
|
|
||||||
info: SessionInfo,
|
|
||||||
view: View,
|
|
||||||
settings_cursor: usize,
|
|
||||||
splash_until: Instant,
|
|
||||||
started: Instant,
|
|
||||||
running: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl App {
|
|
||||||
/// (preset_idx, preset_name, pitch_st, formant_ratio, gate_db, gain_lin,
|
|
||||||
/// mute, record, tone, mode)
|
|
||||||
/// (preset_idx, preset_name, pitch_st, formant_ratio, gate_db, gain_lin,
|
|
||||||
/// mute, record, tone, pitch_correct, mode)
|
|
||||||
fn snapshot(
|
|
||||||
&self,
|
|
||||||
) -> (
|
|
||||||
usize,
|
|
||||||
String,
|
|
||||||
f32,
|
|
||||||
f32,
|
|
||||||
f32,
|
|
||||||
f32,
|
|
||||||
bool,
|
|
||||||
bool,
|
|
||||||
bool,
|
|
||||||
bool,
|
|
||||||
Mode,
|
|
||||||
) {
|
|
||||||
self.control
|
|
||||||
.lock()
|
|
||||||
.map(|c| {
|
|
||||||
let preset = &presets()[c.preset_idx.min(presets().len() - 1)];
|
|
||||||
(
|
|
||||||
c.preset_idx.min(presets().len() - 1),
|
|
||||||
preset.name.to_string(),
|
|
||||||
preset.pitch_semitones + c.pitch_delta,
|
|
||||||
preset.formant_ratio * 2f32.powf(c.formant_delta / 12.0),
|
|
||||||
if c.gate_threshold_db > -90.0 {
|
|
||||||
c.gate_threshold_db
|
|
||||||
} else {
|
|
||||||
preset.gate_db
|
|
||||||
},
|
|
||||||
c.gain,
|
|
||||||
c.mute,
|
|
||||||
c.record,
|
|
||||||
c.tone_hz > 0.0,
|
|
||||||
c.pitch_correct,
|
|
||||||
c.mode,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.unwrap_or((
|
|
||||||
0,
|
|
||||||
"Clean".into(),
|
|
||||||
0.0,
|
|
||||||
1.0,
|
|
||||||
-100.0,
|
|
||||||
1.0,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
Mode::Live,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn setting_value(&self, idx: usize) -> String {
|
|
||||||
let (_, _name, pitch, formant, gate, gain, mute, record, _tone, tune, mode) =
|
|
||||||
self.snapshot();
|
|
||||||
match idx {
|
|
||||||
0 => presets()[self
|
|
||||||
.control
|
|
||||||
.lock()
|
|
||||||
.map(|c| c.preset_idx.min(presets().len() - 1))
|
|
||||||
.unwrap_or(0)]
|
|
||||||
.name
|
|
||||||
.to_string(),
|
|
||||||
1 => format!("{pitch:+.1} st"),
|
|
||||||
2 => format!("{formant:.2}×"),
|
|
||||||
3 => {
|
|
||||||
if gate <= -90.0 {
|
|
||||||
"off".to_string()
|
|
||||||
} else {
|
|
||||||
format!("{gate:.0} dB")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
4 => format!("{:+.1} dB", 20.0 * gain.max(1e-6).log10()),
|
|
||||||
5 => if mute { "ON" } else { "off" }.to_string(),
|
|
||||||
6 => match mode {
|
|
||||||
Mode::Live => "LIVE".to_string(),
|
|
||||||
Mode::Passthrough => "PASSTHROUGH".to_string(),
|
|
||||||
},
|
|
||||||
7 => if record { "recording" } else { "off" }.to_string(),
|
|
||||||
8 => if tune { "ON" } else { "off" }.to_string(),
|
|
||||||
_ => String::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Adjust a settings row. `step` is negative/positive; toggles ignore it.
|
|
||||||
fn adjust(&self, idx: usize, step: i32) {
|
|
||||||
self.with_ctrl(|c| match idx {
|
|
||||||
0 => {
|
|
||||||
let n = presets().len() as i32;
|
|
||||||
c.preset_idx = ((c.preset_idx as i32 + step).rem_euclid(n)) as usize;
|
|
||||||
}
|
|
||||||
1 => c.pitch_delta += step as f32,
|
|
||||||
2 => c.formant_delta += step as f32,
|
|
||||||
3 => {
|
|
||||||
c.gate_threshold_db =
|
|
||||||
(c.gate_threshold_db + step as f32 * 2.0).clamp(-100.0, -20.0);
|
|
||||||
}
|
|
||||||
4 => {
|
|
||||||
let db = 20.0 * c.gain.max(1e-6).log10() + step as f32;
|
|
||||||
c.gain = 10f32.powf(db / 20.0).clamp(0.0316, 4.0); // -30 .. +12 dB
|
|
||||||
}
|
|
||||||
5 => c.mute = !c.mute,
|
|
||||||
6 => c.mode.toggle(),
|
|
||||||
7 => c.record = !c.record,
|
|
||||||
8 => c.pitch_correct = !c.pitch_correct,
|
|
||||||
_ => {}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn on_key(&mut self, key: crossterm::event::KeyEvent) {
|
|
||||||
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
|
||||||
let quit = key.code == KeyCode::Char('q')
|
|
||||||
|| (ctrl && (key.code == KeyCode::Char('c') || key.code == KeyCode::Char('q')));
|
|
||||||
|
|
||||||
// Any key dismisses the splash.
|
|
||||||
if Instant::now() < self.splash_until {
|
|
||||||
self.splash_until = Instant::now();
|
|
||||||
if quit {
|
|
||||||
self.set_quit();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if quit {
|
|
||||||
self.set_quit();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
match self.view {
|
|
||||||
View::Help => {
|
|
||||||
if key.code == KeyCode::Esc
|
|
||||||
|| key.code == KeyCode::Char('h')
|
|
||||||
|| key.code == KeyCode::Char('H')
|
|
||||||
{
|
|
||||||
self.view = View::Main;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
View::Main => self.on_key_main(key),
|
|
||||||
View::Settings => self.on_key_settings(key),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn on_key_main(&mut self, key: crossterm::event::KeyEvent) {
|
|
||||||
match key.code {
|
|
||||||
KeyCode::Up => self.with_ctrl(|c| {
|
|
||||||
c.preset_idx = (c.preset_idx + presets().len() - 1) % presets().len()
|
|
||||||
}),
|
|
||||||
KeyCode::Down => {
|
|
||||||
self.with_ctrl(|c| c.preset_idx = (c.preset_idx + 1) % presets().len())
|
|
||||||
}
|
|
||||||
KeyCode::Left => self.adjust(1, -1),
|
|
||||||
KeyCode::Right => self.adjust(1, 1),
|
|
||||||
KeyCode::Char('s') | KeyCode::Char('S') | KeyCode::Tab => self.view = View::Settings,
|
|
||||||
KeyCode::Char('h') | KeyCode::Char('H') => self.view = View::Help,
|
|
||||||
KeyCode::Char('p') | KeyCode::Char('P') => {
|
|
||||||
self.with_ctrl(|c| c.preset_idx = (c.preset_idx + 1) % presets().len())
|
|
||||||
}
|
|
||||||
KeyCode::Char(d) if d.is_ascii_digit() => self.with_ctrl(|c| {
|
|
||||||
let n = d.to_digit(10).unwrap() as usize;
|
|
||||||
c.preset_idx = if n == 0 {
|
|
||||||
presets().len() - 1
|
|
||||||
} else {
|
|
||||||
(n - 1).min(presets().len() - 1)
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
KeyCode::Char('[') => self.adjust(1, -1),
|
|
||||||
KeyCode::Char(']') => self.adjust(1, 1),
|
|
||||||
KeyCode::Char('{') => self.adjust(2, -1),
|
|
||||||
KeyCode::Char('}') => self.adjust(2, 1),
|
|
||||||
KeyCode::Char('g') | KeyCode::Char('G') => self.adjust(3, -1),
|
|
||||||
KeyCode::Char('b') | KeyCode::Char('B') => self.adjust(6, 1),
|
|
||||||
KeyCode::Char('m') | KeyCode::Char('M') => self.adjust(5, 1),
|
|
||||||
KeyCode::Char('r') | KeyCode::Char('R') => self.adjust(7, 1),
|
|
||||||
KeyCode::Char('+') | KeyCode::Char('=') => self.adjust(4, 1),
|
|
||||||
KeyCode::Char('-') => self.adjust(4, -1),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn on_key_settings(&mut self, key: crossterm::event::KeyEvent) {
|
|
||||||
match key.code {
|
|
||||||
KeyCode::Up => self.settings_cursor = self.settings_cursor.saturating_sub(1),
|
|
||||||
KeyCode::Down => self.settings_cursor = (self.settings_cursor + 1).min(7),
|
|
||||||
KeyCode::Left => self.adjust(self.settings_cursor, -1),
|
|
||||||
KeyCode::Right => self.adjust(self.settings_cursor, 1),
|
|
||||||
KeyCode::Enter => self.adjust(self.settings_cursor, 1),
|
|
||||||
KeyCode::Esc | KeyCode::Char('s') | KeyCode::Char('S') | KeyCode::Tab => {
|
|
||||||
self.view = View::Main
|
|
||||||
}
|
|
||||||
KeyCode::Char('h') | KeyCode::Char('H') => self.view = View::Help,
|
|
||||||
KeyCode::Char('q') => {}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn on_mouse(&mut self, mouse: MouseEvent) {
|
|
||||||
if Instant::now() < self.splash_until {
|
|
||||||
self.splash_until = Instant::now();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if let MouseEventKind::Down(MouseButton::Left) = mouse.kind {
|
|
||||||
let area = self.terminal_size();
|
|
||||||
match self.view {
|
|
||||||
View::Main => {
|
|
||||||
let rects = main_layout(area);
|
|
||||||
let presets_rect = rects.0;
|
|
||||||
if mouse.row >= presets_rect.y
|
|
||||||
&& mouse.row < presets_rect.y + presets_rect.height
|
|
||||||
{
|
|
||||||
let idx = (mouse.row - presets_rect.y) as usize;
|
|
||||||
if idx < presets().len() {
|
|
||||||
self.with_ctrl(|c| c.preset_idx = idx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
View::Settings => {
|
|
||||||
let rects = settings_layout(area);
|
|
||||||
let list_rect = rects.0;
|
|
||||||
if mouse.row >= list_rect.y && mouse.row < list_rect.y + list_rect.height {
|
|
||||||
let idx = (mouse.row - list_rect.y) as usize;
|
|
||||||
if idx < 9 {
|
|
||||||
self.settings_cursor = idx;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
View::Help => self.view = View::Main,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn terminal_size(&self) -> Rect {
|
|
||||||
crossterm::terminal::size()
|
|
||||||
.map(|(w, h)| Rect::new(0, 0, w, h))
|
|
||||||
.unwrap_or(Rect::new(0, 0, 100, 30))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_quit(&mut self) {
|
|
||||||
self.with_ctrl(|c| c.quit = true);
|
|
||||||
self.running = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn with_ctrl(&self, f: impl FnOnce(&mut Control)) {
|
|
||||||
if let Ok(mut c) = self.control.lock() {
|
|
||||||
f(&mut c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render(&mut self, f: &mut Frame) {
|
|
||||||
if Instant::now() < self.splash_until {
|
|
||||||
self.render_splash(f);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
match self.view {
|
|
||||||
View::Main => self.render_main(f),
|
|
||||||
View::Settings => self.render_settings(f),
|
|
||||||
View::Help => self.render_help(f),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_splash(&self, f: &mut Frame) {
|
|
||||||
let area = f.area();
|
|
||||||
let mut lines: Vec<Line> = Vec::new();
|
|
||||||
for (i, l) in LOGO.iter().enumerate() {
|
|
||||||
let color = match i {
|
|
||||||
0 | 1 => Color::Cyan,
|
|
||||||
2 | 3 => Color::Magenta,
|
|
||||||
4 | 5 => Color::Blue,
|
|
||||||
_ => Color::Gray,
|
|
||||||
};
|
|
||||||
lines.push(Line::styled(
|
|
||||||
l.to_string(),
|
|
||||||
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
lines.push(Line::from(""));
|
|
||||||
lines.push(Line::from(
|
|
||||||
" press any key, click, or use the arrow keys ...",
|
|
||||||
));
|
|
||||||
lines.push(Line::styled(
|
|
||||||
" audio is already running",
|
|
||||||
Style::default().fg(Color::DarkGray),
|
|
||||||
));
|
|
||||||
let para = Paragraph::new(lines).alignment(ratatui::layout::Alignment::Center);
|
|
||||||
// Center in the full area, but never taller than the terminal.
|
|
||||||
let rect = centered_rect(70, 55, area);
|
|
||||||
f.render_widget(para, rect);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_help(&self, f: &mut Frame) {
|
|
||||||
let para = Paragraph::new(HELP_TEXT)
|
|
||||||
.style(Style::default().fg(Color::White))
|
|
||||||
.block(block(" vois · help "));
|
|
||||||
f.render_widget(para, f.area());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_header(&self, f: &mut Frame, area: Rect) {
|
|
||||||
let (_, _name, _p, _f, _g, _gain, mute, record, tone, tune, mode) = self.snapshot();
|
|
||||||
let mode_txt = match mode {
|
|
||||||
Mode::Live => "LIVE",
|
|
||||||
Mode::Passthrough => "A/B PASSTHROUGH",
|
|
||||||
};
|
|
||||||
let rec_txt = if record { "● REC" } else { "" };
|
|
||||||
let tone_txt = if tone { "● TONE" } else { "" };
|
|
||||||
let tune_txt = if tune { "● TUNE" } else { "" };
|
|
||||||
let mute_txt = if mute { "MUTED" } else { "" };
|
|
||||||
let header = Line::from(vec![
|
|
||||||
Span::styled(
|
|
||||||
" VOIS ",
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::Cyan)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
),
|
|
||||||
Span::styled(
|
|
||||||
"real-time voice changer",
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::Gray)
|
|
||||||
.add_modifier(Modifier::ITALIC),
|
|
||||||
),
|
|
||||||
Span::raw(" · "),
|
|
||||||
Span::styled(
|
|
||||||
mode_txt,
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::Yellow)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
),
|
|
||||||
Span::raw(" "),
|
|
||||||
Span::styled(
|
|
||||||
rec_txt,
|
|
||||||
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
|
|
||||||
),
|
|
||||||
Span::raw(" "),
|
|
||||||
Span::styled(tone_txt, Style::default().fg(Color::Blue)),
|
|
||||||
Span::raw(" "),
|
|
||||||
Span::styled(tune_txt, Style::default().fg(Color::Magenta)),
|
|
||||||
Span::raw(" "),
|
|
||||||
Span::styled(
|
|
||||||
mute_txt,
|
|
||||||
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
f.render_widget(Paragraph::new(header), area);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_footer(&self, f: &mut Frame, area: Rect, hint: &str) {
|
|
||||||
let footer = Line::from(Span::styled(hint, Style::default().fg(Color::DarkGray)));
|
|
||||||
f.render_widget(Paragraph::new(footer), area);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_main(&mut self, f: &mut Frame) {
|
|
||||||
let area = f.area();
|
|
||||||
let rects = main_layout(area);
|
|
||||||
let (head, body, foot) = (rects.0, rects.1, rects.2);
|
|
||||||
|
|
||||||
self.render_header(f, head);
|
|
||||||
|
|
||||||
let (preset_idx, _name, pitch, formant, gate, gain, mute, _record, _tone, tune, _mode) =
|
|
||||||
self.snapshot();
|
|
||||||
|
|
||||||
let cols = Layout::default()
|
|
||||||
.direction(Direction::Horizontal)
|
|
||||||
.constraints([
|
|
||||||
Constraint::Percentage(38),
|
|
||||||
Constraint::Percentage(28),
|
|
||||||
Constraint::Percentage(34),
|
|
||||||
])
|
|
||||||
.split(body);
|
|
||||||
|
|
||||||
// Presets (navigable, live preview).
|
|
||||||
let mut plines = Vec::new();
|
|
||||||
for (i, p) in presets().iter().enumerate() {
|
|
||||||
let selected = i == preset_idx;
|
|
||||||
let label = format!(
|
|
||||||
"{}{} {}",
|
|
||||||
if selected { "▶" } else { " " },
|
|
||||||
if selected { "" } else { " " },
|
|
||||||
p.name
|
|
||||||
);
|
|
||||||
let style = if selected {
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::Yellow)
|
|
||||||
.add_modifier(Modifier::BOLD)
|
|
||||||
} else {
|
|
||||||
Style::default().fg(Color::Gray)
|
|
||||||
};
|
|
||||||
plines.push(Line::styled(label, style));
|
|
||||||
}
|
|
||||||
let para = Paragraph::new(plines).block(block(" presets · ↑↓ to switch "));
|
|
||||||
f.render_widget(para, cols[0]);
|
|
||||||
|
|
||||||
// Parameters + info.
|
|
||||||
let gate_txt = if gate <= -90.0 {
|
|
||||||
"off".to_string()
|
|
||||||
} else {
|
|
||||||
format!("{gate:.0} dB")
|
|
||||||
};
|
|
||||||
let gain_db = 20.0 * gain.max(1e-6).log10();
|
|
||||||
let param_lines = vec![
|
|
||||||
p_row("preset", &_name, Color::Yellow, "voice character"),
|
|
||||||
p_row(
|
|
||||||
"pitch",
|
|
||||||
&format!("{pitch:+.1} st"),
|
|
||||||
Color::Cyan,
|
|
||||||
"higher=girl, lower=deep",
|
|
||||||
),
|
|
||||||
p_row(
|
|
||||||
"formant",
|
|
||||||
&format!("{formant:.2}×"),
|
|
||||||
Color::Magenta,
|
|
||||||
"brightness/timbre",
|
|
||||||
),
|
|
||||||
p_row("gate", &gate_txt, Color::Blue, "noise below this dB is cut"),
|
|
||||||
p_row(
|
|
||||||
"gain",
|
|
||||||
&format!("{gain_db:+.1} dB"),
|
|
||||||
Color::Green,
|
|
||||||
"output loudness",
|
|
||||||
),
|
|
||||||
p_row(
|
|
||||||
"mute",
|
|
||||||
if mute { "ON" } else { "off" },
|
|
||||||
if mute { Color::Red } else { Color::DarkGray },
|
|
||||||
"silence output",
|
|
||||||
),
|
|
||||||
p_row(
|
|
||||||
"tune",
|
|
||||||
if tune { "ON" } else { "off" },
|
|
||||||
if tune {
|
|
||||||
Color::Magenta
|
|
||||||
} else {
|
|
||||||
Color::DarkGray
|
|
||||||
},
|
|
||||||
"auto-tune: snap pitch",
|
|
||||||
),
|
|
||||||
];
|
|
||||||
let para = Paragraph::new(param_lines).block(block(" parameters · info "));
|
|
||||||
f.render_widget(para, cols[1]);
|
|
||||||
|
|
||||||
self.render_stats(f, cols[2]);
|
|
||||||
|
|
||||||
self.render_footer(
|
|
||||||
f,
|
|
||||||
foot,
|
|
||||||
" ↑↓ preset | ←→ pitch | S settings | H help | Q quit ",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_settings(&mut self, f: &mut Frame) {
|
|
||||||
let area = f.area();
|
|
||||||
let rects = settings_layout(area);
|
|
||||||
let (head, list_rect, foot) = (rects.0, rects.1, rects.2);
|
|
||||||
|
|
||||||
self.render_header(f, head);
|
|
||||||
|
|
||||||
let labels = [
|
|
||||||
"Preset",
|
|
||||||
"Pitch",
|
|
||||||
"Formant",
|
|
||||||
"Gate (min)",
|
|
||||||
"Gain",
|
|
||||||
"Mute",
|
|
||||||
"Mode",
|
|
||||||
"Record",
|
|
||||||
"Pitch corr",
|
|
||||||
];
|
|
||||||
let mut lines = Vec::new();
|
|
||||||
for (i, label) in labels.iter().enumerate() {
|
|
||||||
let value = self.setting_value(i);
|
|
||||||
let selected = i == self.settings_cursor;
|
|
||||||
let style = if selected {
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::Yellow)
|
|
||||||
.add_modifier(Modifier::BOLD)
|
|
||||||
} else {
|
|
||||||
Style::default().fg(Color::Gray)
|
|
||||||
};
|
|
||||||
let info_style = Style::default().fg(Color::DarkGray);
|
|
||||||
lines.push(Line::from(vec![
|
|
||||||
Span::styled(
|
|
||||||
format!(
|
|
||||||
"{}{} {:<12}",
|
|
||||||
if selected { "▶" } else { " " },
|
|
||||||
if selected { "" } else { " " },
|
|
||||||
label
|
|
||||||
),
|
|
||||||
style,
|
|
||||||
),
|
|
||||||
Span::styled(format!("{:<16}", value), style),
|
|
||||||
Span::styled(SETTING_INFO[i], info_style),
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
lines.push(Line::from(""));
|
|
||||||
lines.push(Line::styled(
|
|
||||||
" use ↑ ↓ to select, ← → (or Enter) to change, Esc to go back",
|
|
||||||
Style::default().fg(Color::DarkGray),
|
|
||||||
));
|
|
||||||
let para = Paragraph::new(lines).block(block(" settings "));
|
|
||||||
f.render_widget(para, list_rect);
|
|
||||||
|
|
||||||
self.render_footer(
|
|
||||||
f,
|
|
||||||
foot,
|
|
||||||
" ↑↓ select | ←→ change | Esc back | H help | Q quit ",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_stats(&self, f: &mut Frame, area: Rect) {
|
|
||||||
let s = self
|
|
||||||
.stats
|
|
||||||
.lock()
|
|
||||||
.map(|s| {
|
|
||||||
(
|
|
||||||
s.input_rms,
|
|
||||||
s.output_rms,
|
|
||||||
s.peak_in,
|
|
||||||
s.peak_out,
|
|
||||||
s.blocks,
|
|
||||||
s.samples,
|
|
||||||
s.cpu_pct,
|
|
||||||
s.dsp_latency_ms,
|
|
||||||
s.started,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.unwrap_or((0.0, 0.0, 0.0, 0.0, 0, 0, 0.0, 0.0, Instant::now()));
|
|
||||||
let (in_rms, out_rms, peak_in, peak_out, blocks, samples, cpu, latency, started) = s;
|
|
||||||
|
|
||||||
let in_db = db(in_rms);
|
|
||||||
let out_db = db(out_rms);
|
|
||||||
let peak_in_db = db(peak_in);
|
|
||||||
let peak_out_db = db(peak_out);
|
|
||||||
|
|
||||||
let uptime = started.elapsed().as_secs();
|
|
||||||
let up = format!(
|
|
||||||
"{:02}:{:02}:{:02}",
|
|
||||||
uptime / 3600,
|
|
||||||
(uptime / 60) % 60,
|
|
||||||
uptime % 60
|
|
||||||
);
|
|
||||||
let secs = started.elapsed().as_secs_f32().max(0.1);
|
|
||||||
let hz = samples as f32 / secs;
|
|
||||||
|
|
||||||
let lines = vec![
|
|
||||||
Line::from(""),
|
|
||||||
Line::from(vec![
|
|
||||||
Span::styled(" peak in ", Style::default().fg(Color::DarkGray)),
|
|
||||||
Span::styled(format!("{peak_in_db:+.0} dB"), meter_style(peak_in_db)),
|
|
||||||
Span::styled(" peak out ", Style::default().fg(Color::DarkGray)),
|
|
||||||
Span::styled(format!("{peak_out_db:+.0} dB"), meter_style(peak_out_db)),
|
|
||||||
]),
|
|
||||||
Line::from(""),
|
|
||||||
Line::from(vec![
|
|
||||||
Span::styled(" dsp load ", Style::default().fg(Color::DarkGray)),
|
|
||||||
Span::styled(
|
|
||||||
format!("{cpu:.1} %"),
|
|
||||||
if cpu > 50.0 {
|
|
||||||
Style::default().fg(Color::Red)
|
|
||||||
} else {
|
|
||||||
Style::default().fg(Color::Green)
|
|
||||||
},
|
|
||||||
),
|
|
||||||
Span::raw(" "),
|
|
||||||
Span::styled("latency", Style::default().fg(Color::DarkGray)),
|
|
||||||
Span::raw(format!(" {latency:.0} ms")),
|
|
||||||
]),
|
|
||||||
Line::from(vec![
|
|
||||||
Span::styled(" rate ", Style::default().fg(Color::DarkGray)),
|
|
||||||
Span::raw(format!(
|
|
||||||
"{} Hz {} ch → {} ch ({} → {})",
|
|
||||||
self.info.sample_rate,
|
|
||||||
self.info.in_ch,
|
|
||||||
self.info.out_ch,
|
|
||||||
self.info.format_in,
|
|
||||||
self.info.format_out
|
|
||||||
)),
|
|
||||||
]),
|
|
||||||
Line::from(vec![
|
|
||||||
Span::styled(" uptime ", Style::default().fg(Color::DarkGray)),
|
|
||||||
Span::raw(up),
|
|
||||||
]),
|
|
||||||
Line::from(vec![
|
|
||||||
Span::styled(" blocks ", Style::default().fg(Color::DarkGray)),
|
|
||||||
Span::raw(format!("{blocks} ({hz:.0} blk/s)")),
|
|
||||||
]),
|
|
||||||
Line::from(""),
|
|
||||||
Line::from(Span::styled(
|
|
||||||
format!(" in: {}", self.info.input),
|
|
||||||
Style::default().fg(Color::DarkGray),
|
|
||||||
)),
|
|
||||||
Line::from(Span::styled(
|
|
||||||
format!(" out: {}", self.info.output),
|
|
||||||
Style::default().fg(Color::DarkGray),
|
|
||||||
)),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Meters on top of the column.
|
|
||||||
let vert = Layout::default()
|
|
||||||
.direction(Direction::Vertical)
|
|
||||||
.constraints([
|
|
||||||
Constraint::Length(2),
|
|
||||||
Constraint::Length(1),
|
|
||||||
Constraint::Length(2),
|
|
||||||
Constraint::Length(1),
|
|
||||||
Constraint::Min(0),
|
|
||||||
])
|
|
||||||
.split(area);
|
|
||||||
|
|
||||||
let in_gauge = Gauge::default()
|
|
||||||
.ratio(meter_ratio(in_db))
|
|
||||||
.gauge_style(meter_style(in_db));
|
|
||||||
let out_gauge = Gauge::default()
|
|
||||||
.ratio(meter_ratio(out_db))
|
|
||||||
.gauge_style(meter_style(out_db));
|
|
||||||
|
|
||||||
f.render_widget(
|
|
||||||
Paragraph::new(Line::from(vec![
|
|
||||||
Span::raw(" in "),
|
|
||||||
Span::styled(format!("{in_db:+.0} dB"), meter_style(in_db)),
|
|
||||||
])),
|
|
||||||
vert[0],
|
|
||||||
);
|
|
||||||
f.render_widget(in_gauge, vert[1]);
|
|
||||||
f.render_widget(
|
|
||||||
Paragraph::new(Line::from(vec![
|
|
||||||
Span::raw(" out "),
|
|
||||||
Span::styled(format!("{out_db:+.0} dB"), meter_style(out_db)),
|
|
||||||
])),
|
|
||||||
vert[2],
|
|
||||||
);
|
|
||||||
f.render_widget(out_gauge, vert[3]);
|
|
||||||
f.render_widget(Paragraph::new(lines), vert[4]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Layout rects for the main screen: (header, body, footer).
|
|
||||||
fn main_layout(area: Rect) -> (Rect, Rect, Rect) {
|
|
||||||
let chunks = Layout::default()
|
|
||||||
.direction(Direction::Vertical)
|
|
||||||
.constraints([
|
|
||||||
Constraint::Length(3),
|
|
||||||
Constraint::Min(0),
|
|
||||||
Constraint::Length(1),
|
|
||||||
])
|
|
||||||
.split(area);
|
|
||||||
(chunks[0], chunks[1], chunks[2])
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Layout rects for the settings screen: (header, list, footer).
|
|
||||||
fn settings_layout(area: Rect) -> (Rect, Rect, Rect) {
|
|
||||||
let chunks = Layout::default()
|
|
||||||
.direction(Direction::Vertical)
|
|
||||||
.constraints([
|
|
||||||
Constraint::Length(3),
|
|
||||||
Constraint::Min(0),
|
|
||||||
Constraint::Length(1),
|
|
||||||
])
|
|
||||||
.split(area);
|
|
||||||
(chunks[0], chunks[1], chunks[2])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn p_row(label: &str, value: &str, color: Color, info: &str) -> Line<'static> {
|
|
||||||
Line::from(vec![
|
|
||||||
Span::styled(
|
|
||||||
format!(" {label:<8}"),
|
|
||||||
Style::default().fg(Color::DarkGray),
|
|
||||||
),
|
|
||||||
Span::styled(
|
|
||||||
value.to_string(),
|
|
||||||
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
|
||||||
),
|
|
||||||
Span::styled(format!(" ({info})"), Style::default().fg(Color::DarkGray)),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn block(title: &str) -> Block<'static> {
|
|
||||||
Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.title(Span::styled(
|
|
||||||
title.to_string(),
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::Cyan)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
))
|
|
||||||
.border_style(Style::default().fg(Color::DarkGray))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
|
|
||||||
let height = ((area.height as u32 * percent_y as u32 / 100) as u16).max(12);
|
|
||||||
let top = area.y + (area.height.saturating_sub(height) / 2);
|
|
||||||
let rect = Rect::new(area.x, top, area.width, height);
|
|
||||||
let width = ((rect.width as u32 * percent_x as u32 / 100) as u16).max(30);
|
|
||||||
let x = rect.x + (rect.width.saturating_sub(width) / 2);
|
|
||||||
Rect::new(x, rect.y, width, rect.height)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn run(control: Arc<Mutex<Control>>, stats: Arc<Mutex<Stats>>, info: SessionInfo) {
|
|
||||||
let run_seconds = control.lock().ok().and_then(|c| c.run_seconds).unwrap_or(0);
|
|
||||||
|
|
||||||
if !std::io::stdin().is_terminal() {
|
|
||||||
let started = Instant::now();
|
|
||||||
loop {
|
|
||||||
let done = control
|
|
||||||
.lock()
|
|
||||||
.map(|c| {
|
|
||||||
c.quit || run_seconds > 0 && started.elapsed().as_secs() >= run_seconds as u64
|
|
||||||
})
|
|
||||||
.unwrap_or(true);
|
|
||||||
if done {
|
|
||||||
if let Ok(mut c) = control.lock() {
|
|
||||||
c.quit = true;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
std::thread::sleep(Duration::from_millis(50));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if enable_raw_mode().is_err() {
|
|
||||||
// No real terminal: keep the audio running headless.
|
|
||||||
let started = Instant::now();
|
|
||||||
loop {
|
|
||||||
let done = control
|
|
||||||
.lock()
|
|
||||||
.map(|c| {
|
|
||||||
c.quit || run_seconds > 0 && started.elapsed().as_secs() >= run_seconds as u64
|
|
||||||
})
|
|
||||||
.unwrap_or(true);
|
|
||||||
if done {
|
|
||||||
if let Ok(mut c) = control.lock() {
|
|
||||||
c.quit = true;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
std::thread::sleep(Duration::from_millis(50));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let _ = crossterm::execute!(stdout(), crossterm::event::EnableMouseCapture);
|
|
||||||
let mut terminal = match Terminal::new(CrosstermBackend::new(stdout())) {
|
|
||||||
Ok(t) => t,
|
|
||||||
Err(e) => {
|
|
||||||
disable_raw_mode().ok();
|
|
||||||
eprintln!("tui init failed: {e}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let _ = terminal.clear();
|
|
||||||
|
|
||||||
let mut app = App {
|
|
||||||
control,
|
|
||||||
stats,
|
|
||||||
info,
|
|
||||||
view: View::Main,
|
|
||||||
settings_cursor: 0,
|
|
||||||
splash_until: Instant::now() + Duration::from_millis(1500),
|
|
||||||
started: Instant::now(),
|
|
||||||
running: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
while app.running {
|
|
||||||
let _ = terminal.draw(|f| app.render(f));
|
|
||||||
let _ = stdout().flush();
|
|
||||||
|
|
||||||
if event::poll(Duration::from_millis(50)).unwrap_or(false) {
|
|
||||||
if let Ok(ev) = event::read() {
|
|
||||||
match ev {
|
|
||||||
Event::Key(key) => app.on_key(key),
|
|
||||||
Event::Mouse(m) => app.on_mouse(m),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if run_seconds > 0 && app.started.elapsed().as_secs() >= run_seconds as u64 {
|
|
||||||
app.set_quit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = terminal.show_cursor();
|
|
||||||
let _ = crossterm::execute!(stdout(), crossterm::event::DisableMouseCapture);
|
|
||||||
disable_raw_mode().ok();
|
|
||||||
println!("shutting down...");
|
|
||||||
}
|
|
||||||
134
src/ui/widgets.rs
Normal file
134
src/ui/widgets.rs
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
//! Shared constants and stateless render helpers for the TUI screens.
|
||||||
|
|
||||||
|
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||||
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Borders};
|
||||||
|
|
||||||
|
pub(super) const LOGO: [&str; 7] = [
|
||||||
|
" ██╗ ██╗ ██████╗ ██╗███████╗ ",
|
||||||
|
" ██║ ██║██╔═══██╗██║██╔════╝ ",
|
||||||
|
" ██║ ██║██║ ██║██║███████╗ ",
|
||||||
|
" ╚██╗ ██╔╝██║ ██║██║╚════██║ ",
|
||||||
|
" ╚████╔╝ ╚██████╔╝██║███████║ ",
|
||||||
|
" ╚═══╝ ╚═════╝ ╚═╝╚══════╝ ",
|
||||||
|
" real-time voice changer ",
|
||||||
|
];
|
||||||
|
|
||||||
|
pub(super) const SETTING_INFO: [&str; 9] = [
|
||||||
|
"voice character, previewed live",
|
||||||
|
"pitch: higher = girl/kid, lower = deep",
|
||||||
|
"formant: brightness / timbre of the voice",
|
||||||
|
"noise gate: audio below this dB is cut",
|
||||||
|
"output loudness, in dB",
|
||||||
|
"silence the output",
|
||||||
|
"LIVE = processed, PASSTHROUGH = raw input (A/B)",
|
||||||
|
"write processed audio to a WAV file",
|
||||||
|
"auto-tune: snap pitch to the nearest semitone",
|
||||||
|
];
|
||||||
|
|
||||||
|
pub(super) const HELP_TEXT: &str = "\
|
||||||
|
KEY / MOUSE ACTION
|
||||||
|
───────────────────────────────────────────────
|
||||||
|
↑ ↓ (or mouse) select preset / settings row
|
||||||
|
← → change value
|
||||||
|
Enter toggle / apply
|
||||||
|
S / Tab toggle SETTINGS screen
|
||||||
|
Esc back to main screen
|
||||||
|
H toggle this help screen
|
||||||
|
Q / Ctrl+C quit
|
||||||
|
0 - 9 jump to preset
|
||||||
|
M mute R record
|
||||||
|
B A/B passthrough mode
|
||||||
|
";
|
||||||
|
|
||||||
|
const METER_FLOOR_DB: f32 = -60.0;
|
||||||
|
|
||||||
|
/// RMS / peak value converted to decibels, floored at the meter range.
|
||||||
|
pub(super) fn db(rms: f32) -> f32 {
|
||||||
|
if rms <= 0.0 {
|
||||||
|
METER_FLOOR_DB
|
||||||
|
} else {
|
||||||
|
(20.0 * rms.log10()).clamp(METER_FLOOR_DB, 6.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Meter ratio in 0..=1 for the level gauges.
|
||||||
|
pub(super) fn meter_ratio(db: f32) -> f64 {
|
||||||
|
((db - METER_FLOOR_DB) / -METER_FLOOR_DB).clamp(0.0, 1.0) as f64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Meter color: red near the top, yellow mid, green low.
|
||||||
|
pub(super) fn meter_style(level_db: f32) -> Style {
|
||||||
|
if level_db >= -6.0 {
|
||||||
|
Style::default().fg(Color::Red)
|
||||||
|
} else if level_db >= -20.0 {
|
||||||
|
Style::default().fg(Color::Yellow)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::Green)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Layout rects for the main screen: (header, body, footer).
|
||||||
|
pub(super) fn main_layout(area: Rect) -> (Rect, Rect, Rect) {
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Min(0),
|
||||||
|
Constraint::Length(1),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
(chunks[0], chunks[1], chunks[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Layout rects for the settings screen: (header, list, footer).
|
||||||
|
pub(super) fn settings_layout(area: Rect) -> (Rect, Rect, Rect) {
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Min(0),
|
||||||
|
Constraint::Length(1),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
(chunks[0], chunks[1], chunks[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A settings table row: label / value / hint.
|
||||||
|
pub(super) fn p_row(label: &str, value: &str, color: Color, info: &str) -> Line<'static> {
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
format!(" {label:<8}"),
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
),
|
||||||
|
Span::styled(
|
||||||
|
value.to_string(),
|
||||||
|
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::styled(format!(" ({info})"), Style::default().fg(Color::DarkGray)),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bordered panel with a cyan title.
|
||||||
|
pub(super) fn block(title: &str) -> Block<'static> {
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(Span::styled(
|
||||||
|
title.to_string(),
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
))
|
||||||
|
.border_style(Style::default().fg(Color::DarkGray))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A rect of `percent_x`/`percent_y` centered inside `area`.
|
||||||
|
pub(super) fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
|
||||||
|
let height = ((area.height as u32 * percent_y as u32 / 100) as u16).max(12);
|
||||||
|
let top = area.y + (area.height.saturating_sub(height) / 2);
|
||||||
|
let rect = Rect::new(area.x, top, area.width, height);
|
||||||
|
let width = ((rect.width as u32 * percent_x as u32 / 100) as u16).max(30);
|
||||||
|
let x = rect.x + (rect.width.saturating_sub(width) / 2);
|
||||||
|
Rect::new(x, rect.y, width, rect.height)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue