Initial commit

This commit is contained in:
2026-01-18 15:39:46 +01:00
commit 587f2bd7e7
106 changed files with 14918 additions and 0 deletions

141
src/audio.rs Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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(
&params,
&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
View 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
}
}
}