Initial commit
This commit is contained in:
141
src/audio.rs
Normal file
141
src/audio.rs
Normal file
@@ -0,0 +1,141 @@
|
||||
//! Audio device enumeration and stream creation utilities.
|
||||
//!
|
||||
//! Provides functions to list available audio devices and create audio streams
|
||||
//! with specific configurations.
|
||||
|
||||
use cpal::traits::{DeviceTrait, HostTrait};
|
||||
use cpal::{Device, Host, SupportedStreamConfig};
|
||||
|
||||
/// Information about an available audio device.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AudioDeviceInfo {
|
||||
pub name: String,
|
||||
pub index: usize,
|
||||
pub max_channels: u16,
|
||||
pub is_default: bool,
|
||||
}
|
||||
|
||||
/// Returns the default CPAL host for the current platform.
|
||||
pub fn default_host() -> Host {
|
||||
cpal::default_host()
|
||||
}
|
||||
|
||||
/// Lists all available output audio devices.
|
||||
pub fn list_output_devices() -> Vec<AudioDeviceInfo> {
|
||||
let host = default_host();
|
||||
let default_name = host
|
||||
.default_output_device()
|
||||
.and_then(|d| d.name().ok());
|
||||
|
||||
let Ok(devices) = host.output_devices() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
devices
|
||||
.enumerate()
|
||||
.filter_map(|(index, device)| {
|
||||
let name = device.name().ok()?;
|
||||
let max_channels = device
|
||||
.supported_output_configs()
|
||||
.ok()?
|
||||
.map(|c| c.channels())
|
||||
.max()
|
||||
.unwrap_or(2);
|
||||
let is_default = Some(&name) == default_name.as_ref();
|
||||
Some(AudioDeviceInfo {
|
||||
name,
|
||||
index,
|
||||
max_channels,
|
||||
is_default,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Lists all available input audio devices.
|
||||
pub fn list_input_devices() -> Vec<AudioDeviceInfo> {
|
||||
let host = default_host();
|
||||
let default_name = host
|
||||
.default_input_device()
|
||||
.and_then(|d| d.name().ok());
|
||||
|
||||
let Ok(devices) = host.input_devices() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
devices
|
||||
.enumerate()
|
||||
.filter_map(|(index, device)| {
|
||||
let name = device.name().ok()?;
|
||||
let max_channels = device
|
||||
.supported_input_configs()
|
||||
.ok()?
|
||||
.map(|c| c.channels())
|
||||
.max()
|
||||
.unwrap_or(2);
|
||||
let is_default = Some(&name) == default_name.as_ref();
|
||||
Some(AudioDeviceInfo {
|
||||
name,
|
||||
index,
|
||||
max_channels,
|
||||
is_default,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Finds an output device by index or partial name match.
|
||||
///
|
||||
/// If `spec` parses as a number, returns the device at that index.
|
||||
/// Otherwise, performs a case-insensitive substring match on device names.
|
||||
pub fn find_output_device(spec: &str) -> Option<Device> {
|
||||
let host = default_host();
|
||||
let devices = host.output_devices().ok()?;
|
||||
find_device_impl(devices, spec)
|
||||
}
|
||||
|
||||
/// Finds an input device by index or partial name match.
|
||||
pub fn find_input_device(spec: &str) -> Option<Device> {
|
||||
let host = default_host();
|
||||
let devices = host.input_devices().ok()?;
|
||||
find_device_impl(devices, spec)
|
||||
}
|
||||
|
||||
fn find_device_impl<I>(devices: I, spec: &str) -> Option<Device>
|
||||
where
|
||||
I: Iterator<Item = Device>,
|
||||
{
|
||||
let devices: Vec<_> = devices.collect();
|
||||
if let Ok(idx) = spec.parse::<usize>() {
|
||||
return devices.into_iter().nth(idx);
|
||||
}
|
||||
let spec_lower = spec.to_lowercase();
|
||||
devices.into_iter().find(|d| {
|
||||
d.name()
|
||||
.map(|n| n.to_lowercase().contains(&spec_lower))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the default output device.
|
||||
pub fn default_output_device() -> Option<Device> {
|
||||
default_host().default_output_device()
|
||||
}
|
||||
|
||||
/// Returns the default input device.
|
||||
pub fn default_input_device() -> Option<Device> {
|
||||
default_host().default_input_device()
|
||||
}
|
||||
|
||||
/// Gets the default output config for a device.
|
||||
pub fn default_output_config(device: &Device) -> Option<SupportedStreamConfig> {
|
||||
device.default_output_config().ok()
|
||||
}
|
||||
|
||||
/// Gets the maximum number of output channels supported by a device.
|
||||
pub fn max_output_channels(device: &Device) -> u16 {
|
||||
device
|
||||
.supported_output_configs()
|
||||
.map(|configs| configs.map(|c| c.channels()).max().unwrap_or(2))
|
||||
.unwrap_or(2)
|
||||
}
|
||||
66
src/config.rs
Normal file
66
src/config.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
//! Configuration types for the Doux audio engine.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Configuration for the Doux audio engine.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DouxConfig {
|
||||
/// Output device specification (name or index). None uses system default.
|
||||
pub output_device: Option<String>,
|
||||
/// Input device specification (name or index). None uses system default.
|
||||
pub input_device: Option<String>,
|
||||
/// Number of output channels (will be clamped to device maximum).
|
||||
pub channels: u16,
|
||||
/// Paths to sample directories for lazy loading.
|
||||
pub sample_paths: Vec<PathBuf>,
|
||||
/// Audio buffer size in samples. None uses system default.
|
||||
pub buffer_size: Option<u32>,
|
||||
}
|
||||
|
||||
impl Default for DouxConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
output_device: None,
|
||||
input_device: None,
|
||||
channels: 2,
|
||||
sample_paths: Vec::new(),
|
||||
buffer_size: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DouxConfig {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn with_output_device(mut self, device: impl Into<String>) -> Self {
|
||||
self.output_device = Some(device.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_input_device(mut self, device: impl Into<String>) -> Self {
|
||||
self.input_device = Some(device.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_channels(mut self, channels: u16) -> Self {
|
||||
self.channels = channels;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_sample_path(mut self, path: impl Into<PathBuf>) -> Self {
|
||||
self.sample_paths.push(path.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_sample_paths(mut self, paths: impl IntoIterator<Item = PathBuf>) -> Self {
|
||||
self.sample_paths.extend(paths);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_buffer_size(mut self, size: u32) -> Self {
|
||||
self.buffer_size = Some(size);
|
||||
self
|
||||
}
|
||||
}
|
||||
134
src/effects/chorus.rs
Normal file
134
src/effects/chorus.rs
Normal file
@@ -0,0 +1,134 @@
|
||||
//! Multi-voice chorus effect with stereo modulation.
|
||||
//!
|
||||
//! Creates a shimmering, widened sound by mixing the dry signal with multiple
|
||||
//! delayed copies whose delay times are modulated by LFOs. Each voice uses a
|
||||
//! different LFO phase, and left/right channels are modulated in opposite
|
||||
//! directions for stereo spread.
|
||||
//!
|
||||
//! # Signal Flow
|
||||
//!
|
||||
//! ```text
|
||||
//! L+R → mono → delay line ─┬─ voice 0 (LFO phase 0°) ─┬─→ L
|
||||
//! ├─ voice 1 (LFO phase 120°) ─┤
|
||||
//! └─ voice 2 (LFO phase 240°) ─┴─→ R
|
||||
//! ```
|
||||
//!
|
||||
//! The three voices are phase-offset by 120° to avoid reinforcement artifacts.
|
||||
//! Left and right taps use opposite modulation polarity for stereo width.
|
||||
|
||||
use crate::oscillator::Phasor;
|
||||
|
||||
/// Delay buffer size in samples (~42ms at 48kHz).
|
||||
const BUFFER_SIZE: usize = 2048;
|
||||
|
||||
/// Number of chorus voices (phase-offset delay taps).
|
||||
const VOICES: usize = 3;
|
||||
|
||||
/// Multi-voice stereo chorus effect.
|
||||
///
|
||||
/// Uses a circular delay buffer with three LFO-modulated tap points.
|
||||
/// The LFOs are phase-offset by 1/3 cycle (120°) to create smooth,
|
||||
/// non-pulsing modulation.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Chorus {
|
||||
/// Circular delay buffer (mono, power-of-2 for efficient wrapping).
|
||||
buffer: [f32; BUFFER_SIZE],
|
||||
/// Current write position in the delay buffer.
|
||||
write_pos: usize,
|
||||
/// Per-voice LFOs for delay time modulation.
|
||||
lfo: [Phasor; VOICES],
|
||||
}
|
||||
|
||||
impl Default for Chorus {
|
||||
fn default() -> Self {
|
||||
let mut lfo = [Phasor::default(); VOICES];
|
||||
// Distribute LFO phases evenly: 0°, 120°, 240°
|
||||
for (i, l) in lfo.iter_mut().enumerate() {
|
||||
l.phase = i as f32 / VOICES as f32;
|
||||
}
|
||||
Self {
|
||||
buffer: [0.0; BUFFER_SIZE],
|
||||
write_pos: 0,
|
||||
lfo,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Chorus {
|
||||
/// Processes one stereo sample through the chorus.
|
||||
///
|
||||
/// # Parameters
|
||||
///
|
||||
/// - `left`, `right`: Input stereo sample
|
||||
/// - `rate`: LFO frequency in Hz (typical: 0.5-3.0)
|
||||
/// - `depth`: Modulation intensity `[0.0, 1.0]`
|
||||
/// - `delay_ms`: Base delay time in milliseconds (typical: 10-30)
|
||||
/// - `sr`: Sample rate in Hz
|
||||
/// - `isr`: Inverse sample rate (1.0 / sr)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Stereo output `[left, right]` with 50/50 dry/wet mix (equal power).
|
||||
pub fn process(
|
||||
&mut self,
|
||||
left: f32,
|
||||
right: f32,
|
||||
rate: f32,
|
||||
depth: f32,
|
||||
delay_ms: f32,
|
||||
sr: f32,
|
||||
isr: f32,
|
||||
) -> [f32; 2] {
|
||||
let depth = depth.clamp(0.0, 1.0);
|
||||
let mod_range = delay_ms * 0.8;
|
||||
|
||||
// Sum to mono for delay line (common chorus technique)
|
||||
let mono = (left + right) * 0.5;
|
||||
self.buffer[self.write_pos] = mono;
|
||||
|
||||
let mut out_l = 0.0_f32;
|
||||
let mut out_r = 0.0_f32;
|
||||
|
||||
let min_delay = 1.5;
|
||||
let max_delay = 50.0_f32.min((BUFFER_SIZE as f32 - 2.0) * 1000.0 / sr);
|
||||
|
||||
for v in 0..VOICES {
|
||||
let lfo = self.lfo[v].sine(rate, isr);
|
||||
|
||||
// Opposite modulation for L/R creates stereo width
|
||||
let modulation = depth * mod_range * lfo;
|
||||
let dly_l = (delay_ms + modulation).clamp(min_delay, max_delay);
|
||||
let dly_r = (delay_ms - modulation).clamp(min_delay, max_delay);
|
||||
|
||||
// Convert ms to samples
|
||||
let samp_l = (dly_l * sr * 0.001).clamp(1.0, BUFFER_SIZE as f32 - 2.0);
|
||||
let samp_r = (dly_r * sr * 0.001).clamp(1.0, BUFFER_SIZE as f32 - 2.0);
|
||||
|
||||
// Linear interpolation for sub-sample accuracy
|
||||
let pos_l = samp_l.floor() as usize;
|
||||
let frac_l = samp_l - pos_l as f32;
|
||||
let idx_l0 = (self.write_pos + BUFFER_SIZE - pos_l) & (BUFFER_SIZE - 1);
|
||||
let idx_l1 = (self.write_pos + BUFFER_SIZE - pos_l - 1) & (BUFFER_SIZE - 1);
|
||||
let tap_l = self.buffer[idx_l0] + frac_l * (self.buffer[idx_l1] - self.buffer[idx_l0]);
|
||||
|
||||
let pos_r = samp_r.floor() as usize;
|
||||
let frac_r = samp_r - pos_r as f32;
|
||||
let idx_r0 = (self.write_pos + BUFFER_SIZE - pos_r) & (BUFFER_SIZE - 1);
|
||||
let idx_r1 = (self.write_pos + BUFFER_SIZE - pos_r - 1) & (BUFFER_SIZE - 1);
|
||||
let tap_r = self.buffer[idx_r0] + frac_r * (self.buffer[idx_r1] - self.buffer[idx_r0]);
|
||||
|
||||
out_l += tap_l;
|
||||
out_r += tap_r;
|
||||
}
|
||||
|
||||
self.write_pos = (self.write_pos + 1) & (BUFFER_SIZE - 1);
|
||||
|
||||
// Average the voices
|
||||
out_l /= VOICES as f32;
|
||||
out_r /= VOICES as f32;
|
||||
|
||||
// Equal-power mix: dry × 0.707 + wet × 0.707
|
||||
const MIX: f32 = std::f32::consts::FRAC_1_SQRT_2;
|
||||
[mono * MIX + out_l * MIX, mono * MIX + out_r * MIX]
|
||||
}
|
||||
}
|
||||
47
src/effects/coarse.rs
Normal file
47
src/effects/coarse.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
//! Sample rate reduction (bitcrusher-style decimation).
|
||||
//!
|
||||
//! Reduces the effective sample rate by holding each sample value for multiple
|
||||
//! output samples, creating the characteristic "crunchy" lo-fi sound of early
|
||||
//! samplers and video game consoles.
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! With `factor = 4` at 48kHz, the effective sample rate becomes 12kHz:
|
||||
//!
|
||||
//! ```text
|
||||
//! Input: [a, b, c, d, e, f, g, h, ...]
|
||||
//! Output: [a, a, a, a, e, e, e, e, ...]
|
||||
//! ```
|
||||
|
||||
/// Sample-and-hold decimator for lo-fi effects.
|
||||
///
|
||||
/// Holds input values for `factor` samples, reducing effective sample rate.
|
||||
/// Often combined with bit depth reduction for full bitcrusher effects.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct Coarse {
|
||||
/// Currently held sample value.
|
||||
hold: f32,
|
||||
/// Sample counter (0 to factor-1).
|
||||
t: usize,
|
||||
}
|
||||
|
||||
impl Coarse {
|
||||
/// Processes one sample through the decimator.
|
||||
///
|
||||
/// # Parameters
|
||||
///
|
||||
/// - `input`: Input sample
|
||||
/// - `factor`: Decimation factor (1.0 = bypass, 2.0 = half rate, etc.)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The held sample value. Updates only when the internal counter wraps.
|
||||
pub fn process(&mut self, input: f32, factor: f32) -> f32 {
|
||||
let n = factor.max(1.0) as usize;
|
||||
if self.t == 0 {
|
||||
self.hold = input;
|
||||
}
|
||||
self.t = (self.t + 1) % n;
|
||||
self.hold
|
||||
}
|
||||
}
|
||||
58
src/effects/comb.rs
Normal file
58
src/effects/comb.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
//! Comb filter with damping.
|
||||
//!
|
||||
//! Creates resonant peaks at `freq` and its harmonics by feeding delayed
|
||||
//! signal back into itself. Damping applies a lowpass in the feedback path,
|
||||
//! causing higher harmonics to decay faster (Karplus-Strong style).
|
||||
|
||||
const BUFFER_SIZE: usize = 2048;
|
||||
|
||||
/// Feedback comb filter with one-pole damping.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Comb {
|
||||
buffer: [f32; BUFFER_SIZE],
|
||||
write_pos: usize,
|
||||
damp_state: f32,
|
||||
}
|
||||
|
||||
impl Default for Comb {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
buffer: [0.0; BUFFER_SIZE],
|
||||
write_pos: 0,
|
||||
damp_state: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Comb {
|
||||
/// Processes one sample through the comb filter.
|
||||
///
|
||||
/// - `freq`: Fundamental frequency (delay = 1/freq)
|
||||
/// - `feedback`: Feedback amount `[-0.99, 0.99]`
|
||||
/// - `damp`: High-frequency loss per iteration `[0.0, 1.0]`
|
||||
///
|
||||
/// Returns the delayed signal (wet only).
|
||||
pub fn process(&mut self, input: f32, freq: f32, feedback: f32, damp: f32, sr: f32) -> f32 {
|
||||
let delay_samples = (sr / freq).clamp(1.0, (BUFFER_SIZE - 1) as f32);
|
||||
let delay_int = delay_samples.floor() as usize;
|
||||
let frac = delay_samples - delay_int as f32;
|
||||
|
||||
// Linear interpolation for precise tuning
|
||||
let idx0 = (self.write_pos + BUFFER_SIZE - delay_int) & (BUFFER_SIZE - 1);
|
||||
let idx1 = (self.write_pos + BUFFER_SIZE - delay_int - 1) & (BUFFER_SIZE - 1);
|
||||
let delayed = self.buffer[idx0] + frac * (self.buffer[idx1] - self.buffer[idx0]);
|
||||
|
||||
let feedback = feedback.clamp(-0.99, 0.99);
|
||||
let fb_signal = if damp > 0.0 {
|
||||
self.damp_state = delayed * (1.0 - damp) + self.damp_state * damp;
|
||||
self.damp_state
|
||||
} else {
|
||||
delayed
|
||||
};
|
||||
|
||||
self.buffer[self.write_pos] = input + fb_signal * feedback;
|
||||
self.write_pos = (self.write_pos + 1) & (BUFFER_SIZE - 1);
|
||||
|
||||
delayed
|
||||
}
|
||||
}
|
||||
19
src/effects/crush.rs
Normal file
19
src/effects/crush.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
//! Bit depth reduction for lo-fi effects.
|
||||
//!
|
||||
//! Quantizes amplitude to fewer bits, creating the stepped distortion
|
||||
//! characteristic of early digital audio. Pair with [`super::coarse`] for
|
||||
//! full bitcrusher (sample rate + bit depth reduction).
|
||||
|
||||
use crate::fastmath::exp2f;
|
||||
|
||||
/// Reduces bit depth by quantizing to `2^(bits-1)` levels.
|
||||
///
|
||||
/// - `bits = 16`: Near-transparent (CD quality)
|
||||
/// - `bits = 8`: Classic 8-bit crunch
|
||||
/// - `bits = 4`: Heavily degraded
|
||||
/// - `bits = 1`: Square wave (extreme)
|
||||
pub fn crush(input: f32, bits: f32) -> f32 {
|
||||
let bits = bits.max(1.0);
|
||||
let x = exp2f(bits - 1.0);
|
||||
(input * x).round() / x
|
||||
}
|
||||
37
src/effects/distort.rs
Normal file
37
src/effects/distort.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
//! Waveshaping distortion effects.
|
||||
//!
|
||||
//! Three flavors of nonlinear distortion:
|
||||
//! - [`distort`]: Soft saturation (tube-like warmth)
|
||||
//! - [`fold`]: Wavefolding (complex harmonics)
|
||||
//! - [`wrap`]: Phase wrapping (harsh, digital)
|
||||
|
||||
use crate::fastmath::{expm1f, sinf};
|
||||
|
||||
/// Soft-knee saturation with adjustable drive.
|
||||
///
|
||||
/// Uses `x / (1 + k|x|)` transfer function for smooth clipping.
|
||||
/// Higher `amount` = more compression and harmonics.
|
||||
pub fn distort(input: f32, amount: f32, postgain: f32) -> f32 {
|
||||
let k = expm1f(amount);
|
||||
((1.0 + k) * input / (1.0 + k * input.abs())) * postgain
|
||||
}
|
||||
|
||||
/// Sine wavefolder.
|
||||
///
|
||||
/// Folds the waveform back on itself using `sin(x × amount × π/2)`.
|
||||
/// Creates rich harmonic content without hard clipping.
|
||||
pub fn fold(input: f32, amount: f32) -> f32 {
|
||||
sinf(input * amount * std::f32::consts::FRAC_PI_2)
|
||||
}
|
||||
|
||||
/// Wraps signal into `[-1, 1]` range using modulo.
|
||||
///
|
||||
/// Creates harsh, digital-sounding distortion with discontinuities.
|
||||
/// `wraps` controls how many times the signal can wrap.
|
||||
pub fn wrap(input: f32, wraps: f32) -> f32 {
|
||||
if wraps < 1.0 {
|
||||
return input;
|
||||
}
|
||||
let x = input * (1.0 + wraps);
|
||||
(x + 1.0).rem_euclid(2.0) - 1.0
|
||||
}
|
||||
75
src/effects/flanger.rs
Normal file
75
src/effects/flanger.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
//! Flanger effect with LFO-modulated delay.
|
||||
//!
|
||||
//! Creates the characteristic "jet plane" sweep by mixing the input with a
|
||||
//! short, modulated delay (0.5-10ms). Feedback intensifies the comb filtering.
|
||||
|
||||
use crate::oscillator::Phasor;
|
||||
|
||||
const BUFFER_SIZE: usize = 512;
|
||||
const MIN_DELAY_MS: f32 = 0.5;
|
||||
const MAX_DELAY_MS: f32 = 10.0;
|
||||
const DELAY_RANGE_MS: f32 = MAX_DELAY_MS - MIN_DELAY_MS;
|
||||
|
||||
/// Mono flanger with feedback.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Flanger {
|
||||
buffer: [f32; BUFFER_SIZE],
|
||||
write_pos: usize,
|
||||
lfo: Phasor,
|
||||
feedback: f32,
|
||||
}
|
||||
|
||||
impl Default for Flanger {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
buffer: [0.0; BUFFER_SIZE],
|
||||
write_pos: 0,
|
||||
lfo: Phasor::default(),
|
||||
feedback: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Flanger {
|
||||
/// Processes one sample.
|
||||
///
|
||||
/// - `rate`: LFO speed in Hz (typical: 0.1-2.0)
|
||||
/// - `depth`: Modulation amount `[0.0, 1.0]` (squared for smoother response)
|
||||
/// - `feedback`: Resonance `[0.0, 0.95]`
|
||||
///
|
||||
/// Returns 50/50 dry/wet mix.
|
||||
pub fn process(
|
||||
&mut self,
|
||||
input: f32,
|
||||
rate: f32,
|
||||
depth: f32,
|
||||
feedback: f32,
|
||||
sr: f32,
|
||||
isr: f32,
|
||||
) -> f32 {
|
||||
let lfo_val = self.lfo.sine(rate, isr);
|
||||
let depth_curve = depth * depth;
|
||||
let delay_ms = MIN_DELAY_MS + depth_curve * DELAY_RANGE_MS * (lfo_val * 0.5 + 0.5);
|
||||
|
||||
let delay_samples = (delay_ms * sr * 0.001).clamp(1.0, BUFFER_SIZE as f32 - 2.0);
|
||||
|
||||
let read_pos_int = delay_samples.floor() as usize;
|
||||
let frac = delay_samples - read_pos_int as f32;
|
||||
|
||||
let read_index1 = (self.write_pos + BUFFER_SIZE - read_pos_int) & (BUFFER_SIZE - 1);
|
||||
let read_index2 = (self.write_pos + BUFFER_SIZE - read_pos_int - 1) & (BUFFER_SIZE - 1);
|
||||
|
||||
let delayed1 = self.buffer[read_index1];
|
||||
let delayed2 = self.buffer[read_index2];
|
||||
let delayed = delayed1 + frac * (delayed2 - delayed1);
|
||||
|
||||
let feedback = feedback.clamp(0.0, 0.95);
|
||||
|
||||
self.buffer[self.write_pos] = input + self.feedback * feedback;
|
||||
self.write_pos = (self.write_pos + 1) & (BUFFER_SIZE - 1);
|
||||
|
||||
self.feedback = delayed;
|
||||
|
||||
input * 0.5 + delayed * 0.5
|
||||
}
|
||||
}
|
||||
24
src/effects/lag.rs
Normal file
24
src/effects/lag.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
//! One-pole smoothing filter (slew limiter).
|
||||
//!
|
||||
//! Smooths abrupt parameter changes to prevent clicks and zipper noise.
|
||||
//! Higher rate = slower response.
|
||||
|
||||
/// One-pole lowpass for parameter smoothing.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct Lag {
|
||||
/// Current smoothed value.
|
||||
pub s: f32,
|
||||
}
|
||||
|
||||
impl Lag {
|
||||
/// Moves toward `input` at a rate controlled by `rate × lag_unit`.
|
||||
///
|
||||
/// - `rate`: Smoothing factor (higher = slower)
|
||||
/// - `lag_unit`: Scaling factor (typically sample-rate dependent)
|
||||
#[inline]
|
||||
pub fn update(&mut self, input: f32, rate: f32, lag_unit: f32) -> f32 {
|
||||
let coeff = 1.0 / (rate * lag_unit).max(1.0);
|
||||
self.s += coeff * (input - self.s);
|
||||
self.s
|
||||
}
|
||||
}
|
||||
17
src/effects/mod.rs
Normal file
17
src/effects/mod.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
mod chorus;
|
||||
mod coarse;
|
||||
mod comb;
|
||||
mod crush;
|
||||
mod distort;
|
||||
mod flanger;
|
||||
mod lag;
|
||||
mod phaser;
|
||||
|
||||
pub use chorus::Chorus;
|
||||
pub use coarse::Coarse;
|
||||
pub use comb::Comb;
|
||||
pub use crush::crush;
|
||||
pub use distort::{distort, fold, wrap};
|
||||
pub use flanger::Flanger;
|
||||
pub use lag::Lag;
|
||||
pub use phaser::Phaser;
|
||||
50
src/effects/phaser.rs
Normal file
50
src/effects/phaser.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
//! Phaser effect using cascaded notch filters.
|
||||
//!
|
||||
//! Creates the sweeping, hollow sound by modulating two notch filters
|
||||
//! with an LFO. The notches are offset by ~282 Hz for a richer effect.
|
||||
|
||||
use crate::fastmath::exp2f;
|
||||
use crate::filter::Biquad;
|
||||
use crate::oscillator::Phasor;
|
||||
use crate::types::FilterType;
|
||||
|
||||
/// Frequency offset between the two notch filters (Hz).
|
||||
const NOTCH_OFFSET: f32 = 282.0;
|
||||
|
||||
/// Two-stage phaser with LFO modulation.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct Phaser {
|
||||
notch1: Biquad,
|
||||
notch2: Biquad,
|
||||
lfo: Phasor,
|
||||
}
|
||||
|
||||
impl Phaser {
|
||||
/// Processes one sample.
|
||||
///
|
||||
/// - `rate`: LFO speed in Hz
|
||||
/// - `depth`: Notch resonance (higher = more pronounced, max ~0.95)
|
||||
/// - `center`: Base frequency in Hz
|
||||
/// - `sweep`: Modulation range in cents (1200 = ±1 octave)
|
||||
pub fn process(
|
||||
&mut self,
|
||||
input: f32,
|
||||
rate: f32,
|
||||
depth: f32,
|
||||
center: f32,
|
||||
sweep: f32,
|
||||
sr: f32,
|
||||
isr: f32,
|
||||
) -> f32 {
|
||||
let lfo_val = self.lfo.sine(rate, isr);
|
||||
let q = 2.0 - (depth * 2.0).min(1.9);
|
||||
let detune = exp2f(lfo_val * sweep * (1.0 / 1200.0));
|
||||
|
||||
let max_freq = sr * 0.45;
|
||||
let freq1 = (center * detune).clamp(20.0, max_freq);
|
||||
let freq2 = ((center + NOTCH_OFFSET) * detune).clamp(20.0, max_freq);
|
||||
|
||||
let out = self.notch1.process(input, FilterType::Notch, freq1, q, sr);
|
||||
self.notch2.process(out, FilterType::Notch, freq2, q, sr)
|
||||
}
|
||||
}
|
||||
256
src/envelope.rs
Normal file
256
src/envelope.rs
Normal file
@@ -0,0 +1,256 @@
|
||||
//! ADSR envelope generation for audio synthesis.
|
||||
//!
|
||||
//! This module provides a state-machine based ADSR (Attack, Decay, Sustain, Release)
|
||||
//! envelope generator with configurable curve shapes. The envelope responds to gate
|
||||
//! signals and produces amplitude values in the range `[0.0, 1.0]`.
|
||||
//!
|
||||
//! # Curve Shaping
|
||||
//!
|
||||
//! Attack and decay/release phases use exponential curves controlled by internal
|
||||
//! parameters. Positive exponents create convex curves (slow start, fast finish),
|
||||
//! while negative exponents create concave curves (fast start, slow finish).
|
||||
|
||||
use crate::fastmath::powf;
|
||||
|
||||
/// Attempt to scale the input `x` from range `[0, 1]` to range `[y0, y1]` with an exponent `exp`.
|
||||
///
|
||||
/// Attempt because the expression `powf(1.0 - x, -exp)` can lead to a NaN when `exp` is greater than 1.0.
|
||||
/// Using this function on 1.0 - x reverses the curve direction.
|
||||
///
|
||||
/// - `exp > 0`: Convex curve (slow start, accelerates toward end)
|
||||
/// - `exp < 0`: Concave curve (fast start, decelerates toward end)
|
||||
/// - `exp == 0`: Linear interpolation
|
||||
fn lerp(x: f32, y0: f32, y1: f32, exp: f32) -> f32 {
|
||||
if x <= 0.0 {
|
||||
return y0;
|
||||
}
|
||||
if x >= 1.0 {
|
||||
return y1;
|
||||
}
|
||||
let curved = if exp == 0.0 {
|
||||
x
|
||||
} else if exp > 0.0 {
|
||||
powf(x, exp)
|
||||
} else {
|
||||
1.0 - powf(1.0 - x, -exp)
|
||||
};
|
||||
y0 + (y1 - y0) * curved
|
||||
}
|
||||
|
||||
/// Current phase of the ADSR envelope state machine.
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum AdsrState {
|
||||
/// Envelope is inactive, outputting zero.
|
||||
Off,
|
||||
/// Rising from current value toward peak (1.0).
|
||||
Attack,
|
||||
/// Falling from peak toward sustain level.
|
||||
Decay,
|
||||
/// Holding at sustain level while gate remains high.
|
||||
Sustain,
|
||||
/// Falling from current value toward zero after gate release.
|
||||
Release,
|
||||
}
|
||||
|
||||
/// State-machine ADSR envelope generator.
|
||||
///
|
||||
/// Tracks envelope phase and timing internally. Call [`Adsr::update`] each sample
|
||||
/// with the current time and gate signal to produce envelope values.
|
||||
///
|
||||
/// # Curve Parameters
|
||||
///
|
||||
/// Default curves use an exponent of `2.0` for attack (convex) and decay/release
|
||||
/// (concave when negated internally), producing natural-sounding amplitude shapes.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Adsr {
|
||||
state: AdsrState,
|
||||
start_time: f32,
|
||||
start_val: f32,
|
||||
attack_curve: f32,
|
||||
decay_curve: f32,
|
||||
}
|
||||
|
||||
impl Default for Adsr {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
state: AdsrState::Off,
|
||||
start_time: 0.0,
|
||||
start_val: 0.0,
|
||||
attack_curve: 2.0,
|
||||
decay_curve: 2.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Adsr {
|
||||
/// Returns `true` if the envelope is in the [`AdsrState::Off`] state.
|
||||
pub fn is_off(&self) -> bool {
|
||||
matches!(self.state, AdsrState::Off)
|
||||
}
|
||||
|
||||
/// Advances the envelope state machine and returns the current amplitude.
|
||||
///
|
||||
/// The envelope responds to gate transitions:
|
||||
/// - Gate going high (`> 0.0`) triggers attack from current value
|
||||
/// - Gate going low (`<= 0.0`) triggers release from current value
|
||||
///
|
||||
/// This allows retriggering during any phase without clicks, as the envelope
|
||||
/// always starts from its current position rather than jumping to zero.
|
||||
///
|
||||
/// # Parameters
|
||||
///
|
||||
/// - `time`: Current time in seconds (must be monotonically increasing)
|
||||
/// - `gate`: Gate signal (`> 0.0` = note on, `<= 0.0` = note off)
|
||||
/// - `attack`: Attack duration in seconds
|
||||
/// - `decay`: Decay duration in seconds
|
||||
/// - `sustain`: Sustain level in range `[0.0, 1.0]`
|
||||
/// - `release`: Release duration in seconds
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Envelope amplitude in range `[0.0, 1.0]`.
|
||||
pub fn update(
|
||||
&mut self,
|
||||
time: f32,
|
||||
gate: f32,
|
||||
attack: f32,
|
||||
decay: f32,
|
||||
sustain: f32,
|
||||
release: f32,
|
||||
) -> f32 {
|
||||
match self.state {
|
||||
AdsrState::Off => {
|
||||
if gate > 0.0 {
|
||||
self.state = AdsrState::Attack;
|
||||
self.start_time = time;
|
||||
self.start_val = 0.0;
|
||||
}
|
||||
0.0
|
||||
}
|
||||
AdsrState::Attack => {
|
||||
let t = time - self.start_time;
|
||||
if t > attack {
|
||||
self.state = AdsrState::Decay;
|
||||
self.start_time = time;
|
||||
return 1.0;
|
||||
}
|
||||
lerp(t / attack, self.start_val, 1.0, self.attack_curve)
|
||||
}
|
||||
AdsrState::Decay => {
|
||||
let t = time - self.start_time;
|
||||
let val = lerp(t / decay, 1.0, sustain, -self.decay_curve);
|
||||
if gate <= 0.0 {
|
||||
self.state = AdsrState::Release;
|
||||
self.start_time = time;
|
||||
self.start_val = val;
|
||||
return val;
|
||||
}
|
||||
if t > decay {
|
||||
self.state = AdsrState::Sustain;
|
||||
self.start_time = time;
|
||||
return sustain;
|
||||
}
|
||||
val
|
||||
}
|
||||
AdsrState::Sustain => {
|
||||
if gate <= 0.0 {
|
||||
self.state = AdsrState::Release;
|
||||
self.start_time = time;
|
||||
self.start_val = sustain;
|
||||
}
|
||||
sustain
|
||||
}
|
||||
AdsrState::Release => {
|
||||
let t = time - self.start_time;
|
||||
if t > release {
|
||||
self.state = AdsrState::Off;
|
||||
return 0.0;
|
||||
}
|
||||
let val = lerp(t / release, self.start_val, 0.0, -self.decay_curve);
|
||||
if gate > 0.0 {
|
||||
self.state = AdsrState::Attack;
|
||||
self.start_time = time;
|
||||
self.start_val = val;
|
||||
}
|
||||
val
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsed envelope parameters with activation flag.
|
||||
///
|
||||
/// Used to pass envelope configuration from pattern parsing to voice rendering.
|
||||
/// The `active` field indicates whether the user explicitly specified any
|
||||
/// envelope parameters, allowing voices to skip envelope processing when unused.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct EnvelopeParams {
|
||||
/// Overall envelope amplitude multiplier.
|
||||
pub env: f32,
|
||||
/// Attack time in seconds.
|
||||
pub att: f32,
|
||||
/// Decay time in seconds.
|
||||
pub dec: f32,
|
||||
/// Sustain level in range `[0.0, 1.0]`.
|
||||
pub sus: f32,
|
||||
/// Release time in seconds.
|
||||
pub rel: f32,
|
||||
/// Whether envelope parameters were explicitly provided.
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
/// Constructs envelope parameters from optional user inputs.
|
||||
///
|
||||
/// Applies sensible defaults and infers sustain level from context:
|
||||
/// - If sustain is explicit, use it (clamped to `1.0`)
|
||||
/// - If only attack is set, sustain defaults to `1.0` (full level after attack)
|
||||
/// - If decay is set (with or without attack), sustain defaults to `0.0`
|
||||
/// - Otherwise, sustain defaults to `1.0`
|
||||
///
|
||||
/// When no parameters are provided, returns inactive defaults suitable for
|
||||
/// bypassing envelope processing entirely.
|
||||
///
|
||||
/// # Default Values
|
||||
///
|
||||
/// | Parameter | Default |
|
||||
/// |-----------|---------|
|
||||
/// | `env` | `1.0` |
|
||||
/// | `att` | `0.001` |
|
||||
/// | `dec` | `0.0` |
|
||||
/// | `sus` | `1.0` |
|
||||
/// | `rel` | `0.005` |
|
||||
pub fn init_envelope(
|
||||
env: Option<f32>,
|
||||
att: Option<f32>,
|
||||
dec: Option<f32>,
|
||||
sus: Option<f32>,
|
||||
rel: Option<f32>,
|
||||
) -> EnvelopeParams {
|
||||
if env.is_none() && att.is_none() && dec.is_none() && sus.is_none() && rel.is_none() {
|
||||
return EnvelopeParams {
|
||||
env: 1.0,
|
||||
att: 0.001,
|
||||
dec: 0.0,
|
||||
sus: 1.0,
|
||||
rel: 0.005,
|
||||
active: false,
|
||||
};
|
||||
}
|
||||
|
||||
let sus_val = match (sus, att, dec) {
|
||||
(Some(s), _, _) => s.min(1.0),
|
||||
(None, Some(_), None) => 1.0,
|
||||
(None, None, Some(_)) => 0.0,
|
||||
(None, Some(_), Some(_)) => 0.0,
|
||||
_ => 1.0,
|
||||
};
|
||||
|
||||
EnvelopeParams {
|
||||
env: env.unwrap_or(1.0),
|
||||
att: att.unwrap_or(0.001),
|
||||
dec: dec.unwrap_or(0.0),
|
||||
sus: sus_val,
|
||||
rel: rel.unwrap_or(0.005),
|
||||
active: true,
|
||||
}
|
||||
}
|
||||
42
src/error.rs
Normal file
42
src/error.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
//! Error types for the Doux audio engine.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// Errors that can occur when working with the Doux audio engine.
|
||||
#[derive(Debug)]
|
||||
pub enum DouxError {
|
||||
/// The specified audio device was not found.
|
||||
DeviceNotFound(String),
|
||||
/// No default audio device is available.
|
||||
NoDefaultDevice,
|
||||
/// Failed to create an audio stream.
|
||||
StreamCreationFailed(String),
|
||||
/// The requested channel count is invalid.
|
||||
InvalidChannelCount(u16),
|
||||
/// Failed to get device configuration.
|
||||
DeviceConfigError(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for DouxError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
DouxError::DeviceNotFound(name) => {
|
||||
write!(f, "audio device not found: {name}")
|
||||
}
|
||||
DouxError::NoDefaultDevice => {
|
||||
write!(f, "no default audio device available")
|
||||
}
|
||||
DouxError::StreamCreationFailed(msg) => {
|
||||
write!(f, "failed to create audio stream: {msg}")
|
||||
}
|
||||
DouxError::InvalidChannelCount(count) => {
|
||||
write!(f, "invalid channel count: {count}")
|
||||
}
|
||||
DouxError::DeviceConfigError(msg) => {
|
||||
write!(f, "device configuration error: {msg}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for DouxError {}
|
||||
291
src/event.rs
Normal file
291
src/event.rs
Normal file
@@ -0,0 +1,291 @@
|
||||
use crate::types::{midi2freq, DelayType, FilterSlope, LfoShape};
|
||||
|
||||
#[derive(Clone, Default, Debug)]
|
||||
pub struct Event {
|
||||
pub cmd: Option<String>,
|
||||
|
||||
// Timing
|
||||
pub time: Option<f64>,
|
||||
pub repeat: Option<f32>,
|
||||
pub duration: Option<f32>,
|
||||
pub gate: Option<f32>,
|
||||
|
||||
// Voice control
|
||||
pub voice: Option<usize>,
|
||||
pub reset: Option<bool>,
|
||||
pub orbit: Option<usize>,
|
||||
|
||||
// Pitch
|
||||
pub freq: Option<f32>,
|
||||
pub detune: Option<f32>,
|
||||
pub speed: Option<f32>,
|
||||
pub glide: Option<f32>,
|
||||
|
||||
// Source
|
||||
pub sound: Option<String>,
|
||||
pub pw: Option<f32>,
|
||||
pub spread: Option<f32>,
|
||||
pub size: Option<u16>,
|
||||
pub mult: Option<f32>,
|
||||
pub warp: Option<f32>,
|
||||
pub mirror: Option<f32>,
|
||||
pub harmonics: Option<f32>,
|
||||
pub timbre: Option<f32>,
|
||||
pub morph: Option<f32>,
|
||||
pub n: Option<usize>,
|
||||
pub cut: Option<usize>,
|
||||
pub begin: Option<f32>,
|
||||
pub end: Option<f32>,
|
||||
|
||||
// Web sample (WASM only - set by JavaScript)
|
||||
pub file_pcm: Option<usize>,
|
||||
pub file_frames: Option<usize>,
|
||||
pub file_channels: Option<u8>,
|
||||
pub file_freq: Option<f32>,
|
||||
|
||||
// Gain
|
||||
pub gain: Option<f32>,
|
||||
pub postgain: Option<f32>,
|
||||
pub velocity: Option<f32>,
|
||||
pub pan: Option<f32>,
|
||||
|
||||
// Gain envelope
|
||||
pub attack: Option<f32>,
|
||||
pub decay: Option<f32>,
|
||||
pub sustain: Option<f32>,
|
||||
pub release: Option<f32>,
|
||||
|
||||
// Lowpass filter
|
||||
pub lpf: Option<f32>,
|
||||
pub lpq: Option<f32>,
|
||||
pub lpe: Option<f32>,
|
||||
pub lpa: Option<f32>,
|
||||
pub lpd: Option<f32>,
|
||||
pub lps: Option<f32>,
|
||||
pub lpr: Option<f32>,
|
||||
|
||||
// Highpass filter
|
||||
pub hpf: Option<f32>,
|
||||
pub hpq: Option<f32>,
|
||||
pub hpe: Option<f32>,
|
||||
pub hpa: Option<f32>,
|
||||
pub hpd: Option<f32>,
|
||||
pub hps: Option<f32>,
|
||||
pub hpr: Option<f32>,
|
||||
|
||||
// Bandpass filter
|
||||
pub bpf: Option<f32>,
|
||||
pub bpq: Option<f32>,
|
||||
pub bpe: Option<f32>,
|
||||
pub bpa: Option<f32>,
|
||||
pub bpd: Option<f32>,
|
||||
pub bps: Option<f32>,
|
||||
pub bpr: Option<f32>,
|
||||
|
||||
// Filter type
|
||||
pub ftype: Option<FilterSlope>,
|
||||
|
||||
// Pitch envelope
|
||||
pub penv: Option<f32>,
|
||||
pub patt: Option<f32>,
|
||||
pub pdec: Option<f32>,
|
||||
pub psus: Option<f32>,
|
||||
pub prel: Option<f32>,
|
||||
|
||||
// Vibrato
|
||||
pub vib: Option<f32>,
|
||||
pub vibmod: Option<f32>,
|
||||
pub vibshape: Option<LfoShape>,
|
||||
|
||||
// FM synthesis
|
||||
pub fm: Option<f32>,
|
||||
pub fmh: Option<f32>,
|
||||
pub fmshape: Option<LfoShape>,
|
||||
pub fme: Option<f32>,
|
||||
pub fma: Option<f32>,
|
||||
pub fmd: Option<f32>,
|
||||
pub fms: Option<f32>,
|
||||
pub fmr: Option<f32>,
|
||||
|
||||
// AM
|
||||
pub am: Option<f32>,
|
||||
pub amdepth: Option<f32>,
|
||||
pub amshape: Option<LfoShape>,
|
||||
|
||||
// Ring mod
|
||||
pub rm: Option<f32>,
|
||||
pub rmdepth: Option<f32>,
|
||||
pub rmshape: Option<LfoShape>,
|
||||
|
||||
// Phaser
|
||||
pub phaser: Option<f32>,
|
||||
pub phaserdepth: Option<f32>,
|
||||
pub phasersweep: Option<f32>,
|
||||
pub phasercenter: Option<f32>,
|
||||
|
||||
// Flanger
|
||||
pub flanger: Option<f32>,
|
||||
pub flangerdepth: Option<f32>,
|
||||
pub flangerfeedback: Option<f32>,
|
||||
|
||||
// Chorus
|
||||
pub chorus: Option<f32>,
|
||||
pub chorusdepth: Option<f32>,
|
||||
pub chorusdelay: Option<f32>,
|
||||
|
||||
// Comb filter
|
||||
pub comb: Option<f32>,
|
||||
pub combfreq: Option<f32>,
|
||||
pub combfeedback: Option<f32>,
|
||||
pub combdamp: Option<f32>,
|
||||
|
||||
// Distortion
|
||||
pub coarse: Option<f32>,
|
||||
pub crush: Option<f32>,
|
||||
pub fold: Option<f32>,
|
||||
pub wrap: Option<f32>,
|
||||
pub distort: Option<f32>,
|
||||
pub distortvol: Option<f32>,
|
||||
|
||||
// Delay
|
||||
pub delay: Option<f32>,
|
||||
pub delaytime: Option<f32>,
|
||||
pub delayfeedback: Option<f32>,
|
||||
pub delaytype: Option<DelayType>,
|
||||
|
||||
// Reverb
|
||||
pub verb: Option<f32>,
|
||||
pub verbdecay: Option<f32>,
|
||||
pub verbdamp: Option<f32>,
|
||||
pub verbpredelay: Option<f32>,
|
||||
pub verbdiff: Option<f32>,
|
||||
}
|
||||
|
||||
impl Event {
|
||||
pub fn parse(input: &str) -> Self {
|
||||
let mut event = Self::default();
|
||||
let tokens: Vec<&str> = input.trim().split('/').filter(|s| !s.is_empty()).collect();
|
||||
let mut i = 0;
|
||||
while i + 1 < tokens.len() {
|
||||
let key = tokens[i];
|
||||
let val = tokens[i + 1];
|
||||
match key {
|
||||
"doux" | "dirt" => event.cmd = Some(val.to_string()),
|
||||
"time" | "t" => event.time = val.parse().ok(),
|
||||
"repeat" | "rep" => event.repeat = val.parse().ok(),
|
||||
"duration" | "dur" | "d" => event.duration = val.parse().ok(),
|
||||
"gate" => event.gate = val.parse().ok(),
|
||||
"voice" => event.voice = val.parse::<f32>().ok().map(|f| f as usize),
|
||||
"reset" => event.reset = Some(val == "1" || val == "true"),
|
||||
"orbit" => event.orbit = val.parse::<f32>().ok().map(|f| f as usize),
|
||||
"freq" => event.freq = val.parse().ok(),
|
||||
"note" => event.freq = val.parse().ok().map(midi2freq),
|
||||
"detune" => event.detune = val.parse().ok(),
|
||||
"speed" => event.speed = val.parse().ok(),
|
||||
"glide" => event.glide = val.parse().ok(),
|
||||
"sound" | "s" => event.sound = Some(val.to_string()),
|
||||
"pw" => event.pw = val.parse().ok(),
|
||||
"spread" => event.spread = val.parse().ok(),
|
||||
"size" => event.size = val.parse().ok(),
|
||||
"mult" => event.mult = val.parse().ok(),
|
||||
"warp" => event.warp = val.parse().ok(),
|
||||
"mirror" => event.mirror = val.parse().ok(),
|
||||
"harmonics" | "harm" => event.harmonics = val.parse().ok(),
|
||||
"timbre" => event.timbre = val.parse().ok(),
|
||||
"morph" => event.morph = val.parse().ok(),
|
||||
"n" => event.n = val.parse::<f32>().ok().map(|f| f as usize),
|
||||
"cut" => event.cut = val.parse::<f32>().ok().map(|f| f as usize),
|
||||
"begin" => event.begin = val.parse().ok(),
|
||||
"end" => event.end = val.parse().ok(),
|
||||
"file_pcm" => event.file_pcm = val.parse().ok(),
|
||||
"file_frames" => event.file_frames = val.parse().ok(),
|
||||
"file_channels" => event.file_channels = val.parse::<f32>().ok().map(|f| f as u8),
|
||||
"file_freq" => event.file_freq = val.parse().ok(),
|
||||
"gain" => event.gain = val.parse().ok(),
|
||||
"postgain" => event.postgain = val.parse().ok(),
|
||||
"velocity" => event.velocity = val.parse().ok(),
|
||||
"pan" => event.pan = val.parse().ok(),
|
||||
"attack" => event.attack = val.parse().ok(),
|
||||
"decay" => event.decay = val.parse().ok(),
|
||||
"sustain" => event.sustain = val.parse().ok(),
|
||||
"release" => event.release = val.parse().ok(),
|
||||
"lpf" | "cutoff" => event.lpf = val.parse().ok(),
|
||||
"lpq" | "resonance" => event.lpq = val.parse().ok(),
|
||||
"lpe" | "lpenv" => event.lpe = val.parse().ok(),
|
||||
"lpa" | "lpattack" => event.lpa = val.parse().ok(),
|
||||
"lpd" | "lpdecay" => event.lpd = val.parse().ok(),
|
||||
"lps" | "lpsustain" => event.lps = val.parse().ok(),
|
||||
"lpr" | "lprelease" => event.lpr = val.parse().ok(),
|
||||
"hpf" | "hcutoff" => event.hpf = val.parse().ok(),
|
||||
"hpq" | "hresonance" => event.hpq = val.parse().ok(),
|
||||
"hpe" | "hpenv" => event.hpe = val.parse().ok(),
|
||||
"hpa" => event.hpa = val.parse().ok(),
|
||||
"hpd" => event.hpd = val.parse().ok(),
|
||||
"hps" => event.hps = val.parse().ok(),
|
||||
"hpr" => event.hpr = val.parse().ok(),
|
||||
"bpf" | "bandf" => event.bpf = val.parse().ok(),
|
||||
"bpq" | "bandq" => event.bpq = val.parse().ok(),
|
||||
"bpe" | "bpenv" => event.bpe = val.parse().ok(),
|
||||
"bpa" | "bpattack" => event.bpa = val.parse().ok(),
|
||||
"bpd" | "bpdecay" => event.bpd = val.parse().ok(),
|
||||
"bps" | "bpsustain" => event.bps = val.parse().ok(),
|
||||
"bpr" | "bprelease" => event.bpr = val.parse().ok(),
|
||||
"ftype" => event.ftype = val.parse().ok(),
|
||||
"penv" => event.penv = val.parse().ok(),
|
||||
"patt" => event.patt = val.parse().ok(),
|
||||
"pdec" => event.pdec = val.parse().ok(),
|
||||
"psus" => event.psus = val.parse().ok(),
|
||||
"prel" => event.prel = val.parse().ok(),
|
||||
"vib" => event.vib = val.parse().ok(),
|
||||
"vibmod" => event.vibmod = val.parse().ok(),
|
||||
"vibshape" => event.vibshape = val.parse().ok(),
|
||||
"fm" | "fmi" => event.fm = val.parse().ok(),
|
||||
"fmh" => event.fmh = val.parse().ok(),
|
||||
"fmshape" => event.fmshape = val.parse().ok(),
|
||||
"fme" => event.fme = val.parse().ok(),
|
||||
"fma" => event.fma = val.parse().ok(),
|
||||
"fmd" => event.fmd = val.parse().ok(),
|
||||
"fms" => event.fms = val.parse().ok(),
|
||||
"fmr" => event.fmr = val.parse().ok(),
|
||||
"am" => event.am = val.parse().ok(),
|
||||
"amdepth" => event.amdepth = val.parse().ok(),
|
||||
"amshape" => event.amshape = val.parse().ok(),
|
||||
"rm" => event.rm = val.parse().ok(),
|
||||
"rmdepth" => event.rmdepth = val.parse().ok(),
|
||||
"rmshape" => event.rmshape = val.parse().ok(),
|
||||
"phaser" | "phaserrate" => event.phaser = val.parse().ok(),
|
||||
"phaserdepth" => event.phaserdepth = val.parse().ok(),
|
||||
"phasersweep" => event.phasersweep = val.parse().ok(),
|
||||
"phasercenter" => event.phasercenter = val.parse().ok(),
|
||||
"flanger" | "flangerrate" => event.flanger = val.parse().ok(),
|
||||
"flangerdepth" => event.flangerdepth = val.parse().ok(),
|
||||
"flangerfeedback" => event.flangerfeedback = val.parse().ok(),
|
||||
"chorus" | "chorusrate" => event.chorus = val.parse().ok(),
|
||||
"chorusdepth" => event.chorusdepth = val.parse().ok(),
|
||||
"chorusdelay" => event.chorusdelay = val.parse().ok(),
|
||||
"comb" => event.comb = val.parse().ok(),
|
||||
"combfreq" => event.combfreq = val.parse().ok(),
|
||||
"combfeedback" => event.combfeedback = val.parse().ok(),
|
||||
"combdamp" => event.combdamp = val.parse().ok(),
|
||||
"coarse" => event.coarse = val.parse().ok(),
|
||||
"crush" => event.crush = val.parse().ok(),
|
||||
"fold" => event.fold = val.parse().ok(),
|
||||
"wrap" => event.wrap = val.parse().ok(),
|
||||
"distort" => event.distort = val.parse().ok(),
|
||||
"distortvol" => event.distortvol = val.parse().ok(),
|
||||
"delay" => event.delay = val.parse().ok(),
|
||||
"delaytime" => event.delaytime = val.parse().ok(),
|
||||
"delayfeedback" => event.delayfeedback = val.parse().ok(),
|
||||
"delaytype" | "dtype" => event.delaytype = val.parse().ok(),
|
||||
"verb" | "reverb" => event.verb = val.parse().ok(),
|
||||
"verbdecay" => event.verbdecay = val.parse().ok(),
|
||||
"verbdamp" => event.verbdamp = val.parse().ok(),
|
||||
"verbpredelay" => event.verbpredelay = val.parse().ok(),
|
||||
"verbdiff" => event.verbdiff = val.parse().ok(),
|
||||
_ => {}
|
||||
}
|
||||
i += 2;
|
||||
}
|
||||
event
|
||||
}
|
||||
}
|
||||
237
src/fastmath.rs
Normal file
237
src/fastmath.rs
Normal file
@@ -0,0 +1,237 @@
|
||||
//! Fast approximations for common mathematical functions.
|
||||
//!
|
||||
//! This module provides SIMD-friendly, branch-minimal implementations of
|
||||
//! transcendental functions optimized for audio synthesis. These trade some
|
||||
//! accuracy for significant performance gains in tight DSP loops.
|
||||
//!
|
||||
//! # Accuracy
|
||||
//!
|
||||
//! | Function | Typical Error |
|
||||
//! |----------|---------------|
|
||||
//! | `exp2f` | < 0.1% |
|
||||
//! | `log2f` | < 0.1% |
|
||||
//! | `sinf` | < 1% |
|
||||
//! | `pow10` | < 1% |
|
||||
//!
|
||||
//! # Implementation Notes
|
||||
//!
|
||||
//! The logarithm and exponential functions exploit IEEE 754 float bit layout,
|
||||
//! extracting and manipulating exponent/mantissa fields directly. Trigonometric
|
||||
//! functions use rational polynomial approximations.
|
||||
|
||||
use std::f32::consts::{LOG2_10, LOG2_E, PI, SQRT_2};
|
||||
|
||||
/// Bit position of the exponent field in IEEE 754 single precision.
|
||||
const F32_EXP_SHIFT: i32 = 23;
|
||||
|
||||
/// Exponent bias for IEEE 754 single precision.
|
||||
const F32_BIAS: i32 = 127;
|
||||
|
||||
/// Fast base-2 logarithm approximation.
|
||||
///
|
||||
/// Uses IEEE 754 bit manipulation to extract the exponent, then applies a
|
||||
/// rational polynomial correction for the mantissa contribution.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Does not panic, but returns meaningless results for `x <= 0`.
|
||||
#[inline]
|
||||
pub fn log2f(x: f32) -> f32 {
|
||||
let bits = x.to_bits();
|
||||
let mantissa_bits = bits & ((1 << F32_EXP_SHIFT) - 1);
|
||||
let biased_mantissa = f32::from_bits(mantissa_bits | ((F32_BIAS as u32 - 1) << F32_EXP_SHIFT));
|
||||
|
||||
let y = bits as f32 * (1.0 / (1 << F32_EXP_SHIFT) as f32);
|
||||
y - 124.225_45 - 1.498_030_3 * biased_mantissa - 1.725_88 / (0.352_088_72 + biased_mantissa)
|
||||
}
|
||||
|
||||
/// Fast base-2 exponential approximation.
|
||||
///
|
||||
/// Separates the integer and fractional parts of the exponent. The integer
|
||||
/// part is computed via bit manipulation, while the fractional part uses a
|
||||
/// Taylor-like polynomial expansion centered at 0.5.
|
||||
#[inline]
|
||||
pub fn exp2f(x: f32) -> f32 {
|
||||
let xf = x.floor();
|
||||
let exp_bits = ((127 + xf as i32) as u32) << 23;
|
||||
let ystep = f32::from_bits(exp_bits);
|
||||
|
||||
let x1 = x - xf;
|
||||
let xt = x1 - 0.5;
|
||||
|
||||
const C1: f32 = 0.980_258_17;
|
||||
const C2: f32 = 0.339_731_57;
|
||||
const C3: f32 = 0.078_494_66;
|
||||
const C4: f32 = 0.013_602_088;
|
||||
|
||||
let ytaylor = SQRT_2 + xt * (C1 + xt * (C2 + xt * (C3 + xt * C4)));
|
||||
|
||||
const M0: f32 = 0.999_944_3;
|
||||
const M1: f32 = 1.000_031_2;
|
||||
|
||||
ystep * ytaylor * (M0 + (M1 - M0) * x1)
|
||||
}
|
||||
|
||||
/// Fast power function: `x^y`.
|
||||
///
|
||||
/// Computed as `2^(y * log2(x))` using fast approximations.
|
||||
///
|
||||
/// # Special Cases
|
||||
///
|
||||
/// - Returns `NAN` if `x < 0`
|
||||
/// - Returns `0.0` if `x == 0`
|
||||
/// - Returns `1.0` if `y == 0`
|
||||
#[inline]
|
||||
pub fn powf(x: f32, y: f32) -> f32 {
|
||||
if x < 0.0 {
|
||||
return f32::NAN;
|
||||
}
|
||||
if x == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
if y == 0.0 {
|
||||
return 1.0;
|
||||
}
|
||||
exp2f(y * log2f(x))
|
||||
}
|
||||
|
||||
/// Fast `e^x - 1` approximation.
|
||||
///
|
||||
/// Useful for small `x` where `e^x` is close to 1 and direct subtraction
|
||||
/// would lose precision (though this fast version doesn't preserve that property).
|
||||
#[inline]
|
||||
pub fn expm1f(x: f32) -> f32 {
|
||||
exp2f(x * LOG2_E) - 1.0
|
||||
}
|
||||
|
||||
/// Computes `0.5^x` (equivalently `2^(-x)`).
|
||||
///
|
||||
/// Useful for exponential decay calculations where the half-life is the
|
||||
/// natural unit.
|
||||
#[inline]
|
||||
pub fn pow1half(x: f32) -> f32 {
|
||||
exp2f(-x)
|
||||
}
|
||||
|
||||
/// Fast `10^x` approximation.
|
||||
///
|
||||
/// Useful for decibel conversions: `pow10(db / 20.0)` gives amplitude ratio.
|
||||
#[inline]
|
||||
pub fn pow10(x: f32) -> f32 {
|
||||
exp2f(x * LOG2_10)
|
||||
}
|
||||
|
||||
/// Wraps angle to the range `[-π, π]`.
|
||||
///
|
||||
/// Essential for maintaining phase coherence in oscillators over long
|
||||
/// running times, preventing floating-point precision loss.
|
||||
#[inline]
|
||||
pub fn modpi(x: f32) -> f32 {
|
||||
let mut x = x + PI;
|
||||
x *= 0.5 / PI;
|
||||
x -= x.floor();
|
||||
x *= 2.0 * PI;
|
||||
x - PI
|
||||
}
|
||||
|
||||
/// Parabolic sine approximation.
|
||||
///
|
||||
/// Very fast but lower accuracy than [`sinf`]. Uses a single parabola
|
||||
/// fitted to match sine at 0, ±π/2, and ±π.
|
||||
#[inline]
|
||||
pub fn par_sinf(x: f32) -> f32 {
|
||||
let x = modpi(x);
|
||||
0.405_284_73 * x * (PI - x.abs())
|
||||
}
|
||||
|
||||
/// Parabolic cosine approximation.
|
||||
///
|
||||
/// Phase-shifted [`par_sinf`].
|
||||
#[inline]
|
||||
pub fn par_cosf(x: f32) -> f32 {
|
||||
par_sinf(x + 0.5 * PI)
|
||||
}
|
||||
|
||||
/// Fast sine approximation using rational polynomial.
|
||||
///
|
||||
/// Higher accuracy than [`par_sinf`] but still significantly faster than
|
||||
/// `std::f32::sin`. Uses a Padé-like rational approximation.
|
||||
#[inline]
|
||||
pub fn sinf(x: f32) -> f32 {
|
||||
let x = 4.0 * (x * (0.5 / PI) - (x * (0.5 / PI) + 0.75).floor() + 0.25).abs() - 1.0;
|
||||
let x = x * (PI / 2.0);
|
||||
|
||||
const C1: f32 = 1.0;
|
||||
const C2: f32 = 445.0 / 12122.0;
|
||||
const C3: f32 = -(2363.0 / 18183.0);
|
||||
const C4: f32 = 601.0 / 872784.0;
|
||||
const C5: f32 = 12671.0 / 4363920.0;
|
||||
const C6: f32 = 121.0 / 16662240.0;
|
||||
|
||||
let xx = x * x;
|
||||
let num = x * (C1 + xx * (C3 + xx * C5));
|
||||
let denom = 1.0 + xx * (C2 + xx * (C4 + xx * C6));
|
||||
num / denom
|
||||
}
|
||||
|
||||
/// Fast cosine approximation.
|
||||
///
|
||||
/// Phase-shifted [`sinf`].
|
||||
#[inline]
|
||||
pub fn cosf(x: f32) -> f32 {
|
||||
sinf(x + 0.5 * PI)
|
||||
}
|
||||
|
||||
/// Flush to zero: clamps small values to zero.
|
||||
///
|
||||
/// Prevents denormalized floating-point numbers which can cause severe
|
||||
/// performance degradation in audio processing loops on some architectures.
|
||||
#[inline]
|
||||
pub fn ftz(x: f32, limit: f32) -> f32 {
|
||||
if x < limit && x > -limit {
|
||||
0.0
|
||||
} else {
|
||||
x
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_exp2f() {
|
||||
for i in -10..10 {
|
||||
let x = i as f32 * 0.5;
|
||||
let fast = exp2f(x);
|
||||
let std = 2.0_f32.powf(x);
|
||||
assert!(
|
||||
(fast - std).abs() < 0.001,
|
||||
"exp2f({x}) = {fast} vs std {std}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sinf() {
|
||||
for i in 0..20 {
|
||||
let x = (i as f32 - 10.0) * 0.5;
|
||||
let fast = sinf(x);
|
||||
let std = x.sin();
|
||||
assert!((fast - std).abs() < 0.01, "sinf({x}) = {fast} vs std {std}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pow10() {
|
||||
for i in -5..5 {
|
||||
let x = i as f32 * 0.5;
|
||||
let fast = pow10(x);
|
||||
let std = 10.0_f32.powf(x);
|
||||
assert!(
|
||||
(fast - std).abs() / std < 0.01,
|
||||
"pow10({x}) = {fast} vs std {std}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
261
src/filter.rs
Normal file
261
src/filter.rs
Normal file
@@ -0,0 +1,261 @@
|
||||
//! Biquad filter implementation for audio processing.
|
||||
//!
|
||||
//! Provides a second-order IIR (biquad) filter with multiple filter types and
|
||||
//! coefficient caching for efficient real-time parameter modulation.
|
||||
//!
|
||||
//! # Filter Types
|
||||
//!
|
||||
//! | Type | Description |
|
||||
//! |-------------|--------------------------------------------------|
|
||||
//! | Lowpass | Attenuates frequencies above cutoff |
|
||||
//! | Highpass | Attenuates frequencies below cutoff |
|
||||
//! | Bandpass | Passes frequencies near cutoff, attenuates rest |
|
||||
//! | Notch | Attenuates frequencies near cutoff |
|
||||
//! | Allpass | Passes all frequencies, shifts phase |
|
||||
//! | Peaking | Boosts/cuts frequencies near cutoff |
|
||||
//! | Lowshelf | Boosts/cuts frequencies below cutoff |
|
||||
//! | Highshelf | Boosts/cuts frequencies above cutoff |
|
||||
//!
|
||||
//! # Coefficient Formulas
|
||||
//!
|
||||
//! Based on Robert Bristow-Johnson's Audio EQ Cookbook.
|
||||
|
||||
use crate::fastmath::{par_cosf, par_sinf, pow10};
|
||||
use crate::types::FilterType;
|
||||
use std::f32::consts::PI;
|
||||
|
||||
/// Second-order IIR (biquad) filter with coefficient caching.
|
||||
///
|
||||
/// Implements the standard Direct Form I difference equation:
|
||||
///
|
||||
/// ```text
|
||||
/// y[n] = b0*x[n] + b1*x[n-1] + b2*x[n-2] - a1*y[n-1] - a2*y[n-2]
|
||||
/// ```
|
||||
///
|
||||
/// Coefficients are recalculated only when parameters change beyond a threshold,
|
||||
/// reducing CPU overhead during smooth parameter automation.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Biquad {
|
||||
// Feedforward coefficients (numerator)
|
||||
b0: f32,
|
||||
b1: f32,
|
||||
b2: f32,
|
||||
// Feedback coefficients (denominator, negated)
|
||||
a1: f32,
|
||||
a2: f32,
|
||||
// Input delay line
|
||||
x1: f32,
|
||||
x2: f32,
|
||||
// Output delay line
|
||||
y1: f32,
|
||||
y2: f32,
|
||||
// Cached parameters for change detection
|
||||
cached_freq: f32,
|
||||
cached_q: f32,
|
||||
cached_gain: f32,
|
||||
cached_filter_type: FilterType,
|
||||
}
|
||||
|
||||
impl Default for Biquad {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
b0: 0.0,
|
||||
b1: 0.0,
|
||||
b2: 0.0,
|
||||
a1: 0.0,
|
||||
a2: 0.0,
|
||||
x1: 0.0,
|
||||
x2: 0.0,
|
||||
y1: 0.0,
|
||||
y2: 0.0,
|
||||
cached_freq: 0.0,
|
||||
cached_q: 0.0,
|
||||
cached_gain: 0.0,
|
||||
cached_filter_type: FilterType::Lowpass,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Biquad {
|
||||
/// Checks if parameters have changed enough to warrant coefficient recalculation.
|
||||
///
|
||||
/// Uses relative thresholds: 0.1% for frequency and Q, 0.01 dB for gain.
|
||||
#[inline]
|
||||
fn needs_recalc(&self, freq: f32, q: f32, gain: f32, filter_type: FilterType) -> bool {
|
||||
if filter_type != self.cached_filter_type {
|
||||
return true;
|
||||
}
|
||||
let freq_delta = (freq - self.cached_freq).abs() / self.cached_freq.max(1.0);
|
||||
let q_delta = (q - self.cached_q).abs() / self.cached_q.max(0.1);
|
||||
let gain_delta = (gain - self.cached_gain).abs();
|
||||
freq_delta > 0.001 || q_delta > 0.001 || gain_delta > 0.01
|
||||
}
|
||||
|
||||
/// Processes a single sample through the filter.
|
||||
///
|
||||
/// Convenience wrapper for [`Biquad::process_with_gain`] with `gain = 0.0`.
|
||||
pub fn process(
|
||||
&mut self,
|
||||
input: f32,
|
||||
filter_type: FilterType,
|
||||
freq: f32,
|
||||
q: f32,
|
||||
sr: f32,
|
||||
) -> f32 {
|
||||
self.process_with_gain(input, filter_type, freq, q, 0.0, sr)
|
||||
}
|
||||
|
||||
/// Processes a single sample with gain parameter for shelving/peaking filters.
|
||||
///
|
||||
/// Recalculates coefficients only when parameters change significantly.
|
||||
/// For lowpass and highpass, `q` is interpreted as resonance in dB.
|
||||
/// For other types, `q` is the Q factor directly.
|
||||
///
|
||||
/// # Parameters
|
||||
///
|
||||
/// - `input`: Input sample
|
||||
/// - `filter_type`: Type of filter response
|
||||
/// - `freq`: Cutoff/center frequency in Hz
|
||||
/// - `q`: Q factor or resonance (interpretation depends on filter type)
|
||||
/// - `gain`: Boost/cut in dB (only used by peaking and shelving types)
|
||||
/// - `sr`: Sample rate in Hz
|
||||
pub fn process_with_gain(
|
||||
&mut self,
|
||||
input: f32,
|
||||
filter_type: FilterType,
|
||||
freq: f32,
|
||||
q: f32,
|
||||
gain: f32,
|
||||
sr: f32,
|
||||
) -> f32 {
|
||||
let freq = freq.clamp(1.0, sr * 0.45);
|
||||
if self.needs_recalc(freq, q, gain, filter_type) {
|
||||
let omega = 2.0 * PI * freq / sr;
|
||||
let sin_omega = par_sinf(omega);
|
||||
let cos_omega = par_cosf(omega);
|
||||
|
||||
let q_linear = match filter_type {
|
||||
FilterType::Lowpass | FilterType::Highpass => pow10(q / 20.0),
|
||||
_ => q,
|
||||
};
|
||||
let alpha = sin_omega / (2.0 * q_linear);
|
||||
|
||||
let (b0, b1, b2, a0, a1, a2) = match filter_type {
|
||||
FilterType::Lowpass => {
|
||||
let b1 = 1.0 - cos_omega;
|
||||
let b0 = b1 / 2.0;
|
||||
let b2 = b0;
|
||||
let a0 = 1.0 + alpha;
|
||||
let a1 = -2.0 * cos_omega;
|
||||
let a2 = 1.0 - alpha;
|
||||
(b0, b1, b2, a0, a1, a2)
|
||||
}
|
||||
FilterType::Highpass => {
|
||||
let b0 = (1.0 + cos_omega) / 2.0;
|
||||
let b1 = -(1.0 + cos_omega);
|
||||
let b2 = b0;
|
||||
let a0 = 1.0 + alpha;
|
||||
let a1 = -2.0 * cos_omega;
|
||||
let a2 = 1.0 - alpha;
|
||||
(b0, b1, b2, a0, a1, a2)
|
||||
}
|
||||
FilterType::Bandpass => {
|
||||
let b0 = sin_omega / 2.0;
|
||||
let b1 = 0.0;
|
||||
let b2 = -b0;
|
||||
let a0 = 1.0 + alpha;
|
||||
let a1 = -2.0 * cos_omega;
|
||||
let a2 = 1.0 - alpha;
|
||||
(b0, b1, b2, a0, a1, a2)
|
||||
}
|
||||
FilterType::Notch => {
|
||||
let b0 = 1.0;
|
||||
let b1 = -2.0 * cos_omega;
|
||||
let b2 = 1.0;
|
||||
let a0 = 1.0 + alpha;
|
||||
let a1 = -2.0 * cos_omega;
|
||||
let a2 = 1.0 - alpha;
|
||||
(b0, b1, b2, a0, a1, a2)
|
||||
}
|
||||
FilterType::Allpass => {
|
||||
let b0 = 1.0 - alpha;
|
||||
let b1 = -2.0 * cos_omega;
|
||||
let b2 = 1.0 + alpha;
|
||||
let a0 = 1.0 + alpha;
|
||||
let a1 = -2.0 * cos_omega;
|
||||
let a2 = 1.0 - alpha;
|
||||
(b0, b1, b2, a0, a1, a2)
|
||||
}
|
||||
FilterType::Peaking => {
|
||||
let a = pow10(gain / 40.0);
|
||||
let b0 = 1.0 + alpha * a;
|
||||
let b1 = -2.0 * cos_omega;
|
||||
let b2 = 1.0 - alpha * a;
|
||||
let a0 = 1.0 + alpha / a;
|
||||
let a1 = -2.0 * cos_omega;
|
||||
let a2 = 1.0 - alpha / a;
|
||||
(b0, b1, b2, a0, a1, a2)
|
||||
}
|
||||
FilterType::Lowshelf => {
|
||||
let a = pow10(gain / 40.0);
|
||||
let sqrt2_a_alpha = 2.0 * a.sqrt() * alpha;
|
||||
let am1_cos = (a - 1.0) * cos_omega;
|
||||
let ap1_cos = (a + 1.0) * cos_omega;
|
||||
let b0 = a * ((a + 1.0) - am1_cos + sqrt2_a_alpha);
|
||||
let b1 = 2.0 * a * ((a - 1.0) - ap1_cos);
|
||||
let b2 = a * ((a + 1.0) - am1_cos - sqrt2_a_alpha);
|
||||
let a0 = (a + 1.0) + am1_cos + sqrt2_a_alpha;
|
||||
let a1 = -2.0 * ((a - 1.0) + ap1_cos);
|
||||
let a2 = (a + 1.0) + am1_cos - sqrt2_a_alpha;
|
||||
(b0, b1, b2, a0, a1, a2)
|
||||
}
|
||||
FilterType::Highshelf => {
|
||||
let a = pow10(gain / 40.0);
|
||||
let sqrt2_a_alpha = 2.0 * a.sqrt() * alpha;
|
||||
let am1_cos = (a - 1.0) * cos_omega;
|
||||
let ap1_cos = (a + 1.0) * cos_omega;
|
||||
let b0 = a * ((a + 1.0) + am1_cos + sqrt2_a_alpha);
|
||||
let b1 = -2.0 * a * ((a - 1.0) + ap1_cos);
|
||||
let b2 = a * ((a + 1.0) + am1_cos - sqrt2_a_alpha);
|
||||
let a0 = (a + 1.0) - am1_cos + sqrt2_a_alpha;
|
||||
let a1 = 2.0 * ((a - 1.0) - ap1_cos);
|
||||
let a2 = (a + 1.0) - am1_cos - sqrt2_a_alpha;
|
||||
(b0, b1, b2, a0, a1, a2)
|
||||
}
|
||||
};
|
||||
|
||||
self.b0 = b0 / a0;
|
||||
self.b1 = b1 / a0;
|
||||
self.b2 = b2 / a0;
|
||||
self.a1 = a1 / a0;
|
||||
self.a2 = a2 / a0;
|
||||
|
||||
self.cached_freq = freq;
|
||||
self.cached_q = q;
|
||||
self.cached_gain = gain;
|
||||
self.cached_filter_type = filter_type;
|
||||
}
|
||||
|
||||
let output = self.b0 * input + self.b1 * self.x1 + self.b2 * self.x2
|
||||
- self.a1 * self.y1
|
||||
- self.a2 * self.y2;
|
||||
|
||||
self.x2 = self.x1;
|
||||
self.x1 = input;
|
||||
self.y2 = self.y1;
|
||||
self.y1 = output;
|
||||
|
||||
output
|
||||
}
|
||||
}
|
||||
|
||||
/// Multi-stage filter state for cascaded biquad processing.
|
||||
///
|
||||
/// Contains up to 4 biquad stages for steeper filter slopes (up to 48 dB/octave).
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct FilterState {
|
||||
/// Current cutoff frequency in Hz.
|
||||
pub cutoff: f32,
|
||||
/// Cascaded biquad filter stages.
|
||||
pub biquads: [Biquad; 4],
|
||||
}
|
||||
676
src/lib.rs
Normal file
676
src/lib.rs
Normal file
@@ -0,0 +1,676 @@
|
||||
#[cfg(feature = "native")]
|
||||
pub mod audio;
|
||||
#[cfg(feature = "native")]
|
||||
pub mod config;
|
||||
pub mod effects;
|
||||
pub mod envelope;
|
||||
#[cfg(feature = "native")]
|
||||
pub mod error;
|
||||
pub mod event;
|
||||
pub mod fastmath;
|
||||
pub mod filter;
|
||||
#[cfg(feature = "native")]
|
||||
pub mod loader;
|
||||
pub mod noise;
|
||||
pub mod orbit;
|
||||
#[cfg(feature = "native")]
|
||||
pub mod osc;
|
||||
pub mod oscillator;
|
||||
pub mod plaits;
|
||||
pub mod sample;
|
||||
pub mod schedule;
|
||||
#[cfg(feature = "native")]
|
||||
pub mod telemetry;
|
||||
pub mod types;
|
||||
pub mod voice;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod wasm;
|
||||
|
||||
use envelope::init_envelope;
|
||||
use event::Event;
|
||||
use orbit::{EffectParams, Orbit};
|
||||
use sample::{FileSource, SampleEntry, SampleInfo, SamplePool, WebSampleSource};
|
||||
use schedule::Schedule;
|
||||
#[cfg(feature = "native")]
|
||||
use telemetry::EngineMetrics;
|
||||
use types::{DelayType, Source, BLOCK_SIZE, CHANNELS, MAX_ORBITS, MAX_VOICES};
|
||||
use voice::{Voice, VoiceParams};
|
||||
|
||||
pub struct Engine {
|
||||
pub sr: f32,
|
||||
pub isr: f32,
|
||||
pub voices: Vec<Voice>,
|
||||
pub active_voices: usize,
|
||||
pub orbits: Vec<Orbit>,
|
||||
pub schedule: Schedule,
|
||||
pub time: f64,
|
||||
pub tick: u64,
|
||||
pub output_channels: usize,
|
||||
pub output: Vec<f32>,
|
||||
// Sample storage
|
||||
pub sample_pool: SamplePool,
|
||||
pub samples: Vec<SampleInfo>,
|
||||
pub sample_index: Vec<SampleEntry>,
|
||||
// Default orbit params (used when voice doesn't specify)
|
||||
pub effect_params: EffectParams,
|
||||
// Telemetry (native only)
|
||||
#[cfg(feature = "native")]
|
||||
pub metrics: EngineMetrics,
|
||||
}
|
||||
|
||||
impl Engine {
|
||||
pub fn new(sample_rate: f32) -> Self {
|
||||
Self::new_with_channels(sample_rate, CHANNELS)
|
||||
}
|
||||
|
||||
pub fn new_with_channels(sample_rate: f32, output_channels: usize) -> Self {
|
||||
let mut orbits = Vec::with_capacity(MAX_ORBITS);
|
||||
for _ in 0..MAX_ORBITS {
|
||||
orbits.push(Orbit::new(sample_rate));
|
||||
}
|
||||
|
||||
Self {
|
||||
sr: sample_rate,
|
||||
isr: 1.0 / sample_rate,
|
||||
voices: vec![Voice::default(); MAX_VOICES],
|
||||
active_voices: 0,
|
||||
orbits,
|
||||
schedule: Schedule::new(),
|
||||
time: 0.0,
|
||||
tick: 0,
|
||||
output_channels,
|
||||
output: vec![0.0; BLOCK_SIZE * output_channels],
|
||||
sample_pool: SamplePool::new(),
|
||||
samples: Vec::with_capacity(256),
|
||||
sample_index: Vec::new(),
|
||||
effect_params: EffectParams {
|
||||
delay_time: 0.333,
|
||||
delay_feedback: 0.6,
|
||||
delay_type: DelayType::Standard,
|
||||
verb_decay: 0.75,
|
||||
verb_damp: 0.95,
|
||||
verb_predelay: 0.1,
|
||||
verb_diff: 0.7,
|
||||
comb_freq: 220.0,
|
||||
comb_feedback: 0.9,
|
||||
comb_damp: 0.1,
|
||||
},
|
||||
#[cfg(feature = "native")]
|
||||
metrics: EngineMetrics::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_sample(&mut self, samples: &[f32], channels: u8, freq: f32) -> Option<usize> {
|
||||
let info = self.sample_pool.add(samples, channels, freq)?;
|
||||
let idx = self.samples.len();
|
||||
self.samples.push(info);
|
||||
Some(idx)
|
||||
}
|
||||
|
||||
/// Look up sample by name (e.g., "wave_tek") and n (e.g., 0 for "wave_tek/0")
|
||||
/// n wraps around using modulo if it exceeds the folder count
|
||||
fn find_sample_index(&self, name: &str, n: usize) -> Option<usize> {
|
||||
let prefix = format!("{name}/");
|
||||
let count = self
|
||||
.sample_index
|
||||
.iter()
|
||||
.filter(|e| e.name.starts_with(&prefix))
|
||||
.count();
|
||||
if count == 0 {
|
||||
return None;
|
||||
}
|
||||
let wrapped_n = n % count;
|
||||
let target = format!("{name}/{wrapped_n}");
|
||||
self.sample_index.iter().position(|e| e.name == target)
|
||||
}
|
||||
|
||||
/// Get a loaded sample index, loading lazily if needed (native only)
|
||||
fn get_or_load_sample(&mut self, name: &str, n: usize) -> Option<usize> {
|
||||
// First check if this is a direct index into already-loaded samples (WASM path)
|
||||
// For WASM, treat `name` as numeric index if sample_index is empty
|
||||
if self.sample_index.is_empty() {
|
||||
let idx: usize = name.parse().ok()?;
|
||||
if idx < self.samples.len() {
|
||||
return Some(idx);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
// Find the sample in the index by name
|
||||
let index_idx = self.find_sample_index(name, n)?;
|
||||
|
||||
// If already loaded, return the loaded index
|
||||
if let Some(loaded_idx) = self.sample_index[index_idx].loaded {
|
||||
return Some(loaded_idx);
|
||||
}
|
||||
|
||||
// Load the sample now (native only)
|
||||
#[cfg(feature = "native")]
|
||||
{
|
||||
let path = self.sample_index[index_idx].path.clone();
|
||||
match loader::load_sample_file(self, &path) {
|
||||
Ok(loaded_idx) => {
|
||||
self.sample_index[index_idx].loaded = Some(loaded_idx);
|
||||
Some(loaded_idx)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"Failed to load sample {}: {e}",
|
||||
self.sample_index[index_idx].name
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "native"))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn evaluate(&mut self, input: &str) -> Option<usize> {
|
||||
let event = Event::parse(input);
|
||||
|
||||
// Default to "play" if no explicit command - matches dough's JS wrapper behavior
|
||||
let cmd = event.cmd.as_deref().unwrap_or("play");
|
||||
|
||||
match cmd {
|
||||
"play" => self.play_event(event),
|
||||
"hush" => {
|
||||
self.hush();
|
||||
None
|
||||
}
|
||||
"panic" => {
|
||||
self.panic();
|
||||
None
|
||||
}
|
||||
"reset" => {
|
||||
self.panic();
|
||||
self.schedule.clear();
|
||||
self.time = 0.0;
|
||||
self.tick = 0;
|
||||
None
|
||||
}
|
||||
"release" => {
|
||||
if let Some(v) = event.voice {
|
||||
if v < self.active_voices {
|
||||
self.voices[v].params.gate = 0.0;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
"hush_endless" => {
|
||||
for i in 0..self.active_voices {
|
||||
if self.voices[i].params.duration.is_none() {
|
||||
self.voices[i].params.gate = 0.0;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
"reset_time" => {
|
||||
self.time = 0.0;
|
||||
self.tick = 0;
|
||||
None
|
||||
}
|
||||
"reset_schedule" => {
|
||||
self.schedule.clear();
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn play_event(&mut self, event: Event) -> Option<usize> {
|
||||
if event.time.is_some() {
|
||||
// ALL events with time go to schedule (like dough.c)
|
||||
// This ensures repeat works correctly for time=0 events
|
||||
self.schedule.push(event);
|
||||
return None;
|
||||
}
|
||||
self.process_event(&event)
|
||||
}
|
||||
|
||||
pub fn play(&mut self, params: VoiceParams) -> Option<usize> {
|
||||
if self.active_voices >= MAX_VOICES {
|
||||
return None;
|
||||
}
|
||||
let i = self.active_voices;
|
||||
self.voices[i] = Voice::default();
|
||||
self.voices[i].params = params;
|
||||
self.voices[i].sr = self.sr;
|
||||
self.active_voices += 1;
|
||||
Some(i)
|
||||
}
|
||||
|
||||
/// Process an event, handling voice selection like dough.c's process_engine_event()
|
||||
fn process_event(&mut self, event: &Event) -> Option<usize> {
|
||||
// Cut group: release any voices in the same cut group
|
||||
if let Some(cut) = event.cut {
|
||||
for i in 0..self.active_voices {
|
||||
if self.voices[i].params.cut == Some(cut) {
|
||||
self.voices[i].params.gate = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (voice_idx, is_new_voice) = if let Some(v) = event.voice {
|
||||
if v < self.active_voices {
|
||||
// Voice exists - reuse it
|
||||
(v, false)
|
||||
} else {
|
||||
// Voice index out of range - allocate new
|
||||
if self.active_voices >= MAX_VOICES {
|
||||
return None;
|
||||
}
|
||||
let i = self.active_voices;
|
||||
self.active_voices += 1;
|
||||
(i, true)
|
||||
}
|
||||
} else {
|
||||
// No voice specified - allocate new
|
||||
if self.active_voices >= MAX_VOICES {
|
||||
return None;
|
||||
}
|
||||
let i = self.active_voices;
|
||||
self.active_voices += 1;
|
||||
(i, true)
|
||||
};
|
||||
|
||||
let should_reset = is_new_voice || event.reset.unwrap_or(false);
|
||||
|
||||
if should_reset {
|
||||
self.voices[voice_idx] = Voice::default();
|
||||
self.voices[voice_idx].sr = self.sr;
|
||||
// Initialize glide_lag to target freq to prevent glide from 0
|
||||
if let Some(freq) = event.freq {
|
||||
self.voices[voice_idx].glide_lag.s = freq;
|
||||
}
|
||||
}
|
||||
|
||||
// Update voice params (only the ones explicitly set in event)
|
||||
self.update_voice_params(voice_idx, event);
|
||||
|
||||
Some(voice_idx)
|
||||
}
|
||||
|
||||
/// Update voice params - only updates fields that are explicitly set in the event
|
||||
fn update_voice_params(&mut self, idx: usize, event: &Event) {
|
||||
macro_rules! copy_opt {
|
||||
($src:expr, $dst:expr, $($field:ident),+ $(,)?) => {
|
||||
$(if let Some(val) = $src.$field { $dst.$field = val; })+
|
||||
};
|
||||
}
|
||||
macro_rules! copy_opt_some {
|
||||
($src:expr, $dst:expr, $($field:ident),+ $(,)?) => {
|
||||
$(if let Some(val) = $src.$field { $dst.$field = Some(val); })+
|
||||
};
|
||||
}
|
||||
// Resolve sound/sample first (before borrowing voice)
|
||||
// If sound parses as a Source, use it; otherwise treat as sample folder name
|
||||
let (parsed_source, loaded_sample) = if let Some(ref sound_str) = event.sound {
|
||||
if let Ok(source) = sound_str.parse::<Source>() {
|
||||
(Some(source), None)
|
||||
} else {
|
||||
// Treat as sample folder name
|
||||
let sample = self.get_or_load_sample(sound_str, event.n.unwrap_or(0));
|
||||
(None, sample)
|
||||
}
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
let v = &mut self.voices[idx];
|
||||
|
||||
// --- Pitch ---
|
||||
copy_opt!(event, v.params, freq, detune, speed);
|
||||
copy_opt_some!(event, v.params, glide);
|
||||
|
||||
// --- Source ---
|
||||
if let Some(source) = parsed_source {
|
||||
v.params.sound = source;
|
||||
}
|
||||
copy_opt!(event, v.params, pw, spread);
|
||||
if let Some(size) = event.size {
|
||||
v.params.shape.size = size.min(256);
|
||||
}
|
||||
if let Some(mult) = event.mult {
|
||||
v.params.shape.mult = mult.clamp(0.25, 16.0);
|
||||
}
|
||||
if let Some(warp) = event.warp {
|
||||
v.params.shape.warp = warp.clamp(-1.0, 1.0);
|
||||
}
|
||||
if let Some(mirror) = event.mirror {
|
||||
v.params.shape.mirror = mirror.clamp(0.0, 1.0);
|
||||
}
|
||||
if let Some(harmonics) = event.harmonics {
|
||||
v.params.harmonics = harmonics.clamp(0.01, 0.999);
|
||||
}
|
||||
if let Some(timbre) = event.timbre {
|
||||
v.params.timbre = timbre.clamp(0.01, 0.999);
|
||||
}
|
||||
if let Some(morph) = event.morph {
|
||||
v.params.morph = morph.clamp(0.01, 0.999);
|
||||
}
|
||||
copy_opt_some!(event, v.params, cut);
|
||||
|
||||
// Sample playback (native)
|
||||
if let Some(sample_idx) = loaded_sample {
|
||||
v.params.sound = Source::Sample;
|
||||
let begin = event.begin.unwrap_or(0.0);
|
||||
let end = event.end.unwrap_or(1.0);
|
||||
v.file_source = Some(FileSource::new(sample_idx, begin, end));
|
||||
} else if event.begin.is_some() || event.end.is_some() {
|
||||
// Update begin/end on existing file_source
|
||||
if let Some(ref mut fs) = v.file_source {
|
||||
if let Some(begin) = event.begin {
|
||||
fs.begin = begin.clamp(0.0, 1.0);
|
||||
}
|
||||
if let Some(end) = event.end {
|
||||
fs.end = end.clamp(fs.begin, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Web sample playback (WASM - set by JavaScript)
|
||||
if let (Some(offset), Some(frames)) = (event.file_pcm, event.file_frames) {
|
||||
v.params.sound = Source::WebSample;
|
||||
v.web_sample = Some(WebSampleSource::new(
|
||||
SampleInfo {
|
||||
offset,
|
||||
frames: frames as u32,
|
||||
channels: event.file_channels.unwrap_or(1),
|
||||
freq: event.file_freq.unwrap_or(65.406),
|
||||
},
|
||||
event.begin.unwrap_or(0.0),
|
||||
event.end.unwrap_or(1.0),
|
||||
));
|
||||
}
|
||||
|
||||
// --- Gain ---
|
||||
copy_opt!(event, v.params, gain, postgain, velocity, pan, gate);
|
||||
copy_opt_some!(event, v.params, duration);
|
||||
|
||||
// --- Gain Envelope ---
|
||||
let gain_env = init_envelope(
|
||||
None,
|
||||
event.attack,
|
||||
event.decay,
|
||||
event.sustain,
|
||||
event.release,
|
||||
);
|
||||
if gain_env.active {
|
||||
v.params.attack = gain_env.att;
|
||||
v.params.decay = gain_env.dec;
|
||||
v.params.sustain = gain_env.sus;
|
||||
v.params.release = gain_env.rel;
|
||||
}
|
||||
|
||||
// --- Filters ---
|
||||
// Macro to apply envelope params (env amount + ADSR) to a target
|
||||
macro_rules! apply_env {
|
||||
($src:expr, $dst:expr, $e:ident, $a:ident, $d:ident, $s:ident, $r:ident, $active:ident) => {
|
||||
let env = init_envelope($src.$e, $src.$a, $src.$d, $src.$s, $src.$r);
|
||||
if env.active {
|
||||
$dst.$e = env.env;
|
||||
$dst.$a = env.att;
|
||||
$dst.$d = env.dec;
|
||||
$dst.$s = env.sus;
|
||||
$dst.$r = env.rel;
|
||||
$dst.$active = true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
copy_opt_some!(event, v.params, lpf);
|
||||
copy_opt!(event, v.params, lpq);
|
||||
apply_env!(event, v.params, lpe, lpa, lpd, lps, lpr, lp_env_active);
|
||||
|
||||
copy_opt_some!(event, v.params, hpf);
|
||||
copy_opt!(event, v.params, hpq);
|
||||
apply_env!(event, v.params, hpe, hpa, hpd, hps, hpr, hp_env_active);
|
||||
|
||||
copy_opt_some!(event, v.params, bpf);
|
||||
copy_opt!(event, v.params, bpq);
|
||||
apply_env!(event, v.params, bpe, bpa, bpd, bps, bpr, bp_env_active);
|
||||
|
||||
copy_opt!(event, v.params, ftype);
|
||||
|
||||
// --- Modulation ---
|
||||
apply_env!(
|
||||
event,
|
||||
v.params,
|
||||
penv,
|
||||
patt,
|
||||
pdec,
|
||||
psus,
|
||||
prel,
|
||||
pitch_env_active
|
||||
);
|
||||
copy_opt!(event, v.params, vib, vibmod, vibshape);
|
||||
copy_opt!(event, v.params, fm, fmh, fmshape);
|
||||
apply_env!(event, v.params, fme, fma, fmd, fms, fmr, fm_env_active);
|
||||
copy_opt!(event, v.params, am, amdepth, amshape);
|
||||
copy_opt!(event, v.params, rm, rmdepth, rmshape);
|
||||
|
||||
// --- Effects ---
|
||||
copy_opt!(
|
||||
event,
|
||||
v.params,
|
||||
phaser,
|
||||
phaserdepth,
|
||||
phasersweep,
|
||||
phasercenter
|
||||
);
|
||||
copy_opt!(event, v.params, flanger, flangerdepth, flangerfeedback);
|
||||
copy_opt!(event, v.params, chorus, chorusdepth, chorusdelay);
|
||||
copy_opt!(event, v.params, comb, combfreq, combfeedback, combdamp);
|
||||
copy_opt_some!(event, v.params, coarse, crush, fold, wrap, distort);
|
||||
copy_opt!(event, v.params, distortvol);
|
||||
|
||||
// --- Sends ---
|
||||
copy_opt!(
|
||||
event,
|
||||
v.params,
|
||||
orbit,
|
||||
delay,
|
||||
delaytime,
|
||||
delayfeedback,
|
||||
delaytype
|
||||
);
|
||||
copy_opt!(
|
||||
event,
|
||||
v.params,
|
||||
verb,
|
||||
verbdecay,
|
||||
verbdamp,
|
||||
verbpredelay,
|
||||
verbdiff
|
||||
);
|
||||
}
|
||||
|
||||
fn free_voice(&mut self, i: usize) {
|
||||
if self.active_voices > 0 {
|
||||
self.active_voices -= 1;
|
||||
self.voices.swap(i, self.active_voices);
|
||||
}
|
||||
}
|
||||
|
||||
fn process_schedule(&mut self) {
|
||||
loop {
|
||||
// O(1) early-exit: check only the first (earliest) event
|
||||
let t = match self.schedule.peek_time() {
|
||||
Some(t) if t <= self.time => t,
|
||||
_ => return,
|
||||
};
|
||||
|
||||
let diff = self.time - t;
|
||||
let mut event = self.schedule.pop_front().unwrap();
|
||||
|
||||
// Fire only if event is fresh (within 1ms) - matches dough.c WASM
|
||||
// Old events are silently rescheduled to catch up
|
||||
if diff < 0.001 {
|
||||
self.process_event(&event);
|
||||
}
|
||||
|
||||
// Reschedule repeating events (re-insert in sorted order)
|
||||
if let Some(rep) = event.repeat {
|
||||
event.time = Some(t + rep as f64);
|
||||
self.schedule.push(event);
|
||||
}
|
||||
// Loop continues for catch-up behavior
|
||||
}
|
||||
}
|
||||
|
||||
pub fn gen_sample(
|
||||
&mut self,
|
||||
output: &mut [f32],
|
||||
sample_idx: usize,
|
||||
web_pcm: &[f32],
|
||||
live_input: &[f32],
|
||||
) {
|
||||
let base_idx = sample_idx * self.output_channels;
|
||||
let num_pairs = self.output_channels / 2;
|
||||
|
||||
for c in 0..self.output_channels {
|
||||
output[base_idx + c] = 0.0;
|
||||
}
|
||||
|
||||
// Clear orbit sends
|
||||
for orbit in &mut self.orbits {
|
||||
orbit.clear_sends();
|
||||
}
|
||||
|
||||
// Process voices - matches dough.c behavior exactly:
|
||||
// When a voice dies, it's freed immediately and the loop continues,
|
||||
// which means the swapped-in voice (from the end) gets skipped this frame.
|
||||
let isr = self.isr;
|
||||
let num_orbits = self.orbits.len();
|
||||
|
||||
let mut i = 0;
|
||||
while i < self.active_voices {
|
||||
// Reborrow for each iteration to allow free_voice during loop
|
||||
let pool = self.sample_pool.data.as_slice();
|
||||
let samples = self.samples.as_slice();
|
||||
|
||||
let alive = self.voices[i].process(isr, pool, samples, web_pcm, sample_idx, live_input);
|
||||
if !alive {
|
||||
self.free_voice(i);
|
||||
// Match dough.c: increment i, skipping the swapped-in voice
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let orbit_idx = self.voices[i].params.orbit % num_orbits;
|
||||
let out_pair = orbit_idx % num_pairs;
|
||||
let pair_offset = out_pair * 2;
|
||||
|
||||
output[base_idx + pair_offset] += self.voices[i].ch[0];
|
||||
output[base_idx + pair_offset + 1] += self.voices[i].ch[1];
|
||||
|
||||
// Add to orbit sends
|
||||
if self.voices[i].params.delay > 0.0 {
|
||||
for c in 0..CHANNELS {
|
||||
self.orbits[orbit_idx]
|
||||
.add_delay_send(c, self.voices[i].ch[c] * self.voices[i].params.delay);
|
||||
}
|
||||
// Update orbit delay params from voice
|
||||
self.effect_params.delay_time = self.voices[i].params.delaytime;
|
||||
self.effect_params.delay_feedback = self.voices[i].params.delayfeedback;
|
||||
self.effect_params.delay_type = self.voices[i].params.delaytype;
|
||||
}
|
||||
if self.voices[i].params.verb > 0.0 {
|
||||
for c in 0..CHANNELS {
|
||||
self.orbits[orbit_idx]
|
||||
.add_verb_send(c, self.voices[i].ch[c] * self.voices[i].params.verb);
|
||||
}
|
||||
// Update orbit verb params from voice
|
||||
self.effect_params.verb_decay = self.voices[i].params.verbdecay;
|
||||
self.effect_params.verb_damp = self.voices[i].params.verbdamp;
|
||||
self.effect_params.verb_predelay = self.voices[i].params.verbpredelay;
|
||||
self.effect_params.verb_diff = self.voices[i].params.verbdiff;
|
||||
}
|
||||
if self.voices[i].params.comb > 0.0 {
|
||||
for c in 0..CHANNELS {
|
||||
self.orbits[orbit_idx]
|
||||
.add_comb_send(c, self.voices[i].ch[c] * self.voices[i].params.comb);
|
||||
}
|
||||
// Update orbit comb params from voice
|
||||
self.effect_params.comb_freq = self.voices[i].params.combfreq;
|
||||
self.effect_params.comb_feedback = self.voices[i].params.combfeedback;
|
||||
self.effect_params.comb_damp = self.voices[i].params.combdamp;
|
||||
}
|
||||
|
||||
i += 1;
|
||||
}
|
||||
|
||||
for (orbit_idx, orbit) in self.orbits.iter_mut().enumerate() {
|
||||
orbit.process(&self.effect_params);
|
||||
|
||||
let out_pair = orbit_idx % num_pairs;
|
||||
let pair_offset = out_pair * 2;
|
||||
output[base_idx + pair_offset] +=
|
||||
orbit.delay_out[0] + orbit.verb_out[0] + orbit.comb_out[0];
|
||||
output[base_idx + pair_offset + 1] +=
|
||||
orbit.delay_out[1] + orbit.verb_out[1] + orbit.comb_out[1];
|
||||
}
|
||||
|
||||
for c in 0..self.output_channels {
|
||||
output[base_idx + c] = (output[base_idx + c] * 0.5).clamp(-1.0, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn process_block(&mut self, output: &mut [f32], web_pcm: &[f32], live_input: &[f32]) {
|
||||
#[cfg(feature = "native")]
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let samples = output.len() / self.output_channels;
|
||||
for i in 0..samples {
|
||||
self.process_schedule();
|
||||
self.tick += 1;
|
||||
self.time = self.tick as f64 / self.sr as f64;
|
||||
self.gen_sample(output, i, web_pcm, live_input);
|
||||
}
|
||||
|
||||
#[cfg(feature = "native")]
|
||||
{
|
||||
use std::sync::atomic::Ordering;
|
||||
let elapsed_ns = start.elapsed().as_nanos() as u64;
|
||||
self.metrics.load.record_sample(elapsed_ns);
|
||||
self.metrics
|
||||
.active_voices
|
||||
.store(self.active_voices as u32, Ordering::Relaxed);
|
||||
self.metrics
|
||||
.peak_voices
|
||||
.fetch_max(self.active_voices as u32, Ordering::Relaxed);
|
||||
self.metrics
|
||||
.schedule_depth
|
||||
.store(self.schedule.len() as u32, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dsp(&mut self) {
|
||||
let mut output = std::mem::take(&mut self.output);
|
||||
self.process_block(&mut output, &[], &[]);
|
||||
self.output = output;
|
||||
}
|
||||
|
||||
pub fn dsp_with_web_pcm(&mut self, web_pcm: &[f32], live_input: &[f32]) {
|
||||
let mut output = std::mem::take(&mut self.output);
|
||||
self.process_block(&mut output, web_pcm, live_input);
|
||||
self.output = output;
|
||||
}
|
||||
|
||||
pub fn get_time(&self) -> f64 {
|
||||
self.time
|
||||
}
|
||||
|
||||
pub fn hush(&mut self) {
|
||||
for i in 0..self.active_voices {
|
||||
self.voices[i].params.gate = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn panic(&mut self) {
|
||||
self.active_voices = 0;
|
||||
}
|
||||
}
|
||||
266
src/loader.rs
Normal file
266
src/loader.rs
Normal file
@@ -0,0 +1,266 @@
|
||||
//! Audio sample loading and directory scanning.
|
||||
//!
|
||||
//! Handles discovery and decoding of audio files into the engine's sample pool.
|
||||
//! Supports common audio formats via Symphonia: WAV, MP3, OGG, FLAC, AAC, M4A.
|
||||
//!
|
||||
//! # Directory Structure
|
||||
//!
|
||||
//! The scanner expects samples organized as:
|
||||
//!
|
||||
//! ```text
|
||||
//! samples/
|
||||
//! ├── kick.wav → named "kick"
|
||||
//! ├── snare.wav → named "snare"
|
||||
//! └── hats/ → folder creates numbered entries
|
||||
//! ├── closed.wav → named "hats/0"
|
||||
//! ├── open.wav → named "hats/1"
|
||||
//! └── pedal.wav → named "hats/2"
|
||||
//! ```
|
||||
//!
|
||||
//! Files within folders are sorted alphabetically and assigned sequential indices.
|
||||
//!
|
||||
//! # Lazy Loading
|
||||
//!
|
||||
//! [`scan_samples_dir`] only builds the index without decoding audio data.
|
||||
//! Actual decoding happens on first use via [`load_sample_file`], keeping
|
||||
//! startup fast even with large sample libraries.
|
||||
|
||||
use std::fs::File;
|
||||
use std::path::Path;
|
||||
|
||||
use symphonia::core::audio::SampleBuffer;
|
||||
use symphonia::core::codecs::DecoderOptions;
|
||||
use symphonia::core::formats::FormatOptions;
|
||||
use symphonia::core::io::MediaSourceStream;
|
||||
use symphonia::core::meta::MetadataOptions;
|
||||
use symphonia::core::probe::Hint;
|
||||
|
||||
use crate::sample::SampleEntry;
|
||||
use crate::Engine;
|
||||
|
||||
/// Default base frequency assigned to loaded samples (C2 = 65.406 Hz).
|
||||
///
|
||||
/// Samples are assumed to be pitched at this frequency unless overridden.
|
||||
/// Used for pitch-shifting calculations during playback.
|
||||
const DEFAULT_BASE_FREQ: f32 = 65.406;
|
||||
|
||||
/// Supported audio file extensions.
|
||||
const AUDIO_EXTENSIONS: &[&str] = &["wav", "mp3", "ogg", "flac", "aac", "m4a"];
|
||||
|
||||
/// Checks if a file path has a supported audio extension.
|
||||
fn is_audio_file(path: &Path) -> bool {
|
||||
path.extension().and_then(|e| e.to_str()).is_some_and(|e| {
|
||||
AUDIO_EXTENSIONS
|
||||
.iter()
|
||||
.any(|ext| e.eq_ignore_ascii_case(ext))
|
||||
})
|
||||
}
|
||||
|
||||
/// Scans a directory for audio samples without loading audio data.
|
||||
///
|
||||
/// Builds an index of [`SampleEntry`] with paths and names. Audio data
|
||||
/// remains unloaded (`loaded: None`) until explicitly requested.
|
||||
///
|
||||
/// Top-level audio files are named by their stem (filename without extension).
|
||||
/// Subdirectories create grouped entries named `folder/index` where index
|
||||
/// is the alphabetical position within that folder.
|
||||
///
|
||||
/// Prints a summary of discovered samples and folders to stdout.
|
||||
pub fn scan_samples_dir(dir: &Path) -> Vec<SampleEntry> {
|
||||
let mut entries = Vec::new();
|
||||
let mut folder_count = 0;
|
||||
|
||||
let items = match std::fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to read directory {}: {e}", dir.display());
|
||||
return entries;
|
||||
}
|
||||
};
|
||||
|
||||
let mut paths: Vec<_> = items.filter_map(|e| e.ok()).map(|e| e.path()).collect();
|
||||
paths.sort();
|
||||
|
||||
for item in paths {
|
||||
if item.is_dir() {
|
||||
let folder_name = item
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown");
|
||||
|
||||
let sub_entries = match std::fs::read_dir(&item) {
|
||||
Ok(e) => e,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let mut files: Vec<_> = sub_entries
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| is_audio_file(p))
|
||||
.collect();
|
||||
|
||||
files.sort();
|
||||
|
||||
if !files.is_empty() {
|
||||
folder_count += 1;
|
||||
}
|
||||
|
||||
for (i, path) in files.into_iter().enumerate() {
|
||||
let name = format!("{folder_name}/{i}");
|
||||
entries.push(SampleEntry {
|
||||
path,
|
||||
name,
|
||||
loaded: None,
|
||||
});
|
||||
}
|
||||
} else if is_audio_file(&item) {
|
||||
let name = item
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
entries.push(SampleEntry {
|
||||
path: item,
|
||||
name,
|
||||
loaded: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if !entries.is_empty() {
|
||||
println!(
|
||||
" Found {} samples in {} folders",
|
||||
entries.len(),
|
||||
folder_count
|
||||
);
|
||||
}
|
||||
|
||||
entries
|
||||
}
|
||||
|
||||
/// Decodes an audio file and loads it into the engine's sample pool.
|
||||
///
|
||||
/// Handles format detection, decoding, and sample rate conversion automatically.
|
||||
/// The decoded audio is resampled to match the engine's sample rate if necessary.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The sample pool index on success, or an error description on failure.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `Err` if:
|
||||
/// - File cannot be opened or read
|
||||
/// - Format is unsupported or corrupted
|
||||
/// - No audio track is found
|
||||
/// - Decoding fails completely (partial decode errors are skipped)
|
||||
/// - Sample pool is full
|
||||
pub fn load_sample_file(engine: &mut Engine, path: &Path) -> Result<usize, String> {
|
||||
let file = File::open(path).map_err(|e| format!("Failed to open file: {e}"))?;
|
||||
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
||||
|
||||
let mut hint = Hint::new();
|
||||
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
||||
hint.with_extension(ext);
|
||||
}
|
||||
|
||||
let probed = symphonia::default::get_probe()
|
||||
.format(
|
||||
&hint,
|
||||
mss,
|
||||
&FormatOptions::default(),
|
||||
&MetadataOptions::default(),
|
||||
)
|
||||
.map_err(|e| format!("Failed to probe format: {e}"))?;
|
||||
|
||||
let mut format = probed.format;
|
||||
let track = format
|
||||
.tracks()
|
||||
.iter()
|
||||
.find(|t| t.codec_params.codec != symphonia::core::codecs::CODEC_TYPE_NULL)
|
||||
.ok_or("No audio track found")?;
|
||||
|
||||
let codec_params = &track.codec_params;
|
||||
let channels = codec_params.channels.map(|c| c.count()).unwrap_or(1) as u8;
|
||||
let sample_rate = codec_params.sample_rate.unwrap_or(44100) as f32;
|
||||
|
||||
let mut decoder = symphonia::default::get_codecs()
|
||||
.make(codec_params, &DecoderOptions::default())
|
||||
.map_err(|e| format!("Failed to create decoder: {e}"))?;
|
||||
|
||||
let track_id = track.id;
|
||||
let mut samples: Vec<f32> = Vec::new();
|
||||
let mut sample_buf: Option<SampleBuffer<f32>> = None;
|
||||
|
||||
loop {
|
||||
let packet = match format.next_packet() {
|
||||
Ok(p) => p,
|
||||
Err(symphonia::core::errors::Error::IoError(e))
|
||||
if e.kind() == std::io::ErrorKind::UnexpectedEof =>
|
||||
{
|
||||
break;
|
||||
}
|
||||
Err(e) => return Err(format!("Failed to read packet: {e}")),
|
||||
};
|
||||
|
||||
if packet.track_id() != track_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
let decoded = match decoder.decode(&packet) {
|
||||
Ok(d) => d,
|
||||
Err(symphonia::core::errors::Error::DecodeError(_)) => continue,
|
||||
Err(e) => return Err(format!("Decode error: {e}")),
|
||||
};
|
||||
|
||||
let spec = *decoded.spec();
|
||||
let duration = decoded.capacity() as u64;
|
||||
|
||||
let buf = sample_buf.get_or_insert_with(|| SampleBuffer::<f32>::new(duration, spec));
|
||||
buf.copy_interleaved_ref(decoded);
|
||||
|
||||
samples.extend_from_slice(buf.samples());
|
||||
}
|
||||
|
||||
if samples.is_empty() {
|
||||
return Err("No samples decoded".to_string());
|
||||
}
|
||||
|
||||
let target_sr = engine.sr;
|
||||
let resampled = if (sample_rate - target_sr).abs() > 1.0 {
|
||||
resample_linear(&samples, channels as usize, sample_rate, target_sr)
|
||||
} else {
|
||||
samples
|
||||
};
|
||||
|
||||
engine
|
||||
.load_sample(&resampled, channels, DEFAULT_BASE_FREQ)
|
||||
.ok_or_else(|| "Sample pool full".to_string())
|
||||
}
|
||||
|
||||
/// Resamples interleaved audio using linear interpolation.
|
||||
///
|
||||
/// Simple but fast resampling suitable for non-critical applications.
|
||||
/// For higher quality, consider using a dedicated resampling library like rubato.
|
||||
fn resample_linear(samples: &[f32], channels: usize, from_sr: f32, to_sr: f32) -> Vec<f32> {
|
||||
let ratio = to_sr / from_sr;
|
||||
let in_frames = samples.len() / channels;
|
||||
let out_frames = (in_frames as f32 * ratio) as usize;
|
||||
let mut output = vec![0.0; out_frames * channels];
|
||||
|
||||
for out_frame in 0..out_frames {
|
||||
let in_pos = out_frame as f32 / ratio;
|
||||
let in_frame = in_pos as usize;
|
||||
let next_frame = (in_frame + 1).min(in_frames - 1);
|
||||
let frac = in_pos - in_frame as f32;
|
||||
|
||||
for ch in 0..channels {
|
||||
let s0 = samples[in_frame * channels + ch];
|
||||
let s1 = samples[next_frame * channels + ch];
|
||||
output[out_frame * channels + ch] = s0 + frac * (s1 - s0);
|
||||
}
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
190
src/main.rs
Normal file
190
src/main.rs
Normal file
@@ -0,0 +1,190 @@
|
||||
//! Doux audio synthesis engine CLI.
|
||||
//!
|
||||
//! Provides real-time audio synthesis with OSC control. Supports sample
|
||||
//! playback, multiple output channels, and live audio input processing.
|
||||
|
||||
use clap::Parser;
|
||||
use cpal::traits::{DeviceTrait, StreamTrait};
|
||||
use doux::audio::{
|
||||
default_input_device, default_output_device, find_input_device, find_output_device,
|
||||
list_input_devices, list_output_devices, max_output_channels,
|
||||
};
|
||||
use doux::Engine;
|
||||
use std::collections::VecDeque;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Command-line arguments for the doux audio engine.
|
||||
#[derive(Parser)]
|
||||
#[command(name = "doux")]
|
||||
#[command(about = "Audio synthesis engine with OSC control", long_about = None)]
|
||||
struct Args {
|
||||
/// Directory containing audio samples to load.
|
||||
#[arg(short, long)]
|
||||
samples: Option<PathBuf>,
|
||||
|
||||
/// OSC port to listen on.
|
||||
#[arg(short, long, default_value = "57120")]
|
||||
port: u16,
|
||||
|
||||
/// List available audio devices and exit.
|
||||
#[arg(long)]
|
||||
list_devices: bool,
|
||||
|
||||
/// Input device (name or index).
|
||||
#[arg(short, long)]
|
||||
input: Option<String>,
|
||||
|
||||
/// Output device (name or index).
|
||||
#[arg(short, long)]
|
||||
output: Option<String>,
|
||||
|
||||
/// Number of output channels (default: 2, max depends on device).
|
||||
#[arg(long, default_value = "2")]
|
||||
channels: u16,
|
||||
|
||||
/// Audio buffer size in samples (lower = less latency, higher = more stable).
|
||||
/// Common values: 64, 128, 256, 512, 1024. Default: system choice.
|
||||
#[arg(short, long)]
|
||||
buffer_size: Option<u32>,
|
||||
}
|
||||
|
||||
fn print_devices() {
|
||||
println!("Input devices:");
|
||||
for info in list_input_devices() {
|
||||
let marker = if info.is_default { " *" } else { "" };
|
||||
println!(" {}: {}{}", info.index, info.name, marker);
|
||||
}
|
||||
|
||||
println!("\nOutput devices:");
|
||||
for info in list_output_devices() {
|
||||
let marker = if info.is_default { " *" } else { "" };
|
||||
println!(" {}: {}{}", info.index, info.name, marker);
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args = Args::parse();
|
||||
|
||||
if args.list_devices {
|
||||
print_devices();
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve output device
|
||||
let device = match &args.output {
|
||||
Some(spec) => find_output_device(spec)
|
||||
.unwrap_or_else(|| panic!("output device '{spec}' not found")),
|
||||
None => default_output_device().expect("no output device"),
|
||||
};
|
||||
|
||||
// Clamp channels to device maximum
|
||||
let max_channels = max_output_channels(&device);
|
||||
let output_channels = (args.channels as usize).min(max_channels as usize);
|
||||
if args.channels as usize > output_channels {
|
||||
eprintln!(
|
||||
"Warning: device supports max {} channels, using that instead of {}",
|
||||
max_channels, args.channels
|
||||
);
|
||||
}
|
||||
|
||||
let default_config = device.default_output_config().unwrap();
|
||||
let sample_rate = default_config.sample_rate().0 as f32;
|
||||
|
||||
let config = cpal::StreamConfig {
|
||||
channels: output_channels as u16,
|
||||
sample_rate: default_config.sample_rate(),
|
||||
buffer_size: args
|
||||
.buffer_size
|
||||
.map(cpal::BufferSize::Fixed)
|
||||
.unwrap_or(cpal::BufferSize::Default),
|
||||
};
|
||||
|
||||
println!("Output: {}", device.name().unwrap_or_default());
|
||||
println!("Sample rate: {sample_rate}");
|
||||
println!("Channels: {output_channels}");
|
||||
if let Some(buf) = args.buffer_size {
|
||||
let latency_ms = buf as f32 / sample_rate * 1000.0;
|
||||
println!("Buffer: {buf} samples ({latency_ms:.1} ms)");
|
||||
}
|
||||
|
||||
// Initialize engine with sample index if provided
|
||||
let mut engine = Engine::new_with_channels(sample_rate, output_channels);
|
||||
|
||||
if let Some(ref dir) = args.samples {
|
||||
println!("\nScanning samples from: {}", dir.display());
|
||||
let index = doux::loader::scan_samples_dir(dir);
|
||||
println!("Found {} samples (lazy loading enabled)\n", index.len());
|
||||
engine.sample_index = index;
|
||||
}
|
||||
|
||||
let engine = Arc::new(Mutex::new(engine));
|
||||
|
||||
// Ring buffer for live audio input
|
||||
let input_buffer: Arc<Mutex<VecDeque<f32>>> =
|
||||
Arc::new(Mutex::new(VecDeque::with_capacity(8192)));
|
||||
|
||||
// Set up input stream if device available
|
||||
let input_device = match &args.input {
|
||||
Some(spec) => find_input_device(spec),
|
||||
None => default_input_device(),
|
||||
};
|
||||
let _input_stream = input_device.and_then(|input_device| {
|
||||
let input_config = input_device.default_input_config().ok()?;
|
||||
println!("Input: {}", input_device.name().unwrap_or_default());
|
||||
let buf = Arc::clone(&input_buffer);
|
||||
let stream = input_device
|
||||
.build_input_stream(
|
||||
&input_config.into(),
|
||||
move |data: &[f32], _| {
|
||||
let mut b = buf.lock().unwrap();
|
||||
for &sample in data {
|
||||
b.push_back(sample);
|
||||
if b.len() > 8192 {
|
||||
b.pop_front();
|
||||
}
|
||||
}
|
||||
},
|
||||
|err| eprintln!("input stream error: {err}"),
|
||||
None,
|
||||
)
|
||||
.ok()?;
|
||||
stream.play().ok()?;
|
||||
Some(stream)
|
||||
});
|
||||
|
||||
// Build output stream with audio callback
|
||||
let engine_clone = Arc::clone(&engine);
|
||||
let input_buf_clone = Arc::clone(&input_buffer);
|
||||
let live_scratch: Arc<Mutex<Vec<f32>>> = Arc::new(Mutex::new(vec![0.0; 1024]));
|
||||
let live_scratch_clone = Arc::clone(&live_scratch);
|
||||
let stream = device
|
||||
.build_output_stream(
|
||||
&config,
|
||||
move |data: &mut [f32], _| {
|
||||
let mut buf = input_buf_clone.lock().unwrap();
|
||||
let mut scratch = live_scratch_clone.lock().unwrap();
|
||||
if scratch.len() < data.len() {
|
||||
scratch.resize(data.len(), 0.0);
|
||||
}
|
||||
for sample in scratch[..data.len()].iter_mut() {
|
||||
*sample = buf.pop_front().unwrap_or(0.0);
|
||||
}
|
||||
drop(buf);
|
||||
engine_clone
|
||||
.lock()
|
||||
.unwrap()
|
||||
.process_block(data, &[], &scratch[..data.len()]);
|
||||
},
|
||||
|err| eprintln!("stream error: {err}"),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
stream.play().unwrap();
|
||||
println!("Listening for OSC on port {}", args.port);
|
||||
println!("Press Ctrl+C to stop");
|
||||
|
||||
// Block on OSC server (runs until interrupted)
|
||||
doux::osc::run(engine, args.port);
|
||||
}
|
||||
87
src/noise.rs
Normal file
87
src/noise.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
//! Colored noise generators.
|
||||
//!
|
||||
//! Transforms white noise into spectrally-shaped noise with different frequency
|
||||
//! characteristics. Both generators are stateful filters that process white noise
|
||||
//! sample-by-sample.
|
||||
//!
|
||||
//! # Noise Colors
|
||||
//!
|
||||
//! | Color | Slope | Character |
|
||||
//! |-------|------------|----------------------------------|
|
||||
//! | White | 0 dB/oct | Equal energy per frequency |
|
||||
//! | Pink | -3 dB/oct | Equal energy per octave |
|
||||
//! | Brown | -6 dB/oct | Rumbling, emphasizes low freqs |
|
||||
|
||||
/// Pink noise generator using the Voss-McCartney algorithm.
|
||||
///
|
||||
/// Applies a parallel bank of first-order lowpass filters to shape white noise
|
||||
/// into pink noise with -3 dB/octave rolloff. The coefficients approximate an
|
||||
/// ideal pink spectrum across the audio range.
|
||||
///
|
||||
/// Also known as 1/f noise, pink noise has equal energy per octave, making it
|
||||
/// useful for audio testing and as a natural-sounding noise source.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct PinkNoise {
|
||||
b: [f32; 7],
|
||||
}
|
||||
|
||||
impl Default for PinkNoise {
|
||||
fn default() -> Self {
|
||||
Self { b: [0.0; 7] }
|
||||
}
|
||||
}
|
||||
|
||||
impl PinkNoise {
|
||||
/// Processes one white noise sample and returns the corresponding pink noise sample.
|
||||
///
|
||||
/// The input should be uniformly distributed white noise in the range `[-1, 1]`.
|
||||
/// Output is scaled to approximately the same amplitude range.
|
||||
pub fn next(&mut self, white: f32) -> f32 {
|
||||
self.b[0] = 0.99886 * self.b[0] + white * 0.0555179;
|
||||
self.b[1] = 0.99332 * self.b[1] + white * 0.0750759;
|
||||
self.b[2] = 0.96900 * self.b[2] + white * 0.153852;
|
||||
self.b[3] = 0.86650 * self.b[3] + white * 0.3104856;
|
||||
self.b[4] = 0.55000 * self.b[4] + white * 0.5329522;
|
||||
self.b[5] = -0.7616 * self.b[5] - white * 0.0168980;
|
||||
let pink = self.b[0]
|
||||
+ self.b[1]
|
||||
+ self.b[2]
|
||||
+ self.b[3]
|
||||
+ self.b[4]
|
||||
+ self.b[5]
|
||||
+ self.b[6]
|
||||
+ white * 0.5362;
|
||||
self.b[6] = white * 0.115926;
|
||||
pink * 0.11
|
||||
}
|
||||
}
|
||||
|
||||
/// Brown noise generator using leaky integration.
|
||||
///
|
||||
/// Applies a simple first-order lowpass filter (leaky integrator) to produce
|
||||
/// noise with -6 dB/octave rolloff. Named after Robert Brown (Brownian motion),
|
||||
/// not the color.
|
||||
///
|
||||
/// Also known as red noise or random walk noise. Has a deep, rumbling character
|
||||
/// with strong low-frequency content.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct BrownNoise {
|
||||
out: f32,
|
||||
}
|
||||
|
||||
impl Default for BrownNoise {
|
||||
fn default() -> Self {
|
||||
Self { out: 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl BrownNoise {
|
||||
/// Processes one white noise sample and returns the corresponding brown noise sample.
|
||||
///
|
||||
/// The input should be uniformly distributed white noise in the range `[-1, 1]`.
|
||||
/// Output amplitude depends on the integration coefficient.
|
||||
pub fn next(&mut self, white: f32) -> f32 {
|
||||
self.out = (self.out + 0.02 * white) / 1.02;
|
||||
self.out
|
||||
}
|
||||
}
|
||||
504
src/orbit.rs
Normal file
504
src/orbit.rs
Normal file
@@ -0,0 +1,504 @@
|
||||
use crate::effects::Comb;
|
||||
use crate::fastmath::ftz;
|
||||
use crate::types::{DelayType, CHANNELS};
|
||||
|
||||
const MAX_DELAY_SAMPLES: usize = 48000;
|
||||
const SILENCE_THRESHOLD: f32 = 1e-7;
|
||||
const SILENCE_HOLDOFF: u32 = 48000;
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct EffectParams {
|
||||
pub delay_time: f32,
|
||||
pub delay_feedback: f32,
|
||||
pub delay_type: DelayType,
|
||||
pub verb_decay: f32,
|
||||
pub verb_damp: f32,
|
||||
pub verb_predelay: f32,
|
||||
pub verb_diff: f32,
|
||||
pub comb_freq: f32,
|
||||
pub comb_feedback: f32,
|
||||
pub comb_damp: f32,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DelayLine {
|
||||
buffer: Vec<f32>,
|
||||
write_pos: usize,
|
||||
}
|
||||
|
||||
impl DelayLine {
|
||||
pub fn new(max_samples: usize) -> Self {
|
||||
Self {
|
||||
buffer: vec![0.0; max_samples],
|
||||
write_pos: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn process(&mut self, input: f32, delay_samples: usize) -> f32 {
|
||||
let delay_samples = delay_samples.min(self.buffer.len() - 1);
|
||||
self.buffer[self.write_pos] = input;
|
||||
|
||||
let read_pos = if self.write_pos >= delay_samples {
|
||||
self.write_pos - delay_samples
|
||||
} else {
|
||||
self.buffer.len() - (delay_samples - self.write_pos)
|
||||
};
|
||||
|
||||
self.write_pos = (self.write_pos + 1) % self.buffer.len();
|
||||
self.buffer[read_pos]
|
||||
}
|
||||
|
||||
pub fn read_at(&self, delay_samples: usize) -> f32 {
|
||||
let delay_samples = delay_samples.min(self.buffer.len() - 1);
|
||||
let read_pos = if self.write_pos >= delay_samples {
|
||||
self.write_pos - delay_samples
|
||||
} else {
|
||||
self.buffer.len() - (delay_samples - self.write_pos)
|
||||
};
|
||||
self.buffer[read_pos]
|
||||
}
|
||||
|
||||
pub fn write(&mut self, input: f32) {
|
||||
self.buffer[self.write_pos] = input;
|
||||
self.write_pos = (self.write_pos + 1) % self.buffer.len();
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.buffer.fill(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DelayLine {
|
||||
fn default() -> Self {
|
||||
Self::new(MAX_DELAY_SAMPLES)
|
||||
}
|
||||
}
|
||||
|
||||
const REVERB_SR_REF: f32 = 29761.0;
|
||||
|
||||
fn scale_delay(samples: usize, sr: f32) -> usize {
|
||||
((samples as f32 * sr / REVERB_SR_REF) as usize).max(1)
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ReverbBuffer {
|
||||
buffer: Vec<f32>,
|
||||
write_pos: usize,
|
||||
}
|
||||
|
||||
impl ReverbBuffer {
|
||||
pub fn new(size: usize) -> Self {
|
||||
Self {
|
||||
buffer: vec![0.0; size],
|
||||
write_pos: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write(&mut self, value: f32) {
|
||||
self.buffer[self.write_pos] = value;
|
||||
self.write_pos = (self.write_pos + 1) % self.buffer.len();
|
||||
}
|
||||
|
||||
pub fn read(&self, delay: usize) -> f32 {
|
||||
let delay = delay.min(self.buffer.len() - 1);
|
||||
let pos = if self.write_pos >= delay {
|
||||
self.write_pos - delay
|
||||
} else {
|
||||
self.buffer.len() - (delay - self.write_pos)
|
||||
};
|
||||
self.buffer[pos]
|
||||
}
|
||||
|
||||
pub fn read_write(&mut self, value: f32, delay: usize) -> f32 {
|
||||
let out = self.read(delay);
|
||||
self.write(value);
|
||||
out
|
||||
}
|
||||
|
||||
pub fn allpass(&mut self, input: f32, delay: usize, coeff: f32) -> f32 {
|
||||
let delayed = self.read(delay);
|
||||
let v = input - coeff * delayed;
|
||||
self.write(v);
|
||||
delayed + coeff * v
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.buffer.fill(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DattorroVerb {
|
||||
pre_delay: ReverbBuffer,
|
||||
in_diff1: ReverbBuffer,
|
||||
in_diff2: ReverbBuffer,
|
||||
in_diff3: ReverbBuffer,
|
||||
in_diff4: ReverbBuffer,
|
||||
decay_diff1_l: ReverbBuffer,
|
||||
delay1_l: ReverbBuffer,
|
||||
decay_diff2_l: ReverbBuffer,
|
||||
delay2_l: ReverbBuffer,
|
||||
decay_diff1_r: ReverbBuffer,
|
||||
delay1_r: ReverbBuffer,
|
||||
decay_diff2_r: ReverbBuffer,
|
||||
delay2_r: ReverbBuffer,
|
||||
damp_l: f32,
|
||||
damp_r: f32,
|
||||
pre_delay_len: usize,
|
||||
in_diff1_len: usize,
|
||||
in_diff2_len: usize,
|
||||
in_diff3_len: usize,
|
||||
in_diff4_len: usize,
|
||||
decay_diff1_l_len: usize,
|
||||
delay1_l_len: usize,
|
||||
decay_diff2_l_len: usize,
|
||||
delay2_l_len: usize,
|
||||
decay_diff1_r_len: usize,
|
||||
delay1_r_len: usize,
|
||||
decay_diff2_r_len: usize,
|
||||
delay2_r_len: usize,
|
||||
tap1_l: usize,
|
||||
tap2_l: usize,
|
||||
tap3_l: usize,
|
||||
tap4_l: usize,
|
||||
tap5_l: usize,
|
||||
tap6_l: usize,
|
||||
tap7_l: usize,
|
||||
tap1_r: usize,
|
||||
tap2_r: usize,
|
||||
tap3_r: usize,
|
||||
tap4_r: usize,
|
||||
tap5_r: usize,
|
||||
tap6_r: usize,
|
||||
tap7_r: usize,
|
||||
}
|
||||
|
||||
impl DattorroVerb {
|
||||
pub fn new(sr: f32) -> Self {
|
||||
let pre_delay_len = scale_delay(4800, sr);
|
||||
let in_diff1_len = scale_delay(142, sr);
|
||||
let in_diff2_len = scale_delay(107, sr);
|
||||
let in_diff3_len = scale_delay(379, sr);
|
||||
let in_diff4_len = scale_delay(277, sr);
|
||||
let decay_diff1_l_len = scale_delay(672, sr);
|
||||
let delay1_l_len = scale_delay(4453, sr);
|
||||
let decay_diff2_l_len = scale_delay(1800, sr);
|
||||
let delay2_l_len = scale_delay(3720, sr);
|
||||
let decay_diff1_r_len = scale_delay(908, sr);
|
||||
let delay1_r_len = scale_delay(4217, sr);
|
||||
let decay_diff2_r_len = scale_delay(2656, sr);
|
||||
let delay2_r_len = scale_delay(3163, sr);
|
||||
|
||||
Self {
|
||||
pre_delay: ReverbBuffer::new(pre_delay_len + 1),
|
||||
in_diff1: ReverbBuffer::new(in_diff1_len + 1),
|
||||
in_diff2: ReverbBuffer::new(in_diff2_len + 1),
|
||||
in_diff3: ReverbBuffer::new(in_diff3_len + 1),
|
||||
in_diff4: ReverbBuffer::new(in_diff4_len + 1),
|
||||
decay_diff1_l: ReverbBuffer::new(decay_diff1_l_len + 1),
|
||||
delay1_l: ReverbBuffer::new(delay1_l_len + 1),
|
||||
decay_diff2_l: ReverbBuffer::new(decay_diff2_l_len + 1),
|
||||
delay2_l: ReverbBuffer::new(delay2_l_len + 1),
|
||||
decay_diff1_r: ReverbBuffer::new(decay_diff1_r_len + 1),
|
||||
delay1_r: ReverbBuffer::new(delay1_r_len + 1),
|
||||
decay_diff2_r: ReverbBuffer::new(decay_diff2_r_len + 1),
|
||||
delay2_r: ReverbBuffer::new(delay2_r_len + 1),
|
||||
damp_l: 0.0,
|
||||
damp_r: 0.0,
|
||||
pre_delay_len,
|
||||
in_diff1_len,
|
||||
in_diff2_len,
|
||||
in_diff3_len,
|
||||
in_diff4_len,
|
||||
decay_diff1_l_len,
|
||||
delay1_l_len,
|
||||
decay_diff2_l_len,
|
||||
delay2_l_len,
|
||||
decay_diff1_r_len,
|
||||
delay1_r_len,
|
||||
decay_diff2_r_len,
|
||||
delay2_r_len,
|
||||
tap1_l: scale_delay(266, sr),
|
||||
tap2_l: scale_delay(2974, sr),
|
||||
tap3_l: scale_delay(1913, sr),
|
||||
tap4_l: scale_delay(1996, sr),
|
||||
tap5_l: scale_delay(1990, sr),
|
||||
tap6_l: scale_delay(187, sr),
|
||||
tap7_l: scale_delay(1066, sr),
|
||||
tap1_r: scale_delay(353, sr),
|
||||
tap2_r: scale_delay(3627, sr),
|
||||
tap3_r: scale_delay(1228, sr),
|
||||
tap4_r: scale_delay(2673, sr),
|
||||
tap5_r: scale_delay(2111, sr),
|
||||
tap6_r: scale_delay(335, sr),
|
||||
tap7_r: scale_delay(121, sr),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn process(
|
||||
&mut self,
|
||||
input: f32,
|
||||
decay: f32,
|
||||
damping: f32,
|
||||
predelay: f32,
|
||||
diffusion: f32,
|
||||
) -> [f32; 2] {
|
||||
let decay = decay.clamp(0.0, 0.99);
|
||||
let damping = damping.clamp(0.0, 1.0);
|
||||
let diffusion = diffusion.clamp(0.0, 1.0);
|
||||
let diff1 = 0.75 * diffusion;
|
||||
let diff2 = 0.625 * diffusion;
|
||||
let decay_diff1 = 0.7 * diffusion;
|
||||
let decay_diff2 = 0.5 * diffusion;
|
||||
|
||||
let pre_delay_samples =
|
||||
((predelay * self.pre_delay_len as f32) as usize).min(self.pre_delay_len);
|
||||
let input = ftz(input, 0.0001);
|
||||
let pre = self.pre_delay.read_write(input, pre_delay_samples);
|
||||
|
||||
let mut x = pre;
|
||||
x = self.in_diff1.allpass(x, self.in_diff1_len, diff1);
|
||||
x = self.in_diff2.allpass(x, self.in_diff2_len, diff1);
|
||||
x = self.in_diff3.allpass(x, self.in_diff3_len, diff2);
|
||||
x = self.in_diff4.allpass(x, self.in_diff4_len, diff2);
|
||||
|
||||
let tank_l_in = x + self.delay2_r.read(self.delay2_r_len) * decay;
|
||||
let tank_r_in = x + self.delay2_l.read(self.delay2_l_len) * decay;
|
||||
|
||||
let mut l = self
|
||||
.decay_diff1_l
|
||||
.allpass(tank_l_in, self.decay_diff1_l_len, -decay_diff1);
|
||||
l = self.delay1_l.read_write(l, self.delay1_l_len);
|
||||
self.damp_l = ftz(l * (1.0 - damping) + self.damp_l * damping, 0.0001);
|
||||
l = self.damp_l * decay;
|
||||
l = self
|
||||
.decay_diff2_l
|
||||
.allpass(l, self.decay_diff2_l_len, decay_diff2);
|
||||
self.delay2_l.write(l);
|
||||
|
||||
let mut r = self
|
||||
.decay_diff1_r
|
||||
.allpass(tank_r_in, self.decay_diff1_r_len, -decay_diff1);
|
||||
r = self.delay1_r.read_write(r, self.delay1_r_len);
|
||||
self.damp_r = ftz(r * (1.0 - damping) + self.damp_r * damping, 0.0001);
|
||||
r = self.damp_r * decay;
|
||||
r = self
|
||||
.decay_diff2_r
|
||||
.allpass(r, self.decay_diff2_r_len, decay_diff2);
|
||||
self.delay2_r.write(r);
|
||||
|
||||
let out_l = self.delay1_l.read(self.tap1_l) + self.delay1_l.read(self.tap2_l)
|
||||
- self.decay_diff2_l.read(self.tap3_l)
|
||||
+ self.delay2_l.read(self.tap4_l)
|
||||
- self.delay1_r.read(self.tap5_r)
|
||||
- self.decay_diff2_r.read(self.tap6_r)
|
||||
- self.delay2_r.read(self.tap7_r);
|
||||
|
||||
let out_r = self.delay1_r.read(self.tap1_r) + self.delay1_r.read(self.tap2_r)
|
||||
- self.decay_diff2_r.read(self.tap3_r)
|
||||
+ self.delay2_r.read(self.tap4_r)
|
||||
- self.delay1_l.read(self.tap5_l)
|
||||
- self.decay_diff2_l.read(self.tap6_l)
|
||||
- self.delay2_l.read(self.tap7_l);
|
||||
|
||||
[out_l * 0.6, out_r * 0.6]
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.pre_delay.clear();
|
||||
self.in_diff1.clear();
|
||||
self.in_diff2.clear();
|
||||
self.in_diff3.clear();
|
||||
self.in_diff4.clear();
|
||||
self.decay_diff1_l.clear();
|
||||
self.delay1_l.clear();
|
||||
self.decay_diff2_l.clear();
|
||||
self.delay2_l.clear();
|
||||
self.decay_diff1_r.clear();
|
||||
self.delay1_r.clear();
|
||||
self.decay_diff2_r.clear();
|
||||
self.delay2_r.clear();
|
||||
self.damp_l = 0.0;
|
||||
self.damp_r = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Orbit {
|
||||
pub delay: [DelayLine; CHANNELS],
|
||||
pub delay_send: [f32; CHANNELS],
|
||||
pub delay_out: [f32; CHANNELS],
|
||||
pub delay_feedback: [f32; CHANNELS],
|
||||
pub delay_lp: [f32; CHANNELS],
|
||||
pub verb: DattorroVerb,
|
||||
pub verb_send: [f32; CHANNELS],
|
||||
pub verb_out: [f32; CHANNELS],
|
||||
pub comb: Comb,
|
||||
pub comb_send: [f32; CHANNELS],
|
||||
pub comb_out: [f32; CHANNELS],
|
||||
pub sr: f32,
|
||||
silent_samples: u32,
|
||||
}
|
||||
|
||||
impl Orbit {
|
||||
pub fn new(sr: f32) -> Self {
|
||||
Self {
|
||||
delay: [DelayLine::default(), DelayLine::default()],
|
||||
delay_send: [0.0; CHANNELS],
|
||||
delay_out: [0.0; CHANNELS],
|
||||
delay_feedback: [0.0; CHANNELS],
|
||||
delay_lp: [0.0; CHANNELS],
|
||||
verb: DattorroVerb::new(sr),
|
||||
verb_send: [0.0; CHANNELS],
|
||||
verb_out: [0.0; CHANNELS],
|
||||
comb: Comb::default(),
|
||||
comb_send: [0.0; CHANNELS],
|
||||
comb_out: [0.0; CHANNELS],
|
||||
sr,
|
||||
silent_samples: SILENCE_HOLDOFF + 1,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear_sends(&mut self) {
|
||||
self.delay_send = [0.0; CHANNELS];
|
||||
self.verb_send = [0.0; CHANNELS];
|
||||
self.comb_send = [0.0; CHANNELS];
|
||||
}
|
||||
|
||||
pub fn add_delay_send(&mut self, ch: usize, value: f32) {
|
||||
self.delay_send[ch] += value;
|
||||
}
|
||||
|
||||
pub fn add_verb_send(&mut self, ch: usize, value: f32) {
|
||||
self.verb_send[ch] += value;
|
||||
}
|
||||
|
||||
pub fn add_comb_send(&mut self, ch: usize, value: f32) {
|
||||
self.comb_send[ch] += value;
|
||||
}
|
||||
|
||||
pub fn process(&mut self, p: &EffectParams) {
|
||||
let has_input = self.delay_send[0] != 0.0
|
||||
|| self.delay_send[1] != 0.0
|
||||
|| self.verb_send[0] != 0.0
|
||||
|| self.verb_send[1] != 0.0
|
||||
|| self.comb_send[0] != 0.0
|
||||
|| self.comb_send[1] != 0.0;
|
||||
|
||||
if has_input {
|
||||
self.silent_samples = 0;
|
||||
} else if self.silent_samples > SILENCE_HOLDOFF {
|
||||
self.delay_out = [0.0; CHANNELS];
|
||||
self.verb_out = [0.0; CHANNELS];
|
||||
self.comb_out = [0.0; CHANNELS];
|
||||
return;
|
||||
}
|
||||
|
||||
let delay_samples = ((p.delay_time * self.sr) as usize).min(MAX_DELAY_SAMPLES - 1);
|
||||
let feedback = p.delay_feedback.clamp(0.0, 0.95);
|
||||
|
||||
match p.delay_type {
|
||||
DelayType::Standard => {
|
||||
for c in 0..CHANNELS {
|
||||
let fb = ftz(self.delay_feedback[c], 0.0001);
|
||||
let input = self.delay_send[c] + fb * feedback;
|
||||
self.delay_out[c] = self.delay[c].process(input, delay_samples);
|
||||
self.delay_feedback[c] = self.delay_out[c];
|
||||
}
|
||||
}
|
||||
DelayType::PingPong => {
|
||||
// True ping-pong: mono input → L only, then bounces L↔R
|
||||
let mono_in = (self.delay_send[0] + self.delay_send[1]) * 0.5;
|
||||
let fb_l = ftz(self.delay_feedback[0], 0.0001);
|
||||
let fb_r = ftz(self.delay_feedback[1], 0.0001);
|
||||
|
||||
// L gets new input + feedback from R
|
||||
let input_l = mono_in + fb_r * feedback;
|
||||
// R gets ONLY feedback from L (no direct input - this creates the bounce)
|
||||
let input_r = fb_l * feedback;
|
||||
|
||||
self.delay_out[0] = self.delay[0].process(input_l, delay_samples);
|
||||
self.delay_out[1] = self.delay[1].process(input_r, delay_samples);
|
||||
|
||||
self.delay_feedback[0] = self.delay_out[0];
|
||||
self.delay_feedback[1] = self.delay_out[1];
|
||||
}
|
||||
DelayType::Tape => {
|
||||
// Tape delay: one-pole lowpass in feedback (darkening each repeat)
|
||||
const DAMP: f32 = 0.35;
|
||||
for c in 0..CHANNELS {
|
||||
// Apply lowpass to feedback before using it
|
||||
let fb_raw = ftz(self.delay_feedback[c], 0.0001);
|
||||
let fb = self.delay_lp[c] + DAMP * (fb_raw - self.delay_lp[c]);
|
||||
self.delay_lp[c] = fb;
|
||||
|
||||
let input = self.delay_send[c] + fb * feedback;
|
||||
self.delay_out[c] = self.delay[c].process(input, delay_samples);
|
||||
self.delay_feedback[c] = self.delay_out[c];
|
||||
}
|
||||
}
|
||||
DelayType::Multitap => {
|
||||
// Multitap: 4 taps with straight-to-triplet morph
|
||||
// feedback 0 = straight (1, 1/2, 1/4, 1/8)
|
||||
// feedback 1 = triplet (1, 2/3, 1/3, 1/6)
|
||||
// in between = swing
|
||||
let swing = feedback;
|
||||
let t = delay_samples as f32;
|
||||
|
||||
let tap1 = delay_samples;
|
||||
let tap2 = (t * (0.5 + swing * 0.167)).max(1.0) as usize; // 1/2 → 2/3
|
||||
let tap3 = (t * (0.25 + swing * 0.083)).max(1.0) as usize; // 1/4 → 1/3
|
||||
let tap4 = (t * (0.125 + swing * 0.042)).max(1.0) as usize; // 1/8 → 1/6
|
||||
|
||||
for c in 0..CHANNELS {
|
||||
let fb = ftz(self.delay_feedback[c], 0.0001);
|
||||
let input = self.delay_send[c] + fb * 0.5;
|
||||
self.delay[c].write(input);
|
||||
|
||||
let out1 = self.delay[c].read_at(tap1);
|
||||
let out2 = self.delay[c].read_at(tap2) * 0.7;
|
||||
let out3 = self.delay[c].read_at(tap3) * 0.5;
|
||||
let out4 = self.delay[c].read_at(tap4) * 0.35;
|
||||
|
||||
self.delay_out[c] = out1 + out2 + out3 + out4;
|
||||
self.delay_feedback[c] = out1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let verb_input = (self.verb_send[0] + self.verb_send[1]) * 0.5;
|
||||
let verb_stereo = self.verb.process(
|
||||
verb_input,
|
||||
p.verb_decay,
|
||||
p.verb_damp,
|
||||
p.verb_predelay,
|
||||
p.verb_diff,
|
||||
);
|
||||
self.verb_out[0] = verb_stereo[0];
|
||||
self.verb_out[1] = verb_stereo[1];
|
||||
|
||||
// Comb filter (mono in, mono out to both channels)
|
||||
let comb_input = (self.comb_send[0] + self.comb_send[1]) * 0.5;
|
||||
let comb_out = self.comb.process(
|
||||
comb_input,
|
||||
p.comb_freq,
|
||||
p.comb_feedback,
|
||||
p.comb_damp,
|
||||
self.sr,
|
||||
);
|
||||
self.comb_out[0] = comb_out;
|
||||
self.comb_out[1] = comb_out;
|
||||
|
||||
let energy = self.delay_out[0].abs()
|
||||
+ self.delay_out[1].abs()
|
||||
+ self.verb_out[0].abs()
|
||||
+ self.verb_out[1].abs()
|
||||
+ self.comb_out[0].abs()
|
||||
+ self.comb_out[1].abs();
|
||||
|
||||
if energy < SILENCE_THRESHOLD {
|
||||
self.silent_samples = self.silent_samples.saturating_add(1);
|
||||
} else {
|
||||
self.silent_samples = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
121
src/osc.rs
Normal file
121
src/osc.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
//! OSC (Open Sound Control) message receiver.
|
||||
//!
|
||||
//! Listens for UDP packets containing OSC messages and translates them into
|
||||
//! engine commands. Runs in a dedicated thread, forwarding parsed messages
|
||||
//! to the audio engine for evaluation.
|
||||
//!
|
||||
//! # Message Format
|
||||
//!
|
||||
//! OSC arguments are interpreted as key-value pairs and converted to a path
|
||||
//! string for the engine. Arguments are processed in pairs: odd positions are
|
||||
//! keys (must be strings), even positions are values.
|
||||
//!
|
||||
//! ```text
|
||||
//! OSC: /play ["sound", "kick", "note", 60, "amp", 0.8]
|
||||
//! → Engine path: "sound/kick/note/60/amp/0.8"
|
||||
//! ```
|
||||
//!
|
||||
//! # Protocol
|
||||
//!
|
||||
//! - Transport: UDP
|
||||
//! - Default bind: `0.0.0.0:<port>` (all interfaces)
|
||||
//! - Supports both single messages and bundles (bundles are flattened)
|
||||
|
||||
use rosc::{OscMessage, OscPacket, OscType};
|
||||
use std::net::UdpSocket;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::Engine;
|
||||
|
||||
/// Maximum UDP packet size for incoming OSC messages.
|
||||
const BUFFER_SIZE: usize = 4096;
|
||||
|
||||
/// Starts the OSC receiver loop on the specified port.
|
||||
///
|
||||
/// Binds to all interfaces (`0.0.0.0`) and blocks indefinitely, processing
|
||||
/// incoming messages. Intended to be spawned in a dedicated thread.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the UDP socket cannot be bound (e.g., port already in use).
|
||||
pub fn run(engine: Arc<Mutex<Engine>>, port: u16) {
|
||||
let addr = format!("0.0.0.0:{port}");
|
||||
let socket = UdpSocket::bind(&addr).expect("failed to bind OSC socket");
|
||||
|
||||
let mut buf = [0u8; BUFFER_SIZE];
|
||||
|
||||
loop {
|
||||
match socket.recv_from(&mut buf) {
|
||||
Ok((size, _addr)) => {
|
||||
if let Ok(packet) = rosc::decoder::decode_udp(&buf[..size]) {
|
||||
handle_packet(&engine, &packet.1);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("OSC recv error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively processes an OSC packet, handling both messages and bundles.
|
||||
fn handle_packet(engine: &Arc<Mutex<Engine>>, packet: &OscPacket) {
|
||||
match packet {
|
||||
OscPacket::Message(msg) => handle_message(engine, msg),
|
||||
OscPacket::Bundle(bundle) => {
|
||||
for p in &bundle.content {
|
||||
handle_packet(engine, p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts an OSC message to a path string and evaluates it on the engine.
|
||||
fn handle_message(engine: &Arc<Mutex<Engine>>, msg: &OscMessage) {
|
||||
let path = osc_to_path(msg);
|
||||
if !path.is_empty() {
|
||||
if let Ok(mut e) = engine.lock() {
|
||||
e.evaluate(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts OSC message arguments to a slash-separated path string.
|
||||
///
|
||||
/// Arguments are processed as key-value pairs. Keys must be strings;
|
||||
/// non-string keys cause the pair to be skipped. Values are converted
|
||||
/// to their string representation.
|
||||
fn osc_to_path(msg: &OscMessage) -> String {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
let args = &msg.args;
|
||||
let mut i = 0;
|
||||
|
||||
while i + 1 < args.len() {
|
||||
let key = match &args[i] {
|
||||
OscType::String(s) => s.clone(),
|
||||
_ => {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let val = arg_to_string(&args[i + 1]);
|
||||
parts.push(key);
|
||||
parts.push(val);
|
||||
i += 2;
|
||||
}
|
||||
|
||||
parts.join("/")
|
||||
}
|
||||
|
||||
/// Converts an OSC type to its string representation.
|
||||
fn arg_to_string(arg: &OscType) -> String {
|
||||
match arg {
|
||||
OscType::Int(v) => v.to_string(),
|
||||
OscType::Float(v) => v.to_string(),
|
||||
OscType::Double(v) => v.to_string(),
|
||||
OscType::Long(v) => v.to_string(),
|
||||
OscType::String(s) => s.clone(),
|
||||
OscType::Bool(b) => if *b { "1" } else { "0" }.to_string(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
421
src/oscillator.rs
Normal file
421
src/oscillator.rs
Normal file
@@ -0,0 +1,421 @@
|
||||
//! Band-limited oscillators with phase shaping.
|
||||
//!
|
||||
//! Provides a phasor-based oscillator system with multiple waveforms and
|
||||
//! optional phase distortion. Anti-aliasing is achieved via PolyBLEP
|
||||
//! (Polynomial Band-Limited Step) for discontinuous waveforms.
|
||||
//!
|
||||
//! # Waveforms
|
||||
//!
|
||||
//! | Name | Alias | Description |
|
||||
//! |--------|-------|------------------------------------------|
|
||||
//! | `sine` | - | Pure sinusoid |
|
||||
//! | `tri` | - | Triangle wave |
|
||||
//! | `saw` | - | Sawtooth with PolyBLEP anti-aliasing |
|
||||
//! | `zaw` | - | Raw sawtooth (no anti-aliasing) |
|
||||
//! | `pulse`| - | Variable-width pulse with PolyBLEP |
|
||||
//! | `pulze`| - | Raw pulse (no anti-aliasing) |
|
||||
//!
|
||||
//! # Phase Shaping
|
||||
//!
|
||||
//! [`PhaseShape`] transforms the oscillator phase before waveform generation,
|
||||
//! enabling complex timbres from simple waveforms:
|
||||
//!
|
||||
//! - **mult**: Phase multiplication creates harmonic partials
|
||||
//! - **warp**: Power curve distortion shifts harmonic balance
|
||||
//! - **mirror**: Reflection creates symmetrical waveforms
|
||||
//! - **size**: Step quantization for lo-fi/bitcrushed effects
|
||||
|
||||
use crate::fastmath::{exp2f, powf, sinf};
|
||||
use crate::types::LfoShape;
|
||||
use std::f32::consts::PI;
|
||||
|
||||
/// PolyBLEP correction for band-limited discontinuities.
|
||||
///
|
||||
/// Applies a polynomial correction near waveform discontinuities to reduce
|
||||
/// aliasing. The correction is applied within one sample of the transition.
|
||||
///
|
||||
/// - `t`: Current phase position in `[0, 1)`
|
||||
/// - `dt`: Phase increment per sample (frequency × inverse sample rate)
|
||||
fn poly_blep(t: f32, dt: f32) -> f32 {
|
||||
if t < dt {
|
||||
let t = t / dt;
|
||||
return t + t - t * t - 1.0;
|
||||
}
|
||||
if t > 1.0 - dt {
|
||||
let t = (t - 1.0) / dt;
|
||||
return t * t + t + t + 1.0;
|
||||
}
|
||||
0.0
|
||||
}
|
||||
|
||||
/// Phase transformation parameters for waveform shaping.
|
||||
///
|
||||
/// Applies a chain of transformations to the oscillator phase:
|
||||
/// mult → warp → mirror → size (in that order).
|
||||
///
|
||||
/// All parameters have neutral defaults that result in no transformation.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct PhaseShape {
|
||||
/// Phase multiplier. Values > 1 create harmonic overtones.
|
||||
pub size: u16,
|
||||
/// Phase multiplication factor. Default: 1.0 (no multiplication).
|
||||
pub mult: f32,
|
||||
/// Power curve exponent. Positive values compress early phase,
|
||||
/// negative values compress late phase. Default: 0.0 (linear).
|
||||
pub warp: f32,
|
||||
/// Mirror/fold position in `[0, 1]`. Phase reflects at this point.
|
||||
/// Default: 0.0 (disabled).
|
||||
pub mirror: f32,
|
||||
}
|
||||
|
||||
impl Default for PhaseShape {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
size: 0,
|
||||
mult: 1.0,
|
||||
warp: 0.0,
|
||||
mirror: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PhaseShape {
|
||||
/// Returns `true` if any shaping parameter is non-neutral.
|
||||
#[inline]
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.size >= 2 || self.mult != 1.0 || self.warp != 0.0 || self.mirror > 0.0
|
||||
}
|
||||
|
||||
/// Applies the full transformation chain to a phase value.
|
||||
///
|
||||
/// Input and output are in the range `[0, 1)`.
|
||||
/// Assumes `is_active()` returned true; call unconditionally for simplicity
|
||||
/// or guard with `is_active()` to skip the function call entirely.
|
||||
#[inline]
|
||||
pub fn apply(&self, phase: f32) -> f32 {
|
||||
let mut p = phase;
|
||||
|
||||
// MULT: multiply and wrap
|
||||
if self.mult != 1.0 {
|
||||
p = (p * self.mult).fract();
|
||||
if p < 0.0 {
|
||||
p += 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
// WARP: power curve asymmetry
|
||||
if self.warp != 0.0 {
|
||||
p = powf(p, exp2f(self.warp * 2.0));
|
||||
}
|
||||
|
||||
// MIRROR: reflect at position
|
||||
if self.mirror > 0.0 && self.mirror < 1.0 {
|
||||
let m = self.mirror;
|
||||
p = if p < m {
|
||||
p / m
|
||||
} else {
|
||||
1.0 - (p - m) / (1.0 - m)
|
||||
};
|
||||
}
|
||||
|
||||
// SIZE: quantize
|
||||
if self.size >= 2 {
|
||||
let steps = self.size as f32;
|
||||
p = ((p * steps).floor() / (steps - 1.0)).min(1.0);
|
||||
}
|
||||
|
||||
p
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase accumulator with waveform generation methods.
|
||||
///
|
||||
/// Maintains a phase value in `[0, 1)` that advances each sample based on
|
||||
/// frequency. Provides both stateful methods (advance phase) and stateless
|
||||
/// methods (compute at arbitrary phase for unison/spread).
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Phasor {
|
||||
/// Current phase position in `[0, 1)`.
|
||||
pub phase: f32,
|
||||
/// Held value for sample-and-hold LFO.
|
||||
sh_value: f32,
|
||||
/// PRNG state for sample-and-hold randomization.
|
||||
sh_seed: u32,
|
||||
}
|
||||
|
||||
impl Default for Phasor {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
phase: 0.0,
|
||||
sh_value: 0.0,
|
||||
sh_seed: 123456789,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Phasor {
|
||||
/// Advances the phase by one sample.
|
||||
///
|
||||
/// - `freq`: Oscillator frequency in Hz
|
||||
/// - `isr`: Inverse sample rate (1.0 / sample_rate)
|
||||
pub fn update(&mut self, freq: f32, isr: f32) {
|
||||
self.phase += freq * isr;
|
||||
if self.phase >= 1.0 {
|
||||
self.phase -= 1.0;
|
||||
}
|
||||
if self.phase < 0.0 {
|
||||
self.phase += 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates an LFO sample for the given shape.
|
||||
///
|
||||
/// Sample-and-hold (`Sh`) latches a new random value at each cycle start.
|
||||
pub fn lfo(&mut self, shape: LfoShape, freq: f32, isr: f32) -> f32 {
|
||||
let p = self.phase;
|
||||
self.update(freq, isr);
|
||||
|
||||
match shape {
|
||||
LfoShape::Sine => sinf(p * 2.0 * PI),
|
||||
LfoShape::Tri => {
|
||||
if p < 0.5 {
|
||||
4.0 * p - 1.0
|
||||
} else {
|
||||
3.0 - 4.0 * p
|
||||
}
|
||||
}
|
||||
LfoShape::Saw => p * 2.0 - 1.0,
|
||||
LfoShape::Square => {
|
||||
if p < 0.5 {
|
||||
1.0
|
||||
} else {
|
||||
-1.0
|
||||
}
|
||||
}
|
||||
LfoShape::Sh => {
|
||||
if self.phase < p {
|
||||
self.sh_seed = self.sh_seed.wrapping_mul(1103515245).wrapping_add(12345);
|
||||
self.sh_value = ((self.sh_seed >> 16) & 0x7fff) as f32 / 16383.5 - 1.0;
|
||||
}
|
||||
self.sh_value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure sine wave.
|
||||
pub fn sine(&mut self, freq: f32, isr: f32) -> f32 {
|
||||
let s = sinf(self.phase * 2.0 * PI);
|
||||
self.update(freq, isr);
|
||||
s
|
||||
}
|
||||
|
||||
/// Triangle wave (no anti-aliasing needed, naturally band-limited).
|
||||
pub fn tri(&mut self, freq: f32, isr: f32) -> f32 {
|
||||
let s = if self.phase < 0.5 {
|
||||
4.0 * self.phase - 1.0
|
||||
} else {
|
||||
3.0 - 4.0 * self.phase
|
||||
};
|
||||
self.update(freq, isr);
|
||||
s
|
||||
}
|
||||
|
||||
/// Band-limited sawtooth using PolyBLEP.
|
||||
pub fn saw(&mut self, freq: f32, isr: f32) -> f32 {
|
||||
let dt = freq * isr;
|
||||
let p = poly_blep(self.phase, dt);
|
||||
let s = self.phase * 2.0 - 1.0 - p;
|
||||
self.update(freq, isr);
|
||||
s
|
||||
}
|
||||
|
||||
/// Raw sawtooth without anti-aliasing.
|
||||
///
|
||||
/// Use for low frequencies or when aliasing is acceptable/desired.
|
||||
pub fn zaw(&mut self, freq: f32, isr: f32) -> f32 {
|
||||
let s = self.phase * 2.0 - 1.0;
|
||||
self.update(freq, isr);
|
||||
s
|
||||
}
|
||||
|
||||
/// Band-limited pulse wave with variable width using PolyBLEP.
|
||||
///
|
||||
/// - `pw`: Pulse width in `[0, 1]`. 0.5 = square wave.
|
||||
pub fn pulse(&mut self, freq: f32, pw: f32, isr: f32) -> f32 {
|
||||
let dt = freq * isr;
|
||||
let mut phi = self.phase + pw;
|
||||
if phi >= 1.0 {
|
||||
phi -= 1.0;
|
||||
}
|
||||
let p1 = poly_blep(phi, dt);
|
||||
let p2 = poly_blep(self.phase, dt);
|
||||
let pulse = 2.0 * (self.phase - phi) - p2 + p1;
|
||||
self.update(freq, isr);
|
||||
pulse + pw * 2.0 - 1.0
|
||||
}
|
||||
|
||||
/// Raw pulse wave without anti-aliasing.
|
||||
///
|
||||
/// - `duty`: Duty cycle in `[0, 1]`. 0.5 = square wave.
|
||||
pub fn pulze(&mut self, freq: f32, duty: f32, isr: f32) -> f32 {
|
||||
let s = if self.phase < duty { 1.0 } else { -1.0 };
|
||||
self.update(freq, isr);
|
||||
s
|
||||
}
|
||||
|
||||
/// Sine wave with phase shaping.
|
||||
pub fn sine_shaped(&mut self, freq: f32, isr: f32, shape: &PhaseShape) -> f32 {
|
||||
let p = if shape.is_active() {
|
||||
shape.apply(self.phase)
|
||||
} else {
|
||||
self.phase
|
||||
};
|
||||
let s = sinf(p * 2.0 * PI);
|
||||
self.update(freq, isr);
|
||||
s
|
||||
}
|
||||
|
||||
/// Triangle wave with phase shaping.
|
||||
pub fn tri_shaped(&mut self, freq: f32, isr: f32, shape: &PhaseShape) -> f32 {
|
||||
let p = if shape.is_active() {
|
||||
shape.apply(self.phase)
|
||||
} else {
|
||||
self.phase
|
||||
};
|
||||
let s = if p < 0.5 {
|
||||
4.0 * p - 1.0
|
||||
} else {
|
||||
3.0 - 4.0 * p
|
||||
};
|
||||
self.update(freq, isr);
|
||||
s
|
||||
}
|
||||
|
||||
/// Sawtooth with phase shaping. Falls back to raw saw when shaped.
|
||||
pub fn saw_shaped(&mut self, freq: f32, isr: f32, shape: &PhaseShape) -> f32 {
|
||||
if !shape.is_active() {
|
||||
return self.saw(freq, isr);
|
||||
}
|
||||
let p = shape.apply(self.phase);
|
||||
let s = p * 2.0 - 1.0;
|
||||
self.update(freq, isr);
|
||||
s
|
||||
}
|
||||
|
||||
/// Raw sawtooth with phase shaping.
|
||||
pub fn zaw_shaped(&mut self, freq: f32, isr: f32, shape: &PhaseShape) -> f32 {
|
||||
let p = if shape.is_active() {
|
||||
shape.apply(self.phase)
|
||||
} else {
|
||||
self.phase
|
||||
};
|
||||
let s = p * 2.0 - 1.0;
|
||||
self.update(freq, isr);
|
||||
s
|
||||
}
|
||||
|
||||
/// Pulse wave with phase shaping. Falls back to raw pulse when shaped.
|
||||
pub fn pulse_shaped(&mut self, freq: f32, pw: f32, isr: f32, shape: &PhaseShape) -> f32 {
|
||||
if !shape.is_active() {
|
||||
return self.pulse(freq, pw, isr);
|
||||
}
|
||||
let p = shape.apply(self.phase);
|
||||
let s = if p < pw { 1.0 } else { -1.0 };
|
||||
self.update(freq, isr);
|
||||
s
|
||||
}
|
||||
|
||||
/// Raw pulse with phase shaping.
|
||||
pub fn pulze_shaped(&mut self, freq: f32, duty: f32, isr: f32, shape: &PhaseShape) -> f32 {
|
||||
let p = if shape.is_active() {
|
||||
shape.apply(self.phase)
|
||||
} else {
|
||||
self.phase
|
||||
};
|
||||
let s = if p < duty { 1.0 } else { -1.0 };
|
||||
self.update(freq, isr);
|
||||
s
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Stateless variants for unison/spread - compute at arbitrary phase
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// Sine at arbitrary phase (stateless, for unison voices).
|
||||
#[inline]
|
||||
pub fn sine_at(&self, phase: f32, shape: &PhaseShape) -> f32 {
|
||||
let p = if shape.is_active() {
|
||||
shape.apply(phase)
|
||||
} else {
|
||||
phase
|
||||
};
|
||||
sinf(p * 2.0 * PI)
|
||||
}
|
||||
|
||||
/// Triangle at arbitrary phase (stateless, for unison voices).
|
||||
#[inline]
|
||||
pub fn tri_at(&self, phase: f32, shape: &PhaseShape) -> f32 {
|
||||
let p = if shape.is_active() {
|
||||
shape.apply(phase)
|
||||
} else {
|
||||
phase
|
||||
};
|
||||
if p < 0.5 {
|
||||
4.0 * p - 1.0
|
||||
} else {
|
||||
3.0 - 4.0 * p
|
||||
}
|
||||
}
|
||||
|
||||
/// Sawtooth at arbitrary phase (stateless, for unison voices).
|
||||
#[inline]
|
||||
pub fn saw_at(&self, phase: f32, shape: &PhaseShape) -> f32 {
|
||||
let p = if shape.is_active() {
|
||||
shape.apply(phase)
|
||||
} else {
|
||||
phase
|
||||
};
|
||||
p * 2.0 - 1.0
|
||||
}
|
||||
|
||||
/// Raw sawtooth at arbitrary phase (stateless, for unison voices).
|
||||
#[inline]
|
||||
pub fn zaw_at(&self, phase: f32, shape: &PhaseShape) -> f32 {
|
||||
let p = if shape.is_active() {
|
||||
shape.apply(phase)
|
||||
} else {
|
||||
phase
|
||||
};
|
||||
p * 2.0 - 1.0
|
||||
}
|
||||
|
||||
/// Pulse at arbitrary phase (stateless, for unison voices).
|
||||
#[inline]
|
||||
pub fn pulse_at(&self, phase: f32, pw: f32, shape: &PhaseShape) -> f32 {
|
||||
let p = if shape.is_active() {
|
||||
shape.apply(phase)
|
||||
} else {
|
||||
phase
|
||||
};
|
||||
if p < pw {
|
||||
1.0
|
||||
} else {
|
||||
-1.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Raw pulse at arbitrary phase (stateless, for unison voices).
|
||||
#[inline]
|
||||
pub fn pulze_at(&self, phase: f32, duty: f32, shape: &PhaseShape) -> f32 {
|
||||
let p = if shape.is_active() {
|
||||
shape.apply(phase)
|
||||
} else {
|
||||
phase
|
||||
};
|
||||
if p < duty {
|
||||
1.0
|
||||
} else {
|
||||
-1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
212
src/plaits.rs
Normal file
212
src/plaits.rs
Normal file
@@ -0,0 +1,212 @@
|
||||
//! Unified wrapper for Mutable Instruments Plaits synthesis engines.
|
||||
//!
|
||||
//! This module provides a single enum that wraps all 13 synthesis engines from
|
||||
//! the `mi_plaits_dsp` crate (a Rust port of the Mutable Instruments Plaits
|
||||
//! Eurorack module). Each engine produces sound through a different synthesis
|
||||
//! technique.
|
||||
//!
|
||||
//! # Engine Categories
|
||||
//!
|
||||
//! ## Pitched Engines
|
||||
//! - [`Modal`](PlaitsEngine::Modal) - Physical modeling of resonant structures
|
||||
//! - [`Va`](PlaitsEngine::Va) - Virtual analog (classic subtractive synthesis)
|
||||
//! - [`Ws`](PlaitsEngine::Ws) - Waveshaping synthesis
|
||||
//! - [`Fm`](PlaitsEngine::Fm) - 2-operator FM synthesis
|
||||
//! - [`Grain`](PlaitsEngine::Grain) - Granular synthesis
|
||||
//! - [`Additive`](PlaitsEngine::Additive) - Additive synthesis with harmonic control
|
||||
//! - [`Wavetable`](PlaitsEngine::Wavetable) - Wavetable oscillator
|
||||
//! - [`Chord`](PlaitsEngine::Chord) - Polyphonic chord generator
|
||||
//! - [`Swarm`](PlaitsEngine::Swarm) - Swarm of detuned oscillators
|
||||
//! - [`Noise`](PlaitsEngine::Noise) - Filtered noise with resonance
|
||||
//!
|
||||
//! ## Percussion Engines
|
||||
//! - [`Bass`](PlaitsEngine::Bass) - Analog kick drum model
|
||||
//! - [`Snare`](PlaitsEngine::Snare) - Analog snare drum model
|
||||
//! - [`Hat`](PlaitsEngine::Hat) - Hi-hat synthesis
|
||||
//!
|
||||
//! # Control Parameters
|
||||
//!
|
||||
//! All engines share a common control interface via [`EngineParameters`]:
|
||||
//! - `note` - MIDI note number (pitch)
|
||||
//! - `harmonics` - Timbre brightness/harmonics control
|
||||
//! - `timbre` - Primary timbre parameter
|
||||
//! - `morph` - Secondary timbre/morph parameter
|
||||
//! - `accent` - Velocity/accent amount
|
||||
//! - `trigger` - Gate/trigger state
|
||||
|
||||
use crate::types::{Source, BLOCK_SIZE};
|
||||
use mi_plaits_dsp::engine::additive_engine::AdditiveEngine;
|
||||
use mi_plaits_dsp::engine::bass_drum_engine::BassDrumEngine;
|
||||
use mi_plaits_dsp::engine::chord_engine::ChordEngine;
|
||||
use mi_plaits_dsp::engine::fm_engine::FmEngine;
|
||||
use mi_plaits_dsp::engine::grain_engine::GrainEngine;
|
||||
use mi_plaits_dsp::engine::hihat_engine::HihatEngine;
|
||||
use mi_plaits_dsp::engine::modal_engine::ModalEngine;
|
||||
use mi_plaits_dsp::engine::noise_engine::NoiseEngine;
|
||||
use mi_plaits_dsp::engine::snare_drum_engine::SnareDrumEngine;
|
||||
use mi_plaits_dsp::engine::swarm_engine::SwarmEngine;
|
||||
use mi_plaits_dsp::engine::virtual_analog_engine::VirtualAnalogEngine;
|
||||
use mi_plaits_dsp::engine::waveshaping_engine::WaveshapingEngine;
|
||||
use mi_plaits_dsp::engine::wavetable_engine::WavetableEngine;
|
||||
use mi_plaits_dsp::engine::{Engine, EngineParameters};
|
||||
|
||||
/// Wrapper enum containing all Plaits synthesis engines.
|
||||
///
|
||||
/// Only one engine is active at a time. The engine is lazily initialized
|
||||
/// when first needed and can be switched by creating a new instance with
|
||||
/// [`PlaitsEngine::new`].
|
||||
pub enum PlaitsEngine {
|
||||
/// Physical modeling of resonant structures (strings, plates, tubes).
|
||||
Modal(ModalEngine),
|
||||
/// Classic virtual analog with saw, pulse, and sub oscillator.
|
||||
Va(VirtualAnalogEngine),
|
||||
/// Waveshaping synthesis for harsh, aggressive timbres.
|
||||
Ws(WaveshapingEngine),
|
||||
/// Two-operator FM synthesis.
|
||||
Fm(FmEngine),
|
||||
/// Granular synthesis with pitch-shifting grains.
|
||||
Grain(GrainEngine),
|
||||
/// Additive synthesis with individual harmonic control.
|
||||
Additive(AdditiveEngine),
|
||||
/// Wavetable oscillator with smooth morphing.
|
||||
Wavetable(WavetableEngine<'static>),
|
||||
/// Polyphonic chord generator (boxed due to size).
|
||||
Chord(Box<ChordEngine<'static>>),
|
||||
/// Swarm of detuned sawtooth oscillators.
|
||||
Swarm(SwarmEngine),
|
||||
/// Filtered noise with variable resonance.
|
||||
Noise(NoiseEngine),
|
||||
/// Analog bass drum synthesis.
|
||||
Bass(BassDrumEngine),
|
||||
/// Analog snare drum synthesis.
|
||||
Snare(SnareDrumEngine),
|
||||
/// Metallic hi-hat synthesis.
|
||||
Hat(HihatEngine),
|
||||
}
|
||||
|
||||
impl PlaitsEngine {
|
||||
/// Creates and initializes a new engine based on the given source type.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if `source` is not a Plaits source variant (e.g., `Source::Tri`).
|
||||
pub fn new(source: Source, sample_rate: f32) -> Self {
|
||||
match source {
|
||||
Source::PlModal => {
|
||||
let mut e = ModalEngine::new(BLOCK_SIZE);
|
||||
e.init(sample_rate);
|
||||
Self::Modal(e)
|
||||
}
|
||||
Source::PlVa => {
|
||||
let mut e = VirtualAnalogEngine::new(BLOCK_SIZE);
|
||||
e.init(sample_rate);
|
||||
Self::Va(e)
|
||||
}
|
||||
Source::PlWs => {
|
||||
let mut e = WaveshapingEngine::new();
|
||||
e.init(sample_rate);
|
||||
Self::Ws(e)
|
||||
}
|
||||
Source::PlFm => {
|
||||
let mut e = FmEngine::new();
|
||||
e.init(sample_rate);
|
||||
Self::Fm(e)
|
||||
}
|
||||
Source::PlGrain => {
|
||||
let mut e = GrainEngine::new();
|
||||
e.init(sample_rate);
|
||||
Self::Grain(e)
|
||||
}
|
||||
Source::PlAdd => {
|
||||
let mut e = AdditiveEngine::new();
|
||||
e.init(sample_rate);
|
||||
Self::Additive(e)
|
||||
}
|
||||
Source::PlWt => {
|
||||
let mut e = WavetableEngine::new();
|
||||
e.init(sample_rate);
|
||||
Self::Wavetable(e)
|
||||
}
|
||||
Source::PlChord => {
|
||||
let mut e = ChordEngine::new();
|
||||
e.init(sample_rate);
|
||||
Self::Chord(Box::new(e))
|
||||
}
|
||||
Source::PlSwarm => {
|
||||
let mut e = SwarmEngine::new();
|
||||
e.init(sample_rate);
|
||||
Self::Swarm(e)
|
||||
}
|
||||
Source::PlNoise => {
|
||||
let mut e = NoiseEngine::new(BLOCK_SIZE);
|
||||
e.init(sample_rate);
|
||||
Self::Noise(e)
|
||||
}
|
||||
Source::PlBass => {
|
||||
let mut e = BassDrumEngine::new();
|
||||
e.init(sample_rate);
|
||||
Self::Bass(e)
|
||||
}
|
||||
Source::PlSnare => {
|
||||
let mut e = SnareDrumEngine::new();
|
||||
e.init(sample_rate);
|
||||
Self::Snare(e)
|
||||
}
|
||||
Source::PlHat => {
|
||||
let mut e = HihatEngine::new(BLOCK_SIZE);
|
||||
e.init(sample_rate);
|
||||
Self::Hat(e)
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a block of audio samples.
|
||||
///
|
||||
/// # Arguments
|
||||
/// - `params` - Engine parameters (pitch, timbre, morph, etc.)
|
||||
/// - `out` - Output buffer for main signal (length must be `BLOCK_SIZE`)
|
||||
/// - `aux` - Output buffer for auxiliary signal (length must be `BLOCK_SIZE`)
|
||||
/// - `already_enveloped` - Set to true by percussion engines that apply their own envelope
|
||||
pub fn render(
|
||||
&mut self,
|
||||
params: &EngineParameters,
|
||||
out: &mut [f32],
|
||||
aux: &mut [f32],
|
||||
already_enveloped: &mut bool,
|
||||
) {
|
||||
match self {
|
||||
Self::Modal(e) => e.render(params, out, aux, already_enveloped),
|
||||
Self::Va(e) => e.render(params, out, aux, already_enveloped),
|
||||
Self::Ws(e) => e.render(params, out, aux, already_enveloped),
|
||||
Self::Fm(e) => e.render(params, out, aux, already_enveloped),
|
||||
Self::Grain(e) => e.render(params, out, aux, already_enveloped),
|
||||
Self::Additive(e) => e.render(params, out, aux, already_enveloped),
|
||||
Self::Wavetable(e) => e.render(params, out, aux, already_enveloped),
|
||||
Self::Chord(e) => e.render(params, out, aux, already_enveloped),
|
||||
Self::Swarm(e) => e.render(params, out, aux, already_enveloped),
|
||||
Self::Noise(e) => e.render(params, out, aux, already_enveloped),
|
||||
Self::Bass(e) => e.render(params, out, aux, already_enveloped),
|
||||
Self::Snare(e) => e.render(params, out, aux, already_enveloped),
|
||||
Self::Hat(e) => e.render(params, out, aux, already_enveloped),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the [`Source`] variant corresponding to the current engine.
|
||||
pub fn source(&self) -> Source {
|
||||
match self {
|
||||
Self::Modal(_) => Source::PlModal,
|
||||
Self::Va(_) => Source::PlVa,
|
||||
Self::Ws(_) => Source::PlWs,
|
||||
Self::Fm(_) => Source::PlFm,
|
||||
Self::Grain(_) => Source::PlGrain,
|
||||
Self::Additive(_) => Source::PlAdd,
|
||||
Self::Wavetable(_) => Source::PlWt,
|
||||
Self::Chord(_) => Source::PlChord,
|
||||
Self::Swarm(_) => Source::PlSwarm,
|
||||
Self::Noise(_) => Source::PlNoise,
|
||||
Self::Bass(_) => Source::PlBass,
|
||||
Self::Snare(_) => Source::PlSnare,
|
||||
Self::Hat(_) => Source::PlHat,
|
||||
}
|
||||
}
|
||||
}
|
||||
429
src/repl.rs
Normal file
429
src/repl.rs
Normal file
@@ -0,0 +1,429 @@
|
||||
//! Interactive REPL for the doux audio engine.
|
||||
//!
|
||||
//! Provides a command-line interface for live-coding audio patterns with
|
||||
//! readline-style editing and persistent history.
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! ```text
|
||||
//! doux-repl [OPTIONS]
|
||||
//!
|
||||
//! Options:
|
||||
//! -s, --samples <PATH> Directory containing audio samples
|
||||
//! -i, --input <DEVICE> Input device (name or index)
|
||||
//! -o, --output <DEVICE> Output device (name or index)
|
||||
//! --channels <N> Number of output channels (default: 2)
|
||||
//! --list-devices List available audio devices and exit
|
||||
//! ```
|
||||
//!
|
||||
//! # REPL Commands
|
||||
//!
|
||||
//! | Command | Alias | Description |
|
||||
//! |-----------|-------|--------------------------------------|
|
||||
//! | `.quit` | `.q` | Exit the REPL |
|
||||
//! | `.reset` | `.r` | Reset engine state |
|
||||
//! | `.hush` | | Fade out all voices |
|
||||
//! | `.panic` | | Immediately silence all voices |
|
||||
//! | `.voices` | | Show active voice count |
|
||||
//! | `.time` | | Show engine time in seconds |
|
||||
//! | `.help` | `.h` | Show available commands |
|
||||
//!
|
||||
//! Any other input is evaluated as a doux pattern.
|
||||
|
||||
use clap::Parser;
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use cpal::{Device, Host};
|
||||
use doux::Engine;
|
||||
use rustyline::completion::Completer;
|
||||
use rustyline::error::ReadlineError;
|
||||
use rustyline::highlight::Highlighter;
|
||||
use rustyline::hint::Hinter;
|
||||
use rustyline::validate::Validator;
|
||||
use rustyline::Helper;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::VecDeque;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
// ANSI color codes
|
||||
const RESET: &str = "\x1b[0m";
|
||||
const GRAY: &str = "\x1b[90m";
|
||||
const BOLD: &str = "\x1b[1m";
|
||||
const RED: &str = "\x1b[31m";
|
||||
const DIM_GRAY: &str = "\x1b[2;90m";
|
||||
const CYAN: &str = "\x1b[36m";
|
||||
|
||||
struct DouxHighlighter;
|
||||
|
||||
impl Highlighter for DouxHighlighter {
|
||||
fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> {
|
||||
// Comment: everything after //
|
||||
if let Some(idx) = line.find("//") {
|
||||
let before = &line[..idx];
|
||||
let comment = &line[idx..];
|
||||
let highlighted_before = highlight_pattern(before);
|
||||
return Cow::Owned(format!("{highlighted_before}{DIM_GRAY}{comment}{RESET}"));
|
||||
}
|
||||
|
||||
// Dot command
|
||||
if line.trim_start().starts_with('.') {
|
||||
return Cow::Owned(format!("{CYAN}{line}{RESET}"));
|
||||
}
|
||||
|
||||
// Pattern with /key/value
|
||||
Cow::Owned(highlight_pattern(line))
|
||||
}
|
||||
|
||||
fn highlight_char(&self, _line: &str, _pos: usize, _forced: bool) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn highlight_pattern(line: &str) -> String {
|
||||
let mut result = String::new();
|
||||
let mut chars = line.chars().peekable();
|
||||
let mut after_slash = false;
|
||||
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '/' {
|
||||
result.push_str(GRAY);
|
||||
result.push(c);
|
||||
result.push_str(RESET);
|
||||
after_slash = true;
|
||||
} else if after_slash {
|
||||
// Collect the token until next /
|
||||
let mut token = String::new();
|
||||
token.push(c);
|
||||
while let Some(&next) = chars.peek() {
|
||||
if next == '/' {
|
||||
break;
|
||||
}
|
||||
token.push(chars.next().unwrap());
|
||||
}
|
||||
// Is it a number?
|
||||
if is_number(&token) {
|
||||
result.push_str(RED);
|
||||
result.push_str(&token);
|
||||
result.push_str(RESET);
|
||||
} else {
|
||||
result.push_str(BOLD);
|
||||
result.push_str(&token);
|
||||
result.push_str(RESET);
|
||||
}
|
||||
after_slash = false;
|
||||
} else {
|
||||
result.push(c);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn is_number(s: &str) -> bool {
|
||||
if s.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let s = s.strip_prefix('-').unwrap_or(s);
|
||||
s.chars().all(|c| c.is_ascii_digit() || c == '.')
|
||||
}
|
||||
|
||||
impl Completer for DouxHighlighter {
|
||||
type Candidate = String;
|
||||
}
|
||||
|
||||
impl Hinter for DouxHighlighter {
|
||||
type Hint = String;
|
||||
}
|
||||
|
||||
impl Validator for DouxHighlighter {}
|
||||
|
||||
impl Helper for DouxHighlighter {}
|
||||
|
||||
/// Maximum samples buffered from audio input.
|
||||
const INPUT_BUFFER_SIZE: usize = 8192;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "doux-repl")]
|
||||
#[command(about = "Interactive REPL for doux audio engine")]
|
||||
struct Args {
|
||||
/// Directory containing audio samples
|
||||
#[arg(short, long)]
|
||||
samples: Option<PathBuf>,
|
||||
|
||||
/// List available audio devices and exit
|
||||
#[arg(long)]
|
||||
list_devices: bool,
|
||||
|
||||
/// Input device (name or index)
|
||||
#[arg(short, long)]
|
||||
input: Option<String>,
|
||||
|
||||
/// Output device (name or index)
|
||||
#[arg(short, long)]
|
||||
output: Option<String>,
|
||||
|
||||
/// Number of output channels (default: 2, max depends on device)
|
||||
#[arg(long, default_value = "2")]
|
||||
channels: u16,
|
||||
|
||||
/// Audio buffer size in samples (lower = less latency, higher = more stable).
|
||||
/// Common values: 64, 128, 256, 512, 1024. Default: system choice.
|
||||
#[arg(short, long)]
|
||||
buffer_size: Option<u32>,
|
||||
}
|
||||
|
||||
/// Prints available audio input and output devices.
|
||||
///
|
||||
/// Default devices are marked with `*`.
|
||||
fn list_devices(host: &Host) {
|
||||
let default_in = host.default_input_device().and_then(|d| d.name().ok());
|
||||
let default_out = host.default_output_device().and_then(|d| d.name().ok());
|
||||
|
||||
println!("Input devices:");
|
||||
if let Ok(devices) = host.input_devices() {
|
||||
for (i, d) in devices.enumerate() {
|
||||
let name = d.name().unwrap_or_else(|_| "???".into());
|
||||
let marker = if Some(&name) == default_in.as_ref() {
|
||||
" *"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
println!(" {i}: {name}{marker}");
|
||||
}
|
||||
}
|
||||
|
||||
println!("\nOutput devices:");
|
||||
if let Ok(devices) = host.output_devices() {
|
||||
for (i, d) in devices.enumerate() {
|
||||
let name = d.name().unwrap_or_else(|_| "???".into());
|
||||
let marker = if Some(&name) == default_out.as_ref() {
|
||||
" *"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
println!(" {i}: {name}{marker}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds a device by index or substring match on name.
|
||||
fn find_device<I>(devices: I, spec: &str) -> Option<Device>
|
||||
where
|
||||
I: Iterator<Item = Device>,
|
||||
{
|
||||
let devices: Vec<_> = devices.collect();
|
||||
if let Ok(idx) = spec.parse::<usize>() {
|
||||
return devices.into_iter().nth(idx);
|
||||
}
|
||||
let spec_lower = spec.to_lowercase();
|
||||
devices.into_iter().find(|d| {
|
||||
d.name()
|
||||
.map(|n| n.to_lowercase().contains(&spec_lower))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
/// Prints available REPL commands.
|
||||
fn print_help() {
|
||||
println!("Commands:");
|
||||
println!(" .quit, .q Exit the REPL");
|
||||
println!(" .reset, .r Reset engine state");
|
||||
println!(" .hush Fade out all voices");
|
||||
println!(" .panic Immediately silence all voices");
|
||||
println!(" .voices Show active voice count");
|
||||
println!(" .time Show engine time");
|
||||
println!(" .stats, .s Show engine telemetry (CPU, voices, memory)");
|
||||
println!(" .help, .h Show this help");
|
||||
println!();
|
||||
println!("Any other input is evaluated as a doux pattern.");
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args = Args::parse();
|
||||
let host = cpal::default_host();
|
||||
|
||||
if args.list_devices {
|
||||
list_devices(&host);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let device = match &args.output {
|
||||
Some(spec) => host
|
||||
.output_devices()
|
||||
.ok()
|
||||
.and_then(|d| find_device(d, spec))
|
||||
.unwrap_or_else(|| panic!("output device '{spec}' not found")),
|
||||
None => host.default_output_device().expect("no output device"),
|
||||
};
|
||||
|
||||
let max_channels = device
|
||||
.supported_output_configs()
|
||||
.map(|configs| configs.map(|c| c.channels()).max().unwrap_or(2))
|
||||
.unwrap_or(2);
|
||||
|
||||
let output_channels = (args.channels as usize).min(max_channels as usize);
|
||||
if args.channels as usize > output_channels {
|
||||
eprintln!(
|
||||
"Warning: device supports max {} channels, using that instead of {}",
|
||||
max_channels, args.channels
|
||||
);
|
||||
}
|
||||
|
||||
let default_config = device.default_output_config()?;
|
||||
let sample_rate = default_config.sample_rate().0 as f32;
|
||||
|
||||
let config = cpal::StreamConfig {
|
||||
channels: output_channels as u16,
|
||||
sample_rate: default_config.sample_rate(),
|
||||
buffer_size: args
|
||||
.buffer_size
|
||||
.map(cpal::BufferSize::Fixed)
|
||||
.unwrap_or(cpal::BufferSize::Default),
|
||||
};
|
||||
|
||||
println!("doux-repl");
|
||||
print!(
|
||||
"Output: {} @ {}Hz, {} channels",
|
||||
device.name().unwrap_or_default(),
|
||||
sample_rate,
|
||||
output_channels
|
||||
);
|
||||
if let Some(buf) = args.buffer_size {
|
||||
let latency_ms = buf as f32 / sample_rate * 1000.0;
|
||||
println!(", {buf} samples ({latency_ms:.1} ms)");
|
||||
} else {
|
||||
println!();
|
||||
}
|
||||
|
||||
let mut engine = Engine::new_with_channels(sample_rate, output_channels);
|
||||
|
||||
if let Some(ref dir) = args.samples {
|
||||
let index = doux::loader::scan_samples_dir(dir);
|
||||
println!("Samples: {} from {}", index.len(), dir.display());
|
||||
engine.sample_index = index;
|
||||
}
|
||||
|
||||
let engine = Arc::new(Mutex::new(engine));
|
||||
let input_buffer: Arc<Mutex<VecDeque<f32>>> =
|
||||
Arc::new(Mutex::new(VecDeque::with_capacity(INPUT_BUFFER_SIZE)));
|
||||
|
||||
let input_device = match &args.input {
|
||||
Some(spec) => host.input_devices().ok().and_then(|d| find_device(d, spec)),
|
||||
None => host.default_input_device(),
|
||||
};
|
||||
|
||||
let _input_stream = input_device.and_then(|input_device| {
|
||||
let input_config = input_device.default_input_config().ok()?;
|
||||
println!("Input: {}", input_device.name().unwrap_or_default());
|
||||
let buf = Arc::clone(&input_buffer);
|
||||
let stream = input_device
|
||||
.build_input_stream(
|
||||
&input_config.into(),
|
||||
move |data: &[f32], _| {
|
||||
let mut b = buf.lock().unwrap();
|
||||
b.extend(data.iter().copied());
|
||||
let excess = b.len().saturating_sub(INPUT_BUFFER_SIZE);
|
||||
if excess > 0 {
|
||||
drop(b.drain(..excess));
|
||||
}
|
||||
},
|
||||
|err| eprintln!("input error: {err}"),
|
||||
None,
|
||||
)
|
||||
.ok()?;
|
||||
stream.play().ok()?;
|
||||
Some(stream)
|
||||
});
|
||||
|
||||
let engine_clone = Arc::clone(&engine);
|
||||
let input_buf_clone = Arc::clone(&input_buffer);
|
||||
let sr = sample_rate;
|
||||
let ch = output_channels;
|
||||
|
||||
let stream = device.build_output_stream(
|
||||
&config,
|
||||
move |data: &mut [f32], _| {
|
||||
let mut scratch = vec![0.0f32; data.len()];
|
||||
{
|
||||
let mut buf = input_buf_clone.lock().unwrap();
|
||||
let available = buf.len().min(data.len());
|
||||
for (i, sample) in buf.drain(..available).enumerate() {
|
||||
scratch[i] = sample;
|
||||
}
|
||||
}
|
||||
let mut engine = engine_clone.lock().unwrap();
|
||||
let buffer_samples = data.len() / ch;
|
||||
let buffer_time_ns = (buffer_samples as f64 / sr as f64 * 1e9) as u64;
|
||||
engine.metrics.load.set_buffer_time(buffer_time_ns);
|
||||
engine.process_block(data, &[], &scratch);
|
||||
},
|
||||
|err| eprintln!("stream error: {err}"),
|
||||
None,
|
||||
)?;
|
||||
stream.play()?;
|
||||
|
||||
let mut rl = rustyline::Editor::new()?;
|
||||
rl.set_helper(Some(DouxHighlighter));
|
||||
let history_path = std::env::var("HOME")
|
||||
.map(|h| PathBuf::from(h).join(".doux_history"))
|
||||
.unwrap_or_else(|_| PathBuf::from(".doux_history"));
|
||||
let _ = rl.load_history(&history_path);
|
||||
|
||||
println!("Type .help for commands");
|
||||
|
||||
loop {
|
||||
match rl.readline("doux> ") {
|
||||
Ok(line) => {
|
||||
let _ = rl.add_history_entry(&line);
|
||||
let trimmed = line.trim();
|
||||
|
||||
match trimmed {
|
||||
".quit" | ".q" => break,
|
||||
".reset" | ".r" => {
|
||||
engine.lock().unwrap().evaluate("/doux/reset");
|
||||
}
|
||||
".voices" | ".v" => {
|
||||
println!("{}", engine.lock().unwrap().active_voices);
|
||||
}
|
||||
".time" | ".t" => {
|
||||
println!("{:.3}s", engine.lock().unwrap().time);
|
||||
}
|
||||
".stats" | ".s" => {
|
||||
use std::sync::atomic::Ordering;
|
||||
let e = engine.lock().unwrap();
|
||||
let cpu = e.metrics.load.get_load() * 100.0;
|
||||
let voices = e.metrics.active_voices.load(Ordering::Relaxed);
|
||||
let peak = e.metrics.peak_voices.load(Ordering::Relaxed);
|
||||
let sched = e.metrics.schedule_depth.load(Ordering::Relaxed);
|
||||
let mem = e.metrics.sample_pool_mb();
|
||||
println!("CPU: {cpu:5.1}%");
|
||||
println!("Voices: {voices:3}/{}", doux::types::MAX_VOICES);
|
||||
println!("Peak: {peak:3}");
|
||||
println!("Schedule: {sched:3}");
|
||||
println!("Samples: {mem:.1} MB");
|
||||
}
|
||||
".hush" => {
|
||||
engine.lock().unwrap().hush();
|
||||
}
|
||||
".panic" => {
|
||||
engine.lock().unwrap().panic();
|
||||
}
|
||||
".help" | ".h" => {
|
||||
print_help();
|
||||
}
|
||||
s if !s.is_empty() => {
|
||||
engine.lock().unwrap().evaluate(s);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Err(ReadlineError::Interrupted | ReadlineError::Eof) => break,
|
||||
Err(e) => {
|
||||
eprintln!("readline error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = rl.save_history(&history_path);
|
||||
Ok(())
|
||||
}
|
||||
264
src/sample.rs
Normal file
264
src/sample.rs
Normal file
@@ -0,0 +1,264 @@
|
||||
//! Sample storage and playback primitives.
|
||||
//!
|
||||
//! Provides a memory pool for audio samples and playback cursors for reading
|
||||
//! them back at variable speeds with interpolation.
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! ```text
|
||||
//! SampleEntry (index) SamplePool (storage) FileSource (playhead)
|
||||
//! ┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||
//! │ path: kick.wav │ │ [f32; N] │ │ sample_idx: 0 │
|
||||
//! │ name: "kick" │────▶│ ├─ sample 0 ─────│◀────│ pos: 0.0 │
|
||||
//! │ loaded: Some(0) │ │ ├─ sample 1 │ │ begin: 0.0 │
|
||||
//! └─────────────────┘ │ └─ ... │ │ end: 1.0 │
|
||||
//! └──────────────────┘ └─────────────────┘
|
||||
//! ```
|
||||
//!
|
||||
//! - [`SampleEntry`]: Metadata for lazy-loaded samples (path, name, pool index)
|
||||
//! - [`SamplePool`]: Contiguous f32 storage for all loaded sample data
|
||||
//! - [`SampleInfo`]: Location and format of a sample within the pool
|
||||
//! - [`FileSource`]: Playback cursor with position, speed, and loop points
|
||||
//! - [`WebSampleSource`]: Simplified playback for WASM (no interpolation)
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Index entry for a discoverable sample file.
|
||||
///
|
||||
/// Created during directory scanning with [`crate::loader::scan_samples_dir`].
|
||||
/// The `loaded` field is `None` until the sample is actually decoded and
|
||||
/// added to the pool.
|
||||
pub struct SampleEntry {
|
||||
/// Filesystem path to the audio file.
|
||||
pub path: PathBuf,
|
||||
/// Display name (derived from filename or folder/index).
|
||||
pub name: String,
|
||||
/// Pool index if loaded, `None` if not yet decoded.
|
||||
pub loaded: Option<usize>,
|
||||
}
|
||||
|
||||
/// Contiguous storage for all loaded sample data.
|
||||
///
|
||||
/// Samples are stored sequentially as interleaved f32 frames. Each sample's
|
||||
/// location is tracked by a corresponding [`SampleInfo`].
|
||||
///
|
||||
/// This design minimizes allocations and improves cache locality compared
|
||||
/// to storing each sample in a separate `Vec`.
|
||||
#[derive(Default)]
|
||||
pub struct SamplePool {
|
||||
/// Raw interleaved sample data for all loaded samples.
|
||||
pub data: Vec<f32>,
|
||||
}
|
||||
|
||||
impl SamplePool {
|
||||
/// Creates an empty sample pool.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Adds sample data to the pool and returns its metadata.
|
||||
///
|
||||
/// The samples should be interleaved if multi-channel (e.g., `[L, R, L, R, ...]`).
|
||||
///
|
||||
/// # Parameters
|
||||
///
|
||||
/// - `samples`: Interleaved audio data
|
||||
/// - `channels`: Number of channels (1 = mono, 2 = stereo)
|
||||
/// - `freq`: Base frequency in Hz for pitch calculations
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// [`SampleInfo`] describing the sample's location in the pool.
|
||||
pub fn add(&mut self, samples: &[f32], channels: u8, freq: f32) -> Option<SampleInfo> {
|
||||
let frames = samples.len() / channels as usize;
|
||||
let offset = self.data.len();
|
||||
|
||||
let info = SampleInfo {
|
||||
offset,
|
||||
frames: frames as u32,
|
||||
channels,
|
||||
freq,
|
||||
};
|
||||
|
||||
self.data.extend_from_slice(samples);
|
||||
Some(info)
|
||||
}
|
||||
|
||||
/// Returns the total memory usage in megabytes.
|
||||
pub fn size_mb(&self) -> f32 {
|
||||
(self.data.len() * 4) as f32 / (1024.0 * 1024.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Metadata for a sample stored in the pool.
|
||||
///
|
||||
/// Describes where a sample lives in the pool's data array and its format.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct SampleInfo {
|
||||
/// Byte offset into [`SamplePool::data`] where this sample begins.
|
||||
pub offset: usize,
|
||||
/// Total number of frames (samples per channel).
|
||||
pub frames: u32,
|
||||
/// Number of interleaved channels.
|
||||
pub channels: u8,
|
||||
/// Base frequency in Hz (used for pitch-shifting calculations).
|
||||
pub freq: f32,
|
||||
}
|
||||
|
||||
/// Playback cursor for reading samples from the pool.
|
||||
///
|
||||
/// Tracks playback position and supports:
|
||||
/// - Variable-speed playback (including reverse with negative speed)
|
||||
/// - Start/end points for partial playback or slicing
|
||||
/// - Linear interpolation between samples for smooth pitch shifting
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct FileSource {
|
||||
/// Index into the sample info array.
|
||||
pub sample_idx: usize,
|
||||
/// Current playback position in frames (fractional for interpolation).
|
||||
pub pos: f32,
|
||||
/// Start point as fraction of total length `[0.0, 1.0]`.
|
||||
pub begin: f32,
|
||||
/// End point as fraction of total length `[0.0, 1.0]`.
|
||||
pub end: f32,
|
||||
}
|
||||
|
||||
impl Default for FileSource {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sample_idx: 0,
|
||||
pos: 0.0,
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FileSource {
|
||||
/// Creates a new playback cursor for a sample with start/end points.
|
||||
///
|
||||
/// Points are clamped to valid ranges: begin to `[0, 1]`, end to `[begin, 1]`.
|
||||
pub fn new(sample_idx: usize, begin: f32, end: f32) -> Self {
|
||||
let begin_clamped = begin.clamp(0.0, 1.0);
|
||||
Self {
|
||||
sample_idx,
|
||||
pos: 0.0,
|
||||
begin: begin_clamped,
|
||||
end: end.clamp(begin_clamped, 1.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the next sample value and advances the playback position.
|
||||
///
|
||||
/// Uses linear interpolation for fractional positions, enabling smooth
|
||||
/// pitch shifting without stair-stepping artifacts.
|
||||
///
|
||||
/// # Parameters
|
||||
///
|
||||
/// - `pool`: The sample pool's data slice
|
||||
/// - `info`: Sample metadata (offset, frames, channels)
|
||||
/// - `speed`: Playback rate multiplier (1.0 = normal, 2.0 = double speed)
|
||||
/// - `channel`: Which channel to read (clamped to available channels)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The interpolated sample value, or `0.0` if past the end point.
|
||||
pub fn update(&mut self, pool: &[f32], info: &SampleInfo, speed: f32, channel: usize) -> f32 {
|
||||
let begin_frame = (self.begin * info.frames as f32) as usize;
|
||||
let end_frame = (self.end * info.frames as f32) as usize;
|
||||
let channels = info.channels as usize;
|
||||
|
||||
let current = self.pos as usize + begin_frame;
|
||||
if current >= end_frame {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let frac = self.pos.fract();
|
||||
let ch = channel.min(channels - 1);
|
||||
|
||||
let idx0 = info.offset + current * channels + ch;
|
||||
let idx1 = if current + 1 < end_frame {
|
||||
info.offset + (current + 1) * channels + ch
|
||||
} else {
|
||||
idx0
|
||||
};
|
||||
|
||||
let s0 = pool.get(idx0).copied().unwrap_or(0.0);
|
||||
let s1 = pool.get(idx1).copied().unwrap_or(0.0);
|
||||
|
||||
let sample = s0 + frac * (s1 - s0);
|
||||
|
||||
self.pos += speed;
|
||||
sample
|
||||
}
|
||||
|
||||
/// Returns `true` if playback has reached or passed the end point.
|
||||
pub fn is_done(&self, info: &SampleInfo) -> bool {
|
||||
let begin_frame = (self.begin * info.frames as f32) as usize;
|
||||
let end_frame = (self.end * info.frames as f32) as usize;
|
||||
let current = self.pos as usize + begin_frame;
|
||||
current >= end_frame
|
||||
}
|
||||
}
|
||||
|
||||
/// Simplified sample playback for WASM environments.
|
||||
///
|
||||
/// Unlike [`FileSource`], this struct embeds its [`SampleInfo`] and does not
|
||||
/// perform interpolation. Designed for web playback where JavaScript populates
|
||||
/// a shared PCM buffer that Rust reads from.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct WebSampleSource {
|
||||
/// Sample metadata (location, size, format).
|
||||
pub info: SampleInfo,
|
||||
/// Current playback position in frames (relative to begin point).
|
||||
pub pos: f32,
|
||||
/// Normalized start point (0.0 = sample start, 1.0 = sample end).
|
||||
pub begin: f32,
|
||||
/// Normalized end point (0.0 = sample start, 1.0 = sample end).
|
||||
pub end: f32,
|
||||
}
|
||||
|
||||
impl WebSampleSource {
|
||||
/// Creates a new sample source with the given loop points.
|
||||
///
|
||||
/// Both `begin` and `end` are normalized values in the range 0.0 to 1.0,
|
||||
/// representing positions within the sample. Values are clamped automatically.
|
||||
pub fn new(info: SampleInfo, begin: f32, end: f32) -> Self {
|
||||
let begin_clamped = begin.clamp(0.0, 1.0);
|
||||
Self {
|
||||
info,
|
||||
pos: 0.0,
|
||||
begin: begin_clamped,
|
||||
end: end.clamp(begin_clamped, 1.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Advances playback and returns the next sample value.
|
||||
///
|
||||
/// Returns 0.0 if playback has reached the end point. The `speed` parameter
|
||||
/// controls playback rate (1.0 = normal, 2.0 = double speed, 0.5 = half speed).
|
||||
/// The `channel` parameter selects which channel to read (clamped to available channels).
|
||||
pub fn update(&mut self, pcm_buffer: &[f32], speed: f32, channel: usize) -> f32 {
|
||||
let begin_frame = (self.begin * self.info.frames as f32) as usize;
|
||||
let end_frame = (self.end * self.info.frames as f32) as usize;
|
||||
let current = self.pos as usize + begin_frame;
|
||||
|
||||
if current >= end_frame {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let ch = channel.min(self.info.channels as usize - 1);
|
||||
let idx = self.info.offset + current * self.info.channels as usize + ch;
|
||||
let sample = pcm_buffer.get(idx).copied().unwrap_or(0.0);
|
||||
self.pos += speed;
|
||||
sample
|
||||
}
|
||||
|
||||
/// Returns true if playback has reached or passed the end point.
|
||||
pub fn is_done(&self) -> bool {
|
||||
let begin_frame = (self.begin * self.info.frames as f32) as usize;
|
||||
let end_frame = (self.end * self.info.frames as f32) as usize;
|
||||
let current = self.pos as usize + begin_frame;
|
||||
current >= end_frame
|
||||
}
|
||||
}
|
||||
98
src/schedule.rs
Normal file
98
src/schedule.rs
Normal file
@@ -0,0 +1,98 @@
|
||||
//! Time-based event scheduling with sorted storage.
|
||||
//!
|
||||
//! Manages a queue of [`Event`]s that should fire at specific times.
|
||||
//! Events are kept sorted by time for O(1) early-exit when no events are ready.
|
||||
//!
|
||||
//! # Event Lifecycle
|
||||
//!
|
||||
//! 1. Event with `time` field is parsed → inserted in sorted order
|
||||
//! 2. Engine calls `process_schedule()` each sample
|
||||
//! 3. When `event.time <= engine.time`:
|
||||
//! - Event fires (triggers voice/sound)
|
||||
//! - If `repeat` is set, event is re-inserted with new time
|
||||
//! - Otherwise, event is removed
|
||||
//!
|
||||
//! # Complexity
|
||||
//!
|
||||
//! - Insertion: O(N) due to sorted insert (infrequent, ~10-100/sec)
|
||||
//! - Processing: O(1) when no events ready (99.9% of calls)
|
||||
//! - Processing: O(K) when K events fire (rare)
|
||||
//!
|
||||
//! # Capacity
|
||||
//!
|
||||
//! Limited to [`MAX_EVENTS`](crate::types::MAX_EVENTS) to prevent unbounded
|
||||
//! growth. Events beyond this limit are silently dropped.
|
||||
|
||||
use crate::event::Event;
|
||||
use crate::types::MAX_EVENTS;
|
||||
|
||||
/// Queue of time-scheduled events, sorted by time ascending.
|
||||
///
|
||||
/// Invariant: `events[i].time <= events[i+1].time` for all valid indices.
|
||||
/// This enables O(1) early-exit: if `events[0].time > now`, no events are ready.
|
||||
pub struct Schedule {
|
||||
events: Vec<Event>,
|
||||
}
|
||||
|
||||
impl Schedule {
|
||||
/// Creates an empty schedule with pre-allocated capacity.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
events: Vec::with_capacity(MAX_EVENTS),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds an event to the schedule in sorted order.
|
||||
///
|
||||
/// Events at capacity are silently dropped.
|
||||
/// Insertion is O(N) but occurs infrequently (user actions).
|
||||
pub fn push(&mut self, event: Event) {
|
||||
if self.events.len() >= MAX_EVENTS {
|
||||
return;
|
||||
}
|
||||
let time = event.time.unwrap_or(f64::MAX);
|
||||
let pos = self
|
||||
.events
|
||||
.partition_point(|e| e.time.unwrap_or(f64::MAX) < time);
|
||||
self.events.insert(pos, event);
|
||||
}
|
||||
|
||||
/// Returns the time of the earliest event, if any.
|
||||
#[inline]
|
||||
pub fn peek_time(&self) -> Option<f64> {
|
||||
self.events.first().and_then(|e| e.time)
|
||||
}
|
||||
|
||||
/// Removes and returns the earliest event.
|
||||
#[inline]
|
||||
pub fn pop_front(&mut self) -> Option<Event> {
|
||||
if self.events.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(self.events.remove(0))
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the number of scheduled events.
|
||||
#[inline]
|
||||
pub fn len(&self) -> usize {
|
||||
self.events.len()
|
||||
}
|
||||
|
||||
/// Returns true if no events are scheduled.
|
||||
#[inline]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.events.is_empty()
|
||||
}
|
||||
|
||||
/// Removes all scheduled events.
|
||||
pub fn clear(&mut self) {
|
||||
self.events.clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Schedule {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
112
src/telemetry.rs
Normal file
112
src/telemetry.rs
Normal file
@@ -0,0 +1,112 @@
|
||||
//! Audio engine telemetry. Native only.
|
||||
|
||||
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
const LOAD_SCALE: f32 = 1_000_000.0; // fixed-point for atomic float storage
|
||||
const DEFAULT_SMOOTHING: f32 = 0.9;
|
||||
|
||||
/// Measures DSP load as ratio of processing time to buffer time.
|
||||
///
|
||||
/// Thread-safe via atomics. Load of 1.0 means using all available time.
|
||||
pub struct ProcessLoadMeasurer {
|
||||
buffer_time_ns: AtomicU64,
|
||||
load_fixed: AtomicU32,
|
||||
smoothing: f32,
|
||||
}
|
||||
|
||||
impl Default for ProcessLoadMeasurer {
|
||||
fn default() -> Self {
|
||||
Self::new(DEFAULT_SMOOTHING)
|
||||
}
|
||||
}
|
||||
|
||||
impl ProcessLoadMeasurer {
|
||||
/// Creates a new measurer. Smoothing in [0.0, 0.99]: higher = slower response.
|
||||
pub fn new(smoothing: f32) -> Self {
|
||||
Self {
|
||||
buffer_time_ns: AtomicU64::new(0),
|
||||
load_fixed: AtomicU32::new(0),
|
||||
smoothing: smoothing.clamp(0.0, 0.99),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_buffer_time(&self, ns: u64) {
|
||||
self.buffer_time_ns.store(ns, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Returns a timer that records elapsed time on drop.
|
||||
pub fn start_timer(&self) -> ScopedTimer<'_> {
|
||||
ScopedTimer {
|
||||
measurer: self,
|
||||
start: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_sample(&self, elapsed_ns: u64) {
|
||||
let buffer_ns = self.buffer_time_ns.load(Ordering::Relaxed);
|
||||
if buffer_ns == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let instant_load = (elapsed_ns as f64 / buffer_ns as f64).min(2.0) as f32;
|
||||
let old_fixed = self.load_fixed.load(Ordering::Relaxed);
|
||||
let old_load = old_fixed as f32 / LOAD_SCALE;
|
||||
let new_load = self.smoothing * old_load + (1.0 - self.smoothing) * instant_load;
|
||||
let new_fixed = (new_load * LOAD_SCALE) as u32;
|
||||
|
||||
self.load_fixed.store(new_fixed, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn get_load(&self) -> f32 {
|
||||
self.load_fixed.load(Ordering::Relaxed) as f32 / LOAD_SCALE
|
||||
}
|
||||
|
||||
pub fn reset(&self) {
|
||||
self.load_fixed.store(0, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// RAII timer that records elapsed time on drop.
|
||||
pub struct ScopedTimer<'a> {
|
||||
measurer: &'a ProcessLoadMeasurer,
|
||||
start: Instant,
|
||||
}
|
||||
|
||||
impl Drop for ScopedTimer<'_> {
|
||||
fn drop(&mut self) {
|
||||
let elapsed = self.start.elapsed().as_nanos() as u64;
|
||||
self.measurer.record_sample(elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Aggregated engine metrics. All fields atomic for cross-thread access.
|
||||
pub struct EngineMetrics {
|
||||
pub load: ProcessLoadMeasurer,
|
||||
pub active_voices: AtomicU32,
|
||||
pub peak_voices: AtomicU32,
|
||||
pub schedule_depth: AtomicU32,
|
||||
pub sample_pool_bytes: AtomicU64,
|
||||
}
|
||||
|
||||
impl Default for EngineMetrics {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
load: ProcessLoadMeasurer::default(),
|
||||
active_voices: AtomicU32::new(0),
|
||||
peak_voices: AtomicU32::new(0),
|
||||
schedule_depth: AtomicU32::new(0),
|
||||
sample_pool_bytes: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EngineMetrics {
|
||||
pub fn reset_peak_voices(&self) {
|
||||
self.peak_voices.store(0, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn sample_pool_mb(&self) -> f32 {
|
||||
self.sample_pool_bytes.load(Ordering::Relaxed) as f32 / (1024.0 * 1024.0)
|
||||
}
|
||||
}
|
||||
168
src/types.rs
Normal file
168
src/types.rs
Normal file
@@ -0,0 +1,168 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
pub const BLOCK_SIZE: usize = 128;
|
||||
pub const CHANNELS: usize = 2;
|
||||
pub const MAX_VOICES: usize = 32;
|
||||
pub const MAX_EVENTS: usize = 64;
|
||||
pub const MAX_ORBITS: usize = 8;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Debug, Default)]
|
||||
pub enum Source {
|
||||
#[default]
|
||||
Tri,
|
||||
Sine,
|
||||
Saw,
|
||||
Zaw,
|
||||
Pulse,
|
||||
Pulze,
|
||||
White,
|
||||
Pink,
|
||||
Brown,
|
||||
Sample, // Native: disk-loaded samples via FileSource
|
||||
WebSample, // Web: inline PCM from JavaScript
|
||||
LiveInput, // Live audio input (microphone, line-in)
|
||||
PlModal,
|
||||
PlVa,
|
||||
PlWs,
|
||||
PlFm,
|
||||
PlGrain,
|
||||
PlAdd,
|
||||
PlWt,
|
||||
PlChord,
|
||||
PlSwarm,
|
||||
PlNoise,
|
||||
PlBass,
|
||||
PlSnare,
|
||||
PlHat,
|
||||
}
|
||||
|
||||
impl Source {
|
||||
pub fn is_plaits_percussion(&self) -> bool {
|
||||
matches!(self, Self::PlBass | Self::PlSnare | Self::PlHat)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Source {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"triangle" | "tri" => Ok(Self::Tri),
|
||||
"sine" => Ok(Self::Sine),
|
||||
"sawtooth" | "saw" => Ok(Self::Saw),
|
||||
"zawtooth" | "zaw" => Ok(Self::Zaw),
|
||||
"pulse" | "square" => Ok(Self::Pulse),
|
||||
"pulze" | "zquare" => Ok(Self::Pulze),
|
||||
"white" => Ok(Self::White),
|
||||
"pink" => Ok(Self::Pink),
|
||||
"brown" => Ok(Self::Brown),
|
||||
"sample" => Ok(Self::Sample),
|
||||
"websample" => Ok(Self::WebSample),
|
||||
"live" | "livein" | "mic" => Ok(Self::LiveInput),
|
||||
"plmodal" | "modal" => Ok(Self::PlModal),
|
||||
"plva" | "va" | "analog" => Ok(Self::PlVa),
|
||||
"plws" | "ws" | "waveshape" => Ok(Self::PlWs),
|
||||
"plfm" | "fm2" => Ok(Self::PlFm),
|
||||
"plgrain" | "grain" => Ok(Self::PlGrain),
|
||||
"pladd" | "additive" => Ok(Self::PlAdd),
|
||||
"plwt" | "wavetable" => Ok(Self::PlWt),
|
||||
"plchord" | "chord" => Ok(Self::PlChord),
|
||||
"plswarm" | "swarm" => Ok(Self::PlSwarm),
|
||||
"plnoise" | "pnoise" => Ok(Self::PlNoise),
|
||||
"plbass" | "bass" | "kick" => Ok(Self::PlBass),
|
||||
"plsnare" | "snare" => Ok(Self::PlSnare),
|
||||
"plhat" | "hat" | "hihat" => Ok(Self::PlHat),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Debug, Default)]
|
||||
pub enum FilterSlope {
|
||||
#[default]
|
||||
Db12,
|
||||
Db24,
|
||||
Db48,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Debug, Default)]
|
||||
pub enum LfoShape {
|
||||
#[default]
|
||||
Sine,
|
||||
Tri,
|
||||
Saw,
|
||||
Square,
|
||||
Sh,
|
||||
}
|
||||
|
||||
impl FromStr for LfoShape {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"sine" | "sin" => Ok(Self::Sine),
|
||||
"tri" | "triangle" => Ok(Self::Tri),
|
||||
"saw" | "sawtooth" => Ok(Self::Saw),
|
||||
"square" | "sq" => Ok(Self::Square),
|
||||
"sh" | "sah" | "random" => Ok(Self::Sh),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Debug, Default)]
|
||||
pub enum DelayType {
|
||||
#[default]
|
||||
Standard,
|
||||
PingPong,
|
||||
Tape,
|
||||
Multitap,
|
||||
}
|
||||
|
||||
impl FromStr for DelayType {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"standard" | "std" | "0" => Ok(Self::Standard),
|
||||
"pingpong" | "pp" | "1" => Ok(Self::PingPong),
|
||||
"tape" | "2" => Ok(Self::Tape),
|
||||
"multitap" | "multi" | "3" => Ok(Self::Multitap),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for FilterSlope {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"12db" | "0" => Ok(Self::Db12),
|
||||
"24db" | "1" => Ok(Self::Db24),
|
||||
"48db" | "2" => Ok(Self::Db48),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
pub enum FilterType {
|
||||
Lowpass,
|
||||
Highpass,
|
||||
Bandpass,
|
||||
Notch,
|
||||
Allpass,
|
||||
Peaking,
|
||||
Lowshelf,
|
||||
Highshelf,
|
||||
}
|
||||
|
||||
pub fn midi2freq(note: f32) -> f32 {
|
||||
2.0_f32.powf((note - 69.0) / 12.0) * 440.0
|
||||
}
|
||||
|
||||
pub fn freq2midi(freq: f32) -> f32 {
|
||||
let safe_freq = freq.max(0.001);
|
||||
69.0 + 12.0 * (safe_freq / 440.0).log2()
|
||||
}
|
||||
462
src/voice/mod.rs
Normal file
462
src/voice/mod.rs
Normal file
@@ -0,0 +1,462 @@
|
||||
//! Voice - the core synthesis unit.
|
||||
|
||||
mod params;
|
||||
mod source;
|
||||
|
||||
pub use params::VoiceParams;
|
||||
|
||||
use std::f32::consts::PI;
|
||||
|
||||
use crate::effects::{crush, distort, fold, wrap, Chorus, Coarse, Flanger, Lag, Phaser};
|
||||
use crate::envelope::Adsr;
|
||||
use crate::fastmath::{cosf, exp2f, sinf};
|
||||
use crate::filter::FilterState;
|
||||
use crate::noise::{BrownNoise, PinkNoise};
|
||||
use crate::oscillator::Phasor;
|
||||
use crate::plaits::PlaitsEngine;
|
||||
use crate::sample::{FileSource, SampleInfo, WebSampleSource};
|
||||
use crate::types::{FilterSlope, FilterType, BLOCK_SIZE, CHANNELS};
|
||||
|
||||
fn apply_filter(
|
||||
signal: f32,
|
||||
filter: &mut FilterState,
|
||||
ftype: FilterType,
|
||||
q: f32,
|
||||
num_stages: usize,
|
||||
sr: f32,
|
||||
) -> f32 {
|
||||
let mut out = signal;
|
||||
for stage in 0..num_stages {
|
||||
out = filter.biquads[stage].process(out, ftype, filter.cutoff, q, sr);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub struct Voice {
|
||||
pub params: VoiceParams,
|
||||
pub phasor: Phasor,
|
||||
pub spread_phasors: [Phasor; 7],
|
||||
pub adsr: Adsr,
|
||||
pub lp_adsr: Adsr,
|
||||
pub hp_adsr: Adsr,
|
||||
pub bp_adsr: Adsr,
|
||||
pub lp: FilterState,
|
||||
pub hp: FilterState,
|
||||
pub bp: FilterState,
|
||||
// Modulation
|
||||
pub pitch_adsr: Adsr,
|
||||
pub fm_adsr: Adsr,
|
||||
pub vib_lfo: Phasor,
|
||||
pub fm_phasor: Phasor,
|
||||
pub am_lfo: Phasor,
|
||||
pub rm_lfo: Phasor,
|
||||
pub glide_lag: Lag,
|
||||
pub current_freq: f32,
|
||||
// Noise
|
||||
pub pink_noise: PinkNoise,
|
||||
pub brown_noise: BrownNoise,
|
||||
// Sample playback (native)
|
||||
pub file_source: Option<FileSource>,
|
||||
// Sample playback (web)
|
||||
pub web_sample: Option<WebSampleSource>,
|
||||
// Effects
|
||||
pub phaser: Phaser,
|
||||
pub flanger: Flanger,
|
||||
pub chorus: Chorus,
|
||||
pub coarse: Coarse,
|
||||
|
||||
pub time: f32,
|
||||
pub ch: [f32; CHANNELS],
|
||||
pub spread_side: f32,
|
||||
pub sr: f32,
|
||||
pub lag_unit: f32,
|
||||
pub(super) seed: u32,
|
||||
|
||||
// Plaits engines
|
||||
pub(super) plaits_engine: Option<PlaitsEngine>,
|
||||
pub(super) plaits_out: [f32; BLOCK_SIZE],
|
||||
pub(super) plaits_aux: [f32; BLOCK_SIZE],
|
||||
pub(super) plaits_idx: usize,
|
||||
pub(super) plaits_prev_gate: bool,
|
||||
}
|
||||
|
||||
impl Default for Voice {
|
||||
fn default() -> Self {
|
||||
let sr = 44100.0;
|
||||
Self {
|
||||
params: VoiceParams::default(),
|
||||
phasor: Phasor::default(),
|
||||
spread_phasors: std::array::from_fn(|i| {
|
||||
let mut p = Phasor::default();
|
||||
p.phase = i as f32 / 7.0;
|
||||
p
|
||||
}),
|
||||
adsr: Adsr::default(),
|
||||
lp_adsr: Adsr::default(),
|
||||
hp_adsr: Adsr::default(),
|
||||
bp_adsr: Adsr::default(),
|
||||
lp: FilterState::default(),
|
||||
hp: FilterState::default(),
|
||||
bp: FilterState::default(),
|
||||
pitch_adsr: Adsr::default(),
|
||||
fm_adsr: Adsr::default(),
|
||||
vib_lfo: Phasor::default(),
|
||||
fm_phasor: Phasor::default(),
|
||||
am_lfo: Phasor::default(),
|
||||
rm_lfo: Phasor::default(),
|
||||
glide_lag: Lag::default(),
|
||||
current_freq: 330.0,
|
||||
pink_noise: PinkNoise::default(),
|
||||
brown_noise: BrownNoise::default(),
|
||||
file_source: None,
|
||||
web_sample: None,
|
||||
phaser: Phaser::default(),
|
||||
flanger: Flanger::default(),
|
||||
chorus: Chorus::default(),
|
||||
coarse: Coarse::default(),
|
||||
time: 0.0,
|
||||
ch: [0.0; CHANNELS],
|
||||
spread_side: 0.0,
|
||||
sr,
|
||||
lag_unit: sr / 10.0,
|
||||
seed: 123456789,
|
||||
plaits_engine: None,
|
||||
plaits_out: [0.0; BLOCK_SIZE],
|
||||
plaits_aux: [0.0; BLOCK_SIZE],
|
||||
plaits_idx: BLOCK_SIZE,
|
||||
plaits_prev_gate: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for Voice {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
params: self.params,
|
||||
phasor: self.phasor,
|
||||
spread_phasors: self.spread_phasors,
|
||||
adsr: self.adsr,
|
||||
lp_adsr: self.lp_adsr,
|
||||
hp_adsr: self.hp_adsr,
|
||||
bp_adsr: self.bp_adsr,
|
||||
lp: self.lp,
|
||||
hp: self.hp,
|
||||
bp: self.bp,
|
||||
pitch_adsr: self.pitch_adsr,
|
||||
fm_adsr: self.fm_adsr,
|
||||
vib_lfo: self.vib_lfo,
|
||||
fm_phasor: self.fm_phasor,
|
||||
am_lfo: self.am_lfo,
|
||||
rm_lfo: self.rm_lfo,
|
||||
glide_lag: self.glide_lag,
|
||||
current_freq: self.current_freq,
|
||||
pink_noise: self.pink_noise,
|
||||
brown_noise: self.brown_noise,
|
||||
file_source: self.file_source,
|
||||
web_sample: self.web_sample,
|
||||
phaser: self.phaser,
|
||||
flanger: self.flanger,
|
||||
chorus: self.chorus,
|
||||
coarse: self.coarse,
|
||||
time: self.time,
|
||||
ch: self.ch,
|
||||
spread_side: self.spread_side,
|
||||
sr: self.sr,
|
||||
lag_unit: self.lag_unit,
|
||||
seed: self.seed,
|
||||
plaits_engine: None,
|
||||
plaits_out: [0.0; BLOCK_SIZE],
|
||||
plaits_aux: [0.0; BLOCK_SIZE],
|
||||
plaits_idx: BLOCK_SIZE,
|
||||
plaits_prev_gate: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Voice {
|
||||
pub(super) fn rand(&mut self) -> f32 {
|
||||
self.seed = self.seed.wrapping_mul(1103515245).wrapping_add(12345);
|
||||
((self.seed >> 16) & 0x7fff) as f32 / 32767.0
|
||||
}
|
||||
|
||||
pub(super) fn white(&mut self) -> f32 {
|
||||
self.rand() * 2.0 - 1.0
|
||||
}
|
||||
|
||||
fn compute_freq(&mut self, isr: f32) -> f32 {
|
||||
let mut freq = self.params.freq;
|
||||
|
||||
// Detune (cents offset)
|
||||
if self.params.detune != 0.0 {
|
||||
freq *= exp2f(self.params.detune / 1200.0);
|
||||
}
|
||||
|
||||
// Speed multiplier
|
||||
freq *= self.params.speed;
|
||||
|
||||
// Glide
|
||||
if let Some(glide_time) = self.params.glide {
|
||||
freq = self.glide_lag.update(freq, glide_time, self.lag_unit);
|
||||
}
|
||||
|
||||
// FM synthesis
|
||||
if self.params.fm > 0.0 {
|
||||
let mut fm_amount = self.params.fm;
|
||||
if self.params.fm_env_active {
|
||||
let env = self.fm_adsr.update(
|
||||
self.time,
|
||||
self.params.gate,
|
||||
self.params.fma,
|
||||
self.params.fmd,
|
||||
self.params.fms,
|
||||
self.params.fmr,
|
||||
);
|
||||
fm_amount = self.params.fme * env * fm_amount + fm_amount;
|
||||
}
|
||||
let mod_freq = freq * self.params.fmh;
|
||||
let mod_gain = mod_freq * fm_amount;
|
||||
let modulator = self.fm_phasor.lfo(self.params.fmshape, mod_freq, isr);
|
||||
freq += modulator * mod_gain;
|
||||
}
|
||||
|
||||
// Pitch envelope
|
||||
if self.params.pitch_env_active && self.params.penv != 0.0 {
|
||||
let env = self.pitch_adsr.update(
|
||||
self.time,
|
||||
1.0,
|
||||
self.params.patt,
|
||||
self.params.pdec,
|
||||
self.params.psus,
|
||||
self.params.prel,
|
||||
);
|
||||
let env_adj = if self.params.psus == 1.0 {
|
||||
env - 1.0
|
||||
} else {
|
||||
env
|
||||
};
|
||||
freq *= exp2f(env_adj * self.params.penv / 12.0);
|
||||
}
|
||||
|
||||
// Vibrato
|
||||
if self.params.vib > 0.0 && self.params.vibmod > 0.0 {
|
||||
let mod_val = self.vib_lfo.lfo(self.params.vibshape, self.params.vib, isr);
|
||||
freq *= exp2f(mod_val * self.params.vibmod / 12.0);
|
||||
}
|
||||
|
||||
self.current_freq = freq;
|
||||
freq
|
||||
}
|
||||
|
||||
fn num_stages(&self) -> usize {
|
||||
match self.params.ftype {
|
||||
FilterSlope::Db12 => 1,
|
||||
FilterSlope::Db24 => 2,
|
||||
FilterSlope::Db48 => 4,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn process(
|
||||
&mut self,
|
||||
isr: f32,
|
||||
pool: &[f32],
|
||||
samples: &[SampleInfo],
|
||||
web_pcm: &[f32],
|
||||
sample_idx: usize,
|
||||
live_input: &[f32],
|
||||
) -> bool {
|
||||
let env = self.adsr.update(
|
||||
self.time,
|
||||
self.params.gate,
|
||||
self.params.attack,
|
||||
self.params.decay,
|
||||
self.params.sustain,
|
||||
self.params.release,
|
||||
);
|
||||
if self.adsr.is_off() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let freq = self.compute_freq(isr);
|
||||
if !self.run_source(freq, isr, pool, samples, web_pcm, sample_idx, live_input) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update filter envelopes
|
||||
if let Some(lpf) = self.params.lpf {
|
||||
self.lp.cutoff = lpf;
|
||||
if self.params.lp_env_active {
|
||||
let lp_env = self.lp_adsr.update(
|
||||
self.time,
|
||||
self.params.gate,
|
||||
self.params.lpa,
|
||||
self.params.lpd,
|
||||
self.params.lps,
|
||||
self.params.lpr,
|
||||
);
|
||||
self.lp.cutoff = self.params.lpe * lp_env * lpf + lpf;
|
||||
}
|
||||
}
|
||||
if let Some(hpf) = self.params.hpf {
|
||||
self.hp.cutoff = hpf;
|
||||
if self.params.hp_env_active {
|
||||
let hp_env = self.hp_adsr.update(
|
||||
self.time,
|
||||
self.params.gate,
|
||||
self.params.hpa,
|
||||
self.params.hpd,
|
||||
self.params.hps,
|
||||
self.params.hpr,
|
||||
);
|
||||
self.hp.cutoff = self.params.hpe * hp_env * hpf + hpf;
|
||||
}
|
||||
}
|
||||
if let Some(bpf) = self.params.bpf {
|
||||
self.bp.cutoff = bpf;
|
||||
if self.params.bp_env_active {
|
||||
let bp_env = self.bp_adsr.update(
|
||||
self.time,
|
||||
self.params.gate,
|
||||
self.params.bpa,
|
||||
self.params.bpd,
|
||||
self.params.bps,
|
||||
self.params.bpr,
|
||||
);
|
||||
self.bp.cutoff = self.params.bpe * bp_env * bpf + bpf;
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-filter gain
|
||||
self.ch[0] *= self.params.gain * self.params.velocity;
|
||||
|
||||
// Apply filters (LP -> HP -> BP)
|
||||
let num_stages = self.num_stages();
|
||||
if self.params.lpf.is_some() {
|
||||
self.ch[0] = apply_filter(
|
||||
self.ch[0],
|
||||
&mut self.lp,
|
||||
FilterType::Lowpass,
|
||||
self.params.lpq,
|
||||
num_stages,
|
||||
self.sr,
|
||||
);
|
||||
}
|
||||
if self.params.hpf.is_some() {
|
||||
self.ch[0] = apply_filter(
|
||||
self.ch[0],
|
||||
&mut self.hp,
|
||||
FilterType::Highpass,
|
||||
self.params.hpq,
|
||||
num_stages,
|
||||
self.sr,
|
||||
);
|
||||
}
|
||||
if self.params.bpf.is_some() {
|
||||
self.ch[0] = apply_filter(
|
||||
self.ch[0],
|
||||
&mut self.bp,
|
||||
FilterType::Bandpass,
|
||||
self.params.bpq,
|
||||
num_stages,
|
||||
self.sr,
|
||||
);
|
||||
}
|
||||
|
||||
// Distortion effects
|
||||
if let Some(coarse_factor) = self.params.coarse {
|
||||
self.ch[0] = self.coarse.process(self.ch[0], coarse_factor);
|
||||
}
|
||||
if let Some(crush_bits) = self.params.crush {
|
||||
self.ch[0] = crush(self.ch[0], crush_bits);
|
||||
}
|
||||
if let Some(fold_amount) = self.params.fold {
|
||||
self.ch[0] = fold(self.ch[0], fold_amount);
|
||||
}
|
||||
if let Some(wrap_amount) = self.params.wrap {
|
||||
self.ch[0] = wrap(self.ch[0], wrap_amount);
|
||||
}
|
||||
if let Some(dist_amount) = self.params.distort {
|
||||
self.ch[0] = distort(self.ch[0], dist_amount, self.params.distortvol);
|
||||
}
|
||||
|
||||
// AM modulation
|
||||
if self.params.am > 0.0 {
|
||||
let modulator = self.am_lfo.lfo(self.params.amshape, self.params.am, isr);
|
||||
let depth = self.params.amdepth.clamp(0.0, 1.0);
|
||||
self.ch[0] *= 1.0 + modulator * depth;
|
||||
}
|
||||
|
||||
// Ring modulation
|
||||
if self.params.rm > 0.0 {
|
||||
let modulator = self.rm_lfo.lfo(self.params.rmshape, self.params.rm, isr);
|
||||
let depth = self.params.rmdepth.clamp(0.0, 1.0);
|
||||
self.ch[0] *= (1.0 - depth) + modulator * depth;
|
||||
}
|
||||
|
||||
// Phaser
|
||||
if self.params.phaser > 0.0 {
|
||||
self.ch[0] = self.phaser.process(
|
||||
self.ch[0],
|
||||
self.params.phaser,
|
||||
self.params.phaserdepth,
|
||||
self.params.phasercenter,
|
||||
self.params.phasersweep,
|
||||
self.sr,
|
||||
isr,
|
||||
);
|
||||
}
|
||||
|
||||
// Flanger
|
||||
if self.params.flanger > 0.0 {
|
||||
self.ch[0] = self.flanger.process(
|
||||
self.ch[0],
|
||||
self.params.flanger,
|
||||
self.params.flangerdepth,
|
||||
self.params.flangerfeedback,
|
||||
self.sr,
|
||||
isr,
|
||||
);
|
||||
}
|
||||
|
||||
// Apply gain envelope and postgain
|
||||
self.ch[0] *= env * self.params.postgain;
|
||||
|
||||
// Restore stereo for spread mode
|
||||
if self.params.spread > 0.0 {
|
||||
let side = self.spread_side * env * self.params.postgain;
|
||||
self.ch[1] = self.ch[0] - side;
|
||||
self.ch[0] += side;
|
||||
} else {
|
||||
self.ch[1] = self.ch[0];
|
||||
}
|
||||
|
||||
// Chorus
|
||||
if self.params.chorus > 0.0 {
|
||||
let stereo = self.chorus.process(
|
||||
self.ch[0],
|
||||
self.ch[1],
|
||||
self.params.chorus,
|
||||
self.params.chorusdepth,
|
||||
self.params.chorusdelay,
|
||||
self.sr,
|
||||
isr,
|
||||
);
|
||||
self.ch[0] = stereo[0];
|
||||
self.ch[1] = stereo[1];
|
||||
}
|
||||
|
||||
// Panning
|
||||
if self.params.pan != 0.5 {
|
||||
let pan_pos = self.params.pan * PI / 2.0;
|
||||
self.ch[0] *= cosf(pan_pos);
|
||||
self.ch[1] *= sinf(pan_pos);
|
||||
}
|
||||
|
||||
self.time += isr;
|
||||
if let Some(dur) = self.params.duration {
|
||||
if dur > 0.0 && self.time > dur {
|
||||
self.params.gate = 0.0;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
405
src/voice/params.rs
Normal file
405
src/voice/params.rs
Normal file
@@ -0,0 +1,405 @@
|
||||
//! Voice parameters - pure data structure for synthesis configuration.
|
||||
//!
|
||||
//! This module contains [`VoiceParams`], which holds all parameters that control
|
||||
//! a single voice's sound. Parameters are grouped by function:
|
||||
//!
|
||||
//! - **Core** - frequency, gain, panning, gate
|
||||
//! - **Oscillator** - sound source, pulse width, spread, waveshaping
|
||||
//! - **Amplitude Envelope** - ADSR for volume
|
||||
//! - **Filters** - lowpass, highpass, bandpass with optional envelopes
|
||||
//! - **Pitch Modulation** - glide, pitch envelope, vibrato, FM
|
||||
//! - **Amplitude Modulation** - AM, ring modulation
|
||||
//! - **Effects** - phaser, flanger, chorus, distortion
|
||||
//! - **Routing** - orbit assignment, effect sends
|
||||
|
||||
use crate::oscillator::PhaseShape;
|
||||
use crate::types::{DelayType, FilterSlope, LfoShape, Source};
|
||||
|
||||
/// All parameters that control a voice's sound generation.
|
||||
///
|
||||
/// This is a pure data structure with no methods beyond [`Default`].
|
||||
/// The actual signal processing happens in [`Voice`](super::Voice).
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct VoiceParams {
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Core
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
/// Base frequency in Hz.
|
||||
pub freq: f32,
|
||||
/// Pitch offset in cents (1/100th of a semitone).
|
||||
pub detune: f32,
|
||||
/// Playback speed multiplier (also affects pitch for samples).
|
||||
pub speed: f32,
|
||||
/// Pre-filter gain (0.0 to 1.0+).
|
||||
pub gain: f32,
|
||||
/// MIDI velocity (0.0 to 1.0), multiplied with gain.
|
||||
pub velocity: f32,
|
||||
/// Post-envelope gain (0.0 to 1.0+).
|
||||
pub postgain: f32,
|
||||
/// Stereo pan position (0.0 = left, 0.5 = center, 1.0 = right).
|
||||
pub pan: f32,
|
||||
/// Gate signal (> 0.0 = note on, 0.0 = note off).
|
||||
pub gate: f32,
|
||||
/// Optional note duration in seconds. Voice releases when exceeded.
|
||||
pub duration: Option<f32>,
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Oscillator
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
/// Sound source type (oscillator waveform, sample, or Plaits engine).
|
||||
pub sound: Source,
|
||||
/// Pulse width for pulse/square waves (0.0 to 1.0).
|
||||
pub pw: f32,
|
||||
/// Unison spread amount in cents. Enables 7-voice supersaw when > 0.
|
||||
pub spread: f32,
|
||||
/// Phase shaping parameters for waveform modification.
|
||||
pub shape: PhaseShape,
|
||||
/// Harmonics control for Plaits engines (0.0 to 1.0).
|
||||
pub harmonics: f32,
|
||||
/// Timbre control for Plaits engines (0.0 to 1.0).
|
||||
pub timbre: f32,
|
||||
/// Morph control for Plaits engines (0.0 to 1.0).
|
||||
pub morph: f32,
|
||||
/// Sample slice/cut index for sample playback.
|
||||
pub cut: Option<usize>,
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Amplitude Envelope (ADSR)
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
/// Attack time in seconds.
|
||||
pub attack: f32,
|
||||
/// Decay time in seconds.
|
||||
pub decay: f32,
|
||||
/// Sustain level (0.0 to 1.0).
|
||||
pub sustain: f32,
|
||||
/// Release time in seconds.
|
||||
pub release: f32,
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Lowpass Filter
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
/// Lowpass cutoff frequency in Hz. `None` = filter bypassed.
|
||||
pub lpf: Option<f32>,
|
||||
/// Lowpass resonance/Q (0.0 to 1.0).
|
||||
pub lpq: f32,
|
||||
/// Lowpass envelope depth multiplier.
|
||||
pub lpe: f32,
|
||||
/// Lowpass envelope attack time.
|
||||
pub lpa: f32,
|
||||
/// Lowpass envelope decay time.
|
||||
pub lpd: f32,
|
||||
/// Lowpass envelope sustain level.
|
||||
pub lps: f32,
|
||||
/// Lowpass envelope release time.
|
||||
pub lpr: f32,
|
||||
/// Enable lowpass filter envelope modulation.
|
||||
pub lp_env_active: bool,
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Highpass Filter
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
/// Highpass cutoff frequency in Hz. `None` = filter bypassed.
|
||||
pub hpf: Option<f32>,
|
||||
/// Highpass resonance/Q (0.0 to 1.0).
|
||||
pub hpq: f32,
|
||||
/// Highpass envelope depth multiplier.
|
||||
pub hpe: f32,
|
||||
/// Highpass envelope attack time.
|
||||
pub hpa: f32,
|
||||
/// Highpass envelope decay time.
|
||||
pub hpd: f32,
|
||||
/// Highpass envelope sustain level.
|
||||
pub hps: f32,
|
||||
/// Highpass envelope release time.
|
||||
pub hpr: f32,
|
||||
/// Enable highpass filter envelope modulation.
|
||||
pub hp_env_active: bool,
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Bandpass Filter
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
/// Bandpass center frequency in Hz. `None` = filter bypassed.
|
||||
pub bpf: Option<f32>,
|
||||
/// Bandpass resonance/Q (0.0 to 1.0).
|
||||
pub bpq: f32,
|
||||
/// Bandpass envelope depth multiplier.
|
||||
pub bpe: f32,
|
||||
/// Bandpass envelope attack time.
|
||||
pub bpa: f32,
|
||||
/// Bandpass envelope decay time.
|
||||
pub bpd: f32,
|
||||
/// Bandpass envelope sustain level.
|
||||
pub bps: f32,
|
||||
/// Bandpass envelope release time.
|
||||
pub bpr: f32,
|
||||
/// Enable bandpass filter envelope modulation.
|
||||
pub bp_env_active: bool,
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Filter Slope
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
/// Filter slope (12/24/48 dB per octave) for all filters.
|
||||
pub ftype: FilterSlope,
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Glide (Portamento)
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
/// Glide time in seconds. `None` = no glide.
|
||||
pub glide: Option<f32>,
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Pitch Envelope
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
/// Pitch envelope depth in semitones.
|
||||
pub penv: f32,
|
||||
/// Pitch envelope attack time.
|
||||
pub patt: f32,
|
||||
/// Pitch envelope decay time.
|
||||
pub pdec: f32,
|
||||
/// Pitch envelope sustain level.
|
||||
pub psus: f32,
|
||||
/// Pitch envelope release time.
|
||||
pub prel: f32,
|
||||
/// Enable pitch envelope modulation.
|
||||
pub pitch_env_active: bool,
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Vibrato
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
/// Vibrato LFO rate in Hz.
|
||||
pub vib: f32,
|
||||
/// Vibrato depth in semitones.
|
||||
pub vibmod: f32,
|
||||
/// Vibrato LFO waveform.
|
||||
pub vibshape: LfoShape,
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// FM Synthesis
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
/// FM modulation index (depth).
|
||||
pub fm: f32,
|
||||
/// FM harmonic ratio (modulator freq = carrier freq * fmh).
|
||||
pub fmh: f32,
|
||||
/// FM modulator waveform.
|
||||
pub fmshape: LfoShape,
|
||||
/// FM envelope depth multiplier.
|
||||
pub fme: f32,
|
||||
/// FM envelope attack time.
|
||||
pub fma: f32,
|
||||
/// FM envelope decay time.
|
||||
pub fmd: f32,
|
||||
/// FM envelope sustain level.
|
||||
pub fms: f32,
|
||||
/// FM envelope release time.
|
||||
pub fmr: f32,
|
||||
/// Enable FM envelope modulation.
|
||||
pub fm_env_active: bool,
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Amplitude Modulation
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
/// AM LFO rate in Hz.
|
||||
pub am: f32,
|
||||
/// AM depth (0.0 to 1.0).
|
||||
pub amdepth: f32,
|
||||
/// AM LFO waveform.
|
||||
pub amshape: LfoShape,
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Ring Modulation
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
/// Ring modulator frequency in Hz.
|
||||
pub rm: f32,
|
||||
/// Ring modulation depth (0.0 to 1.0).
|
||||
pub rmdepth: f32,
|
||||
/// Ring modulator waveform.
|
||||
pub rmshape: LfoShape,
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Phaser
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
/// Phaser LFO rate in Hz. 0 = bypassed.
|
||||
pub phaser: f32,
|
||||
/// Phaser depth/feedback (0.0 to 1.0).
|
||||
pub phaserdepth: f32,
|
||||
/// Phaser sweep range in Hz.
|
||||
pub phasersweep: f32,
|
||||
/// Phaser center frequency in Hz.
|
||||
pub phasercenter: f32,
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Flanger
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
/// Flanger LFO rate in Hz. 0 = bypassed.
|
||||
pub flanger: f32,
|
||||
/// Flanger depth (0.0 to 1.0).
|
||||
pub flangerdepth: f32,
|
||||
/// Flanger feedback amount (0.0 to 1.0).
|
||||
pub flangerfeedback: f32,
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Chorus
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
/// Chorus LFO rate in Hz. 0 = bypassed.
|
||||
pub chorus: f32,
|
||||
/// Chorus depth/modulation amount (0.0 to 1.0).
|
||||
pub chorusdepth: f32,
|
||||
/// Chorus base delay time in ms.
|
||||
pub chorusdelay: f32,
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Distortion
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
/// Coarse sample rate reduction factor. `None` = bypassed.
|
||||
pub coarse: Option<f32>,
|
||||
/// Bit crush depth (bits). `None` = bypassed.
|
||||
pub crush: Option<f32>,
|
||||
/// Wavefolding amount. `None` = bypassed.
|
||||
pub fold: Option<f32>,
|
||||
/// Wavewrapping amount. `None` = bypassed.
|
||||
pub wrap: Option<f32>,
|
||||
/// Distortion/saturation amount. `None` = bypassed.
|
||||
pub distort: Option<f32>,
|
||||
/// Distortion output volume compensation.
|
||||
pub distortvol: f32,
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Routing / Sends
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
/// Orbit index for effect bus routing (0 to MAX_ORBITS-1).
|
||||
pub orbit: usize,
|
||||
/// Delay send level (0.0 to 1.0).
|
||||
pub delay: f32,
|
||||
/// Delay time in seconds (overrides orbit default).
|
||||
pub delaytime: f32,
|
||||
/// Delay feedback amount (overrides orbit default).
|
||||
pub delayfeedback: f32,
|
||||
/// Delay type (overrides orbit default).
|
||||
pub delaytype: DelayType,
|
||||
/// Reverb send level (0.0 to 1.0).
|
||||
pub verb: f32,
|
||||
/// Reverb decay time (overrides orbit default).
|
||||
pub verbdecay: f32,
|
||||
/// Reverb damping (overrides orbit default).
|
||||
pub verbdamp: f32,
|
||||
/// Reverb pre-delay in seconds.
|
||||
pub verbpredelay: f32,
|
||||
/// Reverb diffusion amount.
|
||||
pub verbdiff: f32,
|
||||
/// Comb filter send level (0.0 to 1.0).
|
||||
pub comb: f32,
|
||||
/// Comb filter frequency in Hz.
|
||||
pub combfreq: f32,
|
||||
/// Comb filter feedback amount.
|
||||
pub combfeedback: f32,
|
||||
/// Comb filter damping.
|
||||
pub combdamp: f32,
|
||||
}
|
||||
|
||||
impl Default for VoiceParams {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
freq: 330.0,
|
||||
detune: 0.0,
|
||||
speed: 1.0,
|
||||
gain: 1.0,
|
||||
velocity: 1.0,
|
||||
postgain: 1.0,
|
||||
pan: 0.5,
|
||||
gate: 1.0,
|
||||
duration: None,
|
||||
sound: Source::Tri,
|
||||
pw: 0.5,
|
||||
spread: 0.0,
|
||||
shape: PhaseShape::default(),
|
||||
harmonics: 0.5,
|
||||
timbre: 0.5,
|
||||
morph: 0.5,
|
||||
cut: None,
|
||||
attack: 0.001,
|
||||
decay: 0.0,
|
||||
sustain: 1.0,
|
||||
release: 0.005,
|
||||
lpf: None,
|
||||
lpq: 0.2,
|
||||
lpe: 1.0,
|
||||
lpa: 0.001,
|
||||
lpd: 0.0,
|
||||
lps: 1.0,
|
||||
lpr: 0.005,
|
||||
lp_env_active: false,
|
||||
hpf: None,
|
||||
hpq: 0.2,
|
||||
hpe: 1.0,
|
||||
hpa: 0.001,
|
||||
hpd: 0.0,
|
||||
hps: 1.0,
|
||||
hpr: 0.005,
|
||||
hp_env_active: false,
|
||||
bpf: None,
|
||||
bpq: 0.2,
|
||||
bpe: 1.0,
|
||||
bpa: 0.001,
|
||||
bpd: 0.0,
|
||||
bps: 1.0,
|
||||
bpr: 0.005,
|
||||
bp_env_active: false,
|
||||
ftype: FilterSlope::Db12,
|
||||
glide: None,
|
||||
penv: 1.0,
|
||||
patt: 0.001,
|
||||
pdec: 0.0,
|
||||
psus: 1.0,
|
||||
prel: 0.005,
|
||||
pitch_env_active: false,
|
||||
vib: 0.0,
|
||||
vibmod: 0.5,
|
||||
vibshape: LfoShape::Sine,
|
||||
fm: 0.0,
|
||||
fmh: 1.0,
|
||||
fmshape: LfoShape::Sine,
|
||||
fme: 1.0,
|
||||
fma: 0.001,
|
||||
fmd: 0.0,
|
||||
fms: 1.0,
|
||||
fmr: 0.005,
|
||||
fm_env_active: false,
|
||||
am: 0.0,
|
||||
amdepth: 0.5,
|
||||
amshape: LfoShape::Sine,
|
||||
rm: 0.0,
|
||||
rmdepth: 1.0,
|
||||
rmshape: LfoShape::Sine,
|
||||
phaser: 0.0,
|
||||
phaserdepth: 0.75,
|
||||
phasersweep: 2000.0,
|
||||
phasercenter: 1000.0,
|
||||
flanger: 0.0,
|
||||
flangerdepth: 0.5,
|
||||
flangerfeedback: 0.5,
|
||||
chorus: 0.0,
|
||||
chorusdepth: 0.5,
|
||||
chorusdelay: 25.0,
|
||||
coarse: None,
|
||||
crush: None,
|
||||
fold: None,
|
||||
wrap: None,
|
||||
distort: None,
|
||||
distortvol: 1.0,
|
||||
orbit: 0,
|
||||
delay: 0.0,
|
||||
delaytime: 0.333,
|
||||
delayfeedback: 0.6,
|
||||
delaytype: DelayType::Standard,
|
||||
verb: 0.0,
|
||||
verbdecay: 0.75,
|
||||
verbdamp: 0.95,
|
||||
verbpredelay: 0.1,
|
||||
verbdiff: 0.7,
|
||||
comb: 0.0,
|
||||
combfreq: 220.0,
|
||||
combfeedback: 0.9,
|
||||
combdamp: 0.1,
|
||||
}
|
||||
}
|
||||
}
|
||||
213
src/voice/source.rs
Normal file
213
src/voice/source.rs
Normal file
@@ -0,0 +1,213 @@
|
||||
//! Source generation - oscillators, samples, Plaits engines, spread mode.
|
||||
|
||||
use crate::fastmath::exp2f;
|
||||
use crate::oscillator::Phasor;
|
||||
use crate::plaits::PlaitsEngine;
|
||||
use crate::sample::SampleInfo;
|
||||
use crate::types::{freq2midi, Source, BLOCK_SIZE, CHANNELS};
|
||||
use mi_plaits_dsp::engine::{EngineParameters, TriggerState};
|
||||
|
||||
use super::Voice;
|
||||
|
||||
impl Voice {
|
||||
#[inline]
|
||||
pub(super) fn osc_at(&self, phasor: &Phasor, phase: f32) -> f32 {
|
||||
match self.params.sound {
|
||||
Source::Tri => phasor.tri_at(phase, &self.params.shape),
|
||||
Source::Sine => phasor.sine_at(phase, &self.params.shape),
|
||||
Source::Saw => phasor.saw_at(phase, &self.params.shape),
|
||||
Source::Zaw => phasor.zaw_at(phase, &self.params.shape),
|
||||
Source::Pulse => phasor.pulse_at(phase, self.params.pw, &self.params.shape),
|
||||
Source::Pulze => phasor.pulze_at(phase, self.params.pw, &self.params.shape),
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn run_source(
|
||||
&mut self,
|
||||
freq: f32,
|
||||
isr: f32,
|
||||
pool: &[f32],
|
||||
samples: &[SampleInfo],
|
||||
web_pcm: &[f32],
|
||||
sample_idx: usize,
|
||||
live_input: &[f32],
|
||||
) -> bool {
|
||||
match self.params.sound {
|
||||
Source::Sample => {
|
||||
if let Some(ref mut fs) = self.file_source {
|
||||
if let Some(info) = samples.get(fs.sample_idx) {
|
||||
if fs.is_done(info) {
|
||||
return false;
|
||||
}
|
||||
for c in 0..CHANNELS {
|
||||
self.ch[c] = fs.update(pool, info, self.params.speed, c) * 0.2;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
self.ch[0] = 0.0;
|
||||
self.ch[1] = 0.0;
|
||||
}
|
||||
Source::WebSample => {
|
||||
if let Some(ref mut ws) = self.web_sample {
|
||||
if ws.is_done() {
|
||||
return false;
|
||||
}
|
||||
for c in 0..CHANNELS {
|
||||
self.ch[c] = ws.update(web_pcm, self.params.speed, c) * 0.2;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
self.ch[0] = 0.0;
|
||||
self.ch[1] = 0.0;
|
||||
}
|
||||
Source::LiveInput => {
|
||||
let input_idx = sample_idx * CHANNELS;
|
||||
for c in 0..CHANNELS {
|
||||
let idx = input_idx + c;
|
||||
self.ch[c] = live_input.get(idx).copied().unwrap_or(0.0) * 0.2;
|
||||
}
|
||||
}
|
||||
Source::PlModal
|
||||
| Source::PlVa
|
||||
| Source::PlWs
|
||||
| Source::PlFm
|
||||
| Source::PlGrain
|
||||
| Source::PlAdd
|
||||
| Source::PlWt
|
||||
| Source::PlChord
|
||||
| Source::PlSwarm
|
||||
| Source::PlNoise
|
||||
| Source::PlBass
|
||||
| Source::PlSnare
|
||||
| Source::PlHat => {
|
||||
if self.plaits_idx >= BLOCK_SIZE {
|
||||
let need_new = self
|
||||
.plaits_engine
|
||||
.as_ref()
|
||||
.is_none_or(|e| e.source() != self.params.sound);
|
||||
if need_new {
|
||||
let sample_rate = 1.0 / isr;
|
||||
self.plaits_engine = Some(PlaitsEngine::new(self.params.sound, sample_rate));
|
||||
}
|
||||
let engine = self.plaits_engine.as_mut().unwrap();
|
||||
|
||||
let trigger = if self.params.sound.is_plaits_percussion() {
|
||||
TriggerState::Unpatched
|
||||
} else {
|
||||
let gate_high = self.params.gate > 0.5;
|
||||
let t = if gate_high && !self.plaits_prev_gate {
|
||||
TriggerState::RisingEdge
|
||||
} else if gate_high {
|
||||
TriggerState::High
|
||||
} else {
|
||||
TriggerState::Low
|
||||
};
|
||||
self.plaits_prev_gate = gate_high;
|
||||
t
|
||||
};
|
||||
|
||||
let params = EngineParameters {
|
||||
trigger,
|
||||
note: freq2midi(freq),
|
||||
timbre: self.params.timbre,
|
||||
morph: self.params.morph,
|
||||
harmonics: self.params.harmonics,
|
||||
accent: self.params.velocity,
|
||||
a0_normalized: 55.0 * isr,
|
||||
};
|
||||
|
||||
let mut already_enveloped = false;
|
||||
engine.render(
|
||||
¶ms,
|
||||
&mut self.plaits_out,
|
||||
&mut self.plaits_aux,
|
||||
&mut already_enveloped,
|
||||
);
|
||||
self.plaits_idx = 0;
|
||||
}
|
||||
|
||||
self.ch[0] = self.plaits_out[self.plaits_idx] * 0.2;
|
||||
self.ch[1] = self.ch[0];
|
||||
self.plaits_idx += 1;
|
||||
}
|
||||
_ => {
|
||||
let spread = self.params.spread;
|
||||
if spread > 0.0 {
|
||||
self.run_spread(freq, isr);
|
||||
} else {
|
||||
self.run_single_osc(freq, isr);
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn run_spread(&mut self, freq: f32, isr: f32) {
|
||||
let mut left = 0.0;
|
||||
let mut right = 0.0;
|
||||
const PAN: [f32; 3] = [0.3, 0.6, 0.9];
|
||||
|
||||
// Center oscillator
|
||||
let phase_c = self.spread_phasors[3].phase;
|
||||
let center = self.osc_at(&self.spread_phasors[3], phase_c);
|
||||
self.spread_phasors[3].phase = (phase_c + freq * isr) % 1.0;
|
||||
left += center;
|
||||
right += center;
|
||||
|
||||
// Symmetric pairs with parabolic detuning + stereo spread
|
||||
for i in 1..=3 {
|
||||
let detune_cents = (i * i) as f32 * self.params.spread;
|
||||
let ratio_up = exp2f(detune_cents / 1200.0);
|
||||
let ratio_down = exp2f(-detune_cents / 1200.0);
|
||||
|
||||
let phase_up = self.spread_phasors[3 + i].phase;
|
||||
let voice_up = self.osc_at(&self.spread_phasors[3 + i], phase_up);
|
||||
self.spread_phasors[3 + i].phase = (phase_up + freq * ratio_up * isr) % 1.0;
|
||||
|
||||
let phase_down = self.spread_phasors[3 - i].phase;
|
||||
let voice_down = self.osc_at(&self.spread_phasors[3 - i], phase_down);
|
||||
self.spread_phasors[3 - i].phase = (phase_down + freq * ratio_down * isr) % 1.0;
|
||||
|
||||
let pan = PAN[i - 1];
|
||||
left += voice_down * (0.5 + pan * 0.5) + voice_up * (0.5 - pan * 0.5);
|
||||
right += voice_up * (0.5 + pan * 0.5) + voice_down * (0.5 - pan * 0.5);
|
||||
}
|
||||
|
||||
// Store as mid/side - effects process mid, stereo restored later
|
||||
let mid = (left + right) / 2.0;
|
||||
let side = (left - right) / 2.0;
|
||||
self.ch[0] = mid / 4.0 * 0.2;
|
||||
self.spread_side = side / 4.0 * 0.2;
|
||||
}
|
||||
|
||||
fn run_single_osc(&mut self, freq: f32, isr: f32) {
|
||||
self.ch[0] = match self.params.sound {
|
||||
Source::Tri => self.phasor.tri_shaped(freq, isr, &self.params.shape) * 0.2,
|
||||
Source::Sine => self.phasor.sine_shaped(freq, isr, &self.params.shape) * 0.2,
|
||||
Source::Saw => self.phasor.saw_shaped(freq, isr, &self.params.shape) * 0.2,
|
||||
Source::Zaw => self.phasor.zaw_shaped(freq, isr, &self.params.shape) * 0.2,
|
||||
Source::Pulse => {
|
||||
self.phasor
|
||||
.pulse_shaped(freq, self.params.pw, isr, &self.params.shape)
|
||||
* 0.2
|
||||
}
|
||||
Source::Pulze => {
|
||||
self.phasor
|
||||
.pulze_shaped(freq, self.params.pw, isr, &self.params.shape)
|
||||
* 0.2
|
||||
}
|
||||
Source::White => self.white() * 0.2,
|
||||
Source::Pink => {
|
||||
let w = self.white();
|
||||
self.pink_noise.next(w) * 0.2
|
||||
}
|
||||
Source::Brown => {
|
||||
let w = self.white();
|
||||
self.brown_noise.next(w) * 0.2
|
||||
}
|
||||
_ => 0.0,
|
||||
};
|
||||
}
|
||||
}
|
||||
385
src/wasm.rs
Normal file
385
src/wasm.rs
Normal file
@@ -0,0 +1,385 @@
|
||||
//! WebAssembly FFI bindings for browser-based audio.
|
||||
//!
|
||||
//! Exposes the doux engine to JavaScript via a C-compatible interface. The host
|
||||
//! (browser) and WASM module communicate through shared memory buffers.
|
||||
//!
|
||||
//! # Memory Layout
|
||||
//!
|
||||
//! ```text
|
||||
//! ┌─────────────────────────────────────────────────────────────────────┐
|
||||
//! │ Static Buffers (shared with JS via pointers) │
|
||||
//! ├─────────────────┬───────────────────────────────────────────────────┤
|
||||
//! │ OUTPUT │ Audio output buffer (BLOCK_SIZE × CHANNELS f32) │
|
||||
//! │ INPUT_BUFFER │ Live audio input (BLOCK_SIZE × CHANNELS f32) │
|
||||
//! │ EVENT_INPUT │ Command strings from JS (1024 bytes, null-term) │
|
||||
//! │ SAMPLE_BUFFER │ Staging area for sample uploads (16MB of f32) │
|
||||
//! │ FRAMEBUFFER │ Ring buffer for waveform visualization │
|
||||
//! └─────────────────┴───────────────────────────────────────────────────┘
|
||||
//! ```
|
||||
//!
|
||||
//! # Typical Usage Flow
|
||||
//!
|
||||
//! ```text
|
||||
//! JS WASM
|
||||
//! ── ────
|
||||
//! 1. doux_init(sampleRate) → Create engine
|
||||
//! 2. get_*_pointer() → Get buffer addresses
|
||||
//! 3. Write command to EVENT_INPUT
|
||||
//! 4. evaluate() → Parse & execute command
|
||||
//! 5. [Optional] Write samples to SAMPLE_BUFFER
|
||||
//! 6. load_sample(len, ch, freq) → Add to pool
|
||||
//! 7. [Optional] Write mic input to INPUT_BUFFER
|
||||
//! 8. dsp() → Process one block
|
||||
//! 9. Read OUTPUT ← Get audio samples
|
||||
//! 10. Repeat 3-9 in audio callback
|
||||
//! ```
|
||||
//!
|
||||
//! # Audio Worklet Integration
|
||||
//!
|
||||
//! In the browser, this typically runs in an AudioWorkletProcessor:
|
||||
//! - `dsp()` is called each audio quantum (~128 samples)
|
||||
//! - Output buffer is copied to the worklet's output
|
||||
//! - Input buffer receives microphone data for live processing
|
||||
|
||||
#![allow(static_mut_refs)]
|
||||
|
||||
use crate::types::{Source, BLOCK_SIZE, CHANNELS};
|
||||
use crate::Engine;
|
||||
|
||||
/// Maximum length of command strings from JavaScript.
|
||||
const EVENT_INPUT_SIZE: usize = 1024;
|
||||
|
||||
/// Ring buffer size for waveform visualization (~60fps at 48kHz stereo).
|
||||
/// Calculation: floor(48000/60) × 2 channels × 4 (double-buffer headroom) = 6400
|
||||
const FRAMEBUFFER_SIZE: usize = 6400;
|
||||
|
||||
// Global engine instance (single-threaded WASM environment)
|
||||
static mut ENGINE: Option<Engine> = None;
|
||||
|
||||
// Shared memory buffers accessible from JavaScript
|
||||
static mut OUTPUT: [f32; BLOCK_SIZE * CHANNELS] = [0.0; BLOCK_SIZE * CHANNELS];
|
||||
static mut EVENT_INPUT: [u8; EVENT_INPUT_SIZE] = [0; EVENT_INPUT_SIZE];
|
||||
static mut FRAMEBUFFER: [f32; FRAMEBUFFER_SIZE] = [0.0; FRAMEBUFFER_SIZE];
|
||||
static mut FRAME_IDX: i32 = 0;
|
||||
|
||||
/// Sample upload staging buffer (16MB = 4M floats).
|
||||
/// JS decodes audio files and writes f32 samples here before calling `load_sample`.
|
||||
const SAMPLE_BUFFER_SIZE: usize = 4_194_304;
|
||||
static mut SAMPLE_BUFFER: [f32; SAMPLE_BUFFER_SIZE] = [0.0; SAMPLE_BUFFER_SIZE];
|
||||
|
||||
/// Live audio input buffer (microphone/line-in from Web Audio).
|
||||
static mut INPUT_BUFFER: [f32; BLOCK_SIZE * CHANNELS] = [0.0; BLOCK_SIZE * CHANNELS];
|
||||
|
||||
// =============================================================================
|
||||
// Lifecycle
|
||||
// =============================================================================
|
||||
|
||||
/// Initializes the audio engine at the given sample rate.
|
||||
///
|
||||
/// Must be called once before any other functions.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn doux_init(sample_rate: f32) {
|
||||
unsafe {
|
||||
ENGINE = Some(Engine::new(sample_rate));
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Audio Processing
|
||||
// =============================================================================
|
||||
|
||||
/// Processes one block of audio and updates the framebuffer.
|
||||
///
|
||||
/// Call this from the AudioWorklet's `process()` method. Reads from
|
||||
/// `INPUT_BUFFER`, writes to `OUTPUT`, and appends to `FRAMEBUFFER`.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn dsp() {
|
||||
unsafe {
|
||||
if let Some(ref mut engine) = ENGINE {
|
||||
engine.process_block(&mut OUTPUT, &SAMPLE_BUFFER, &INPUT_BUFFER);
|
||||
|
||||
// Copy to ring buffer for visualization
|
||||
let fb_len = FRAMEBUFFER.len() as i32;
|
||||
for (i, &sample) in OUTPUT.iter().enumerate() {
|
||||
let idx = (FRAME_IDX + i as i32) % fb_len;
|
||||
FRAMEBUFFER[idx as usize] = sample;
|
||||
}
|
||||
FRAME_IDX = (FRAME_IDX + (BLOCK_SIZE * CHANNELS) as i32) % fb_len;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Command Interface
|
||||
// =============================================================================
|
||||
|
||||
/// Parses and executes the command string in `EVENT_INPUT`.
|
||||
///
|
||||
/// The command should be written as a null-terminated UTF-8 string to the
|
||||
/// buffer returned by `get_event_input_pointer()`.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// - Sample index if the command triggered a sample load
|
||||
/// - `-1` on error or for commands that don't return a value
|
||||
#[no_mangle]
|
||||
pub extern "C" fn evaluate() -> i32 {
|
||||
unsafe {
|
||||
if let Some(ref mut engine) = ENGINE {
|
||||
let len = EVENT_INPUT
|
||||
.iter()
|
||||
.position(|&b| b == 0)
|
||||
.unwrap_or(EVENT_INPUT_SIZE);
|
||||
if len == 0 {
|
||||
return -1;
|
||||
}
|
||||
if let Ok(s) = core::str::from_utf8(&EVENT_INPUT[..len]) {
|
||||
let result = engine.evaluate(s).map(|i| i as i32).unwrap_or(-1);
|
||||
EVENT_INPUT[0] = 0; // Clear for next command
|
||||
return result;
|
||||
}
|
||||
}
|
||||
-1
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Buffer Pointers (for JS interop)
|
||||
// =============================================================================
|
||||
|
||||
/// Returns pointer to the audio output buffer.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn get_output_pointer() -> *const f32 {
|
||||
unsafe { OUTPUT.as_ptr() }
|
||||
}
|
||||
|
||||
/// Returns the length of the output buffer in samples.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn get_output_len() -> usize {
|
||||
BLOCK_SIZE * CHANNELS
|
||||
}
|
||||
|
||||
/// Returns mutable pointer to the event input buffer.
|
||||
///
|
||||
/// Write null-terminated UTF-8 command strings here, then call `evaluate()`.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn get_event_input_pointer() -> *mut u8 {
|
||||
unsafe { EVENT_INPUT.as_mut_ptr() }
|
||||
}
|
||||
|
||||
/// Returns mutable pointer to the sample upload staging buffer.
|
||||
///
|
||||
/// Write decoded f32 samples here, then call `load_sample()`.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn get_sample_buffer_pointer() -> *mut f32 {
|
||||
unsafe { SAMPLE_BUFFER.as_mut_ptr() }
|
||||
}
|
||||
|
||||
/// Returns the capacity of the sample buffer in floats.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn get_sample_buffer_len() -> usize {
|
||||
SAMPLE_BUFFER_SIZE
|
||||
}
|
||||
|
||||
/// Returns mutable pointer to the live audio input buffer.
|
||||
///
|
||||
/// Write microphone/line-in samples here before calling `dsp()`.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn get_input_buffer_pointer() -> *mut f32 {
|
||||
unsafe { INPUT_BUFFER.as_mut_ptr() }
|
||||
}
|
||||
|
||||
/// Returns the length of the input buffer in samples.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn get_input_buffer_len() -> usize {
|
||||
BLOCK_SIZE * CHANNELS
|
||||
}
|
||||
|
||||
/// Returns pointer to the waveform visualization ring buffer.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn get_framebuffer_pointer() -> *const f32 {
|
||||
unsafe { FRAMEBUFFER.as_ptr() }
|
||||
}
|
||||
|
||||
/// Returns pointer to the current frame index in the ring buffer.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn get_frame_pointer() -> *const i32 {
|
||||
unsafe { &FRAME_IDX as *const i32 }
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Sample Loading
|
||||
// =============================================================================
|
||||
|
||||
/// Loads sample data from the staging buffer into the engine's pool.
|
||||
///
|
||||
/// # Parameters
|
||||
///
|
||||
/// - `len`: Number of f32 samples in `SAMPLE_BUFFER`
|
||||
/// - `channels`: Channel count (1 = mono, 2 = stereo)
|
||||
/// - `freq`: Base frequency for pitch calculations
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Pool index on success, `-1` on failure.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn load_sample(len: usize, channels: u8, freq: f32) -> i32 {
|
||||
unsafe {
|
||||
if let Some(ref mut engine) = ENGINE {
|
||||
let samples = &SAMPLE_BUFFER[..len.min(SAMPLE_BUFFER_SIZE)];
|
||||
match engine.load_sample(samples, channels, freq) {
|
||||
Some(idx) => idx as i32,
|
||||
None => -1,
|
||||
}
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the number of samples loaded in the pool.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn get_sample_count() -> usize {
|
||||
unsafe {
|
||||
if let Some(ref engine) = ENGINE {
|
||||
engine.samples.len()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Engine State
|
||||
// =============================================================================
|
||||
|
||||
/// Returns the current engine time in seconds.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn get_time() -> f64 {
|
||||
unsafe {
|
||||
if let Some(ref engine) = ENGINE {
|
||||
engine.time
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the engine's sample rate.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn get_sample_rate() -> f32 {
|
||||
unsafe {
|
||||
if let Some(ref engine) = ENGINE {
|
||||
engine.sr
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the number of currently active voices.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn get_active_voices() -> usize {
|
||||
unsafe {
|
||||
if let Some(ref engine) = ENGINE {
|
||||
engine.active_voices
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fades out all active voices smoothly.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn hush() {
|
||||
unsafe {
|
||||
if let Some(ref mut engine) = ENGINE {
|
||||
engine.hush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Immediately silences all voices (may click).
|
||||
#[no_mangle]
|
||||
pub extern "C" fn panic() {
|
||||
unsafe {
|
||||
if let Some(ref mut engine) = ENGINE {
|
||||
engine.panic();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Debug Helpers
|
||||
// =============================================================================
|
||||
|
||||
/// Debug: reads a byte from the event input buffer.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn debug_event_input_byte(idx: usize) -> u8 {
|
||||
unsafe { EVENT_INPUT.get(idx).copied().unwrap_or(255) }
|
||||
}
|
||||
|
||||
/// Debug: returns the source type of a voice as an integer.
|
||||
///
|
||||
/// Mapping: Tri=0, Sine=1, Saw=2, ... LiveInput=11, PlModal=12, etc.
|
||||
/// Returns `-1` if voice index is invalid.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn debug_voice_source(voice_idx: usize) -> i32 {
|
||||
unsafe {
|
||||
if let Some(ref engine) = ENGINE {
|
||||
if voice_idx < engine.active_voices {
|
||||
match engine.voices[voice_idx].params.sound {
|
||||
Source::Tri => 0,
|
||||
Source::Sine => 1,
|
||||
Source::Saw => 2,
|
||||
Source::Zaw => 3,
|
||||
Source::Pulse => 4,
|
||||
Source::Pulze => 5,
|
||||
Source::White => 6,
|
||||
Source::Pink => 7,
|
||||
Source::Brown => 8,
|
||||
Source::Sample => 9,
|
||||
Source::WebSample => 10,
|
||||
Source::LiveInput => 11,
|
||||
Source::PlModal => 12,
|
||||
Source::PlVa => 13,
|
||||
Source::PlWs => 14,
|
||||
Source::PlFm => 15,
|
||||
Source::PlGrain => 16,
|
||||
Source::PlAdd => 17,
|
||||
Source::PlWt => 18,
|
||||
Source::PlChord => 19,
|
||||
Source::PlSwarm => 20,
|
||||
Source::PlNoise => 21,
|
||||
Source::PlBass => 22,
|
||||
Source::PlSnare => 23,
|
||||
Source::PlHat => 24,
|
||||
}
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Debug: returns 1 if voice has a web sample attached, 0 otherwise.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn debug_voice_has_web_sample(voice_idx: usize) -> i32 {
|
||||
unsafe {
|
||||
if let Some(ref engine) = ENGINE {
|
||||
if voice_idx < engine.active_voices {
|
||||
if engine.voices[voice_idx].web_sample.is_some() {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user