diff --git a/CLAUDE.md b/CLAUDE.md
index 08e36b7..c81356d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -7,7 +7,7 @@ TUI step sequencer / drum machine / synth in Rust. All DSP from scratch.
```bash
cargo build # dev build
cargo build --release
-cargo test # 23 tests, runs in <1s
+cargo test # 55 tests, runs in <1s
cargo run # launch TUI
```
@@ -46,7 +46,7 @@ Two-thread model: UI thread (ratatui + crossterm) and audio thread (cpal/CoreAud
- `drum_voice.rs` — per-track DSP (kick, snare, hats, etc.), params: tune/sweep/color/snap/filter/drive/decay/volume
- `synth_voice.rs` — polyphonic synth DSP, 2 oscillators, 2 envelopes, filter with own ADSR
- `mixer.rs` — channel mixing, send effects
-- `effects.rs` — reverb, delay, compressor, saturator
+- `effects.rs` — FDN reverb, delay, compressor, saturator, lookahead limiter, sidechain envelope, oversampler
- `display_buffer.rs` — lock-free audio→UI buffer for waveform display
- `fft.rs` — FFT for spectrum analyzer
diff --git a/Cargo.toml b/Cargo.toml
index 4c93f19..3060234 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "textstep"
-version = "1.3.0"
+version = "1.4.0"
edition = "2024"
[dependencies]
diff --git a/README.md b/README.md
index 13a995e..d7164cd 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,10 @@
-# T E X T S T E P
+# 🎛️ T E X T S T E P 🎹
**A terminal-based step sequencer, drum machine, and synthesizer built entirely in Rust.**
-All DSP from scratch — no samples, no external audio libraries. Just your terminal and your speakers.
+♪ All DSP from scratch — no samples, no external audio libraries. Just your terminal and your speakers. ♫
[](https://www.rust-lang.org/)
[](https://www.gnu.org/licenses/old-licenses/gpl-2.0.en.html)
@@ -12,8 +12,8 @@ All DSP from scratch — no samples, no external audio libraries. Just your term
[](https://www.kernel.org/)
[](https://developer.apple.com/documentation/coreaudio)
[](https://ratatui.rs/)
-[]()
-[]()
+[]()
+[]()

@@ -21,22 +21,22 @@ All DSP from scratch — no samples, no external audio libraries. Just your term
---
-## Features
+## ✨ Features
-- **8 Drum Tracks** — Kick, Snare, Closed HiHat, Open HiHat, Ride, Clap, Cowbell, Tom — each fully synthesized with 8 tweakable sound parameters
-- **Dual Polyphonic Synths** — Synth A + Synth B with DJ-style crossfader, each with 2 oscillators + sub, 2 ADSR envelopes, resonant filter, dual LFOs with 6 waveforms, independent patterns and kits
-- **32-Step Sequencer** — 10 patterns and 8 kit slots with per-pattern BPM and swing
-- **16 Scene Slots** — snapshot and recall full instrument states (all patterns, kits, BPM, swing) with queued or immediate switching
-- **Send Effects Chain** — Schroeder reverb, tempo-synced filtered delay, tube saturator, SSL-style glue compressor
-- **Live Performance** — drum pads, real-time recording, pattern queuing, scene-based arrangement
-- **Mouse Support** — click the grid, drag parameters Ableton-style, audition sounds from the activity bar
-- **Project System** — save/load `.tsp` files, standalone kit export, preset browser
-- **Collapsible Panels** — minimize/expand synth, drum knobs, and waveform sections; auto-adapts to small terminals
-- **Spectrum Analyzer** — real-time FFT spectrum display and VU meter with 90s Hi-Fi LED aesthetic
+- 🥁 **8 Drum Tracks** — Kick, Snare, Closed HiHat, Open HiHat, Ride, Clap, Cowbell, Tom — each fully synthesized with 8 tweakable sound parameters
+- 🎹 **Dual Polyphonic Synths** — Synth A + Synth B with DJ-style crossfader, each with 2 oscillators + sub, 2 ADSR envelopes, resonant filter, dual LFOs with 6 waveforms, independent patterns and kits
+- 🔲 **32-Step Sequencer** — 10 patterns and 8 kit slots with per-pattern BPM and swing
+- 🎬 **16 Scene Slots** — snapshot and recall full instrument states (all patterns, kits, BPM, swing) with queued or immediate switching
+- 🔊 **Pro Audio Engine** — FDN reverb (8-line stereo), tempo-synced delay, 2×-oversampled tube saturator, SSL-style glue compressor, lookahead limiter, sidechain compression (kick ducks synths)
+- 🎤 **Live Performance** — drum pads, real-time recording, pattern queuing, scene-based arrangement
+- 🖱️ **Mouse Support** — click the grid, drag parameters Ableton-style, audition sounds from the activity bar
+- 💾 **Project System** — save/load `.tsp` files, standalone kit export, preset browser
+- 📐 **Collapsible Panels** — minimize/expand synth, drum knobs, and waveform sections; auto-adapts to small terminals
+- 📊 **Spectrum Analyzer** — real-time FFT spectrum display and VU meter with 90s Hi-Fi LED aesthetic
-Ships with **10 demo scenes** ready to play: Acid Techno, Classic House, Deep House, Driving Techno, Lo-Fi Hip Hop, Trance, Drum & Bass, Electro Funk, Dub Techno, and Ambient — each with matched drum patterns, synth patterns, and kits.
+Ships with **10 demo scenes** ready to play: 🎵 Acid Techno, Classic House, Deep House, Driving Techno, Lo-Fi Hip Hop, Trance, Drum & Bass, Electro Funk, Dub Techno, and Ambient — each with matched drum patterns, synth patterns, and kits.
-## Getting Started
+## 🚀 Getting Started
### Prerequisites
@@ -66,7 +66,7 @@ sudo apt-get install libasound2-dev # Debian/Ubuntu
Run the tests:
```bash
-cargo test # 48 tests, runs in <1s
+cargo test # 55 tests, runs in <1s
```
### Pre-built Binaries
@@ -79,9 +79,9 @@ Pre-built binaries for macOS (ARM, x86_64, universal) and Linux (x86_64) are ava
xattr -d com.apple.quarantine ./textstep
```
-## Quick Manual
+## 🎚️ Quick Manual
-### Transport
+### ▶️ Transport
| Key | Action |
|-----|--------|
@@ -94,13 +94,14 @@ xattr -d com.apple.quarantine ./textstep
| `L` | Cycle loop length: 8 / 16 / 24 / 32 |
| `Shift+C` | Cycle compressor: Off / Light / Medium / Heavy / Max |
| `Shift+T` | Cycle tube saturator: Off / Light / Medium / Heavy / Max |
+| `Shift+D` | Cycle sidechain duck: Off / Light / Medium / Heavy / Max |
| `Shift+V` | Adjust master volume |
| `<` / `>` | Swing ±5% |
| `(` / `)` | Crossfader toward A / B |
| Click `A` / `B` | Mute/unmute Synth A / B |
| Click `C` | Reset crossfader to center |
-### Navigation
+### 🧭 Navigation
| Key | Action |
|-----|--------|
@@ -112,7 +113,7 @@ xattr -d com.apple.quarantine ./textstep
| `~` | Toggle spectrum analyzer / VU meter |
| `?` | Help overlay |
-### Sound Design
+### 🎛️ Sound Design
Each drum track has 8 parameters across three pages:
@@ -126,7 +127,7 @@ Tweak with `Shift+Up/Down` (adjust value) or `Alt+Up/Down` (adjust and audition
Mute (`Shift+M`) and Solo (`Shift+S`) are always accessible on any page.
-### Drum Pads
+### 🥁 Drum Pads
The bottom keyboard row triggers sounds live:
@@ -136,7 +137,7 @@ The bottom keyboard row triggers sounds live:
With record enabled and playback running, pad hits write steps at the playhead.
-### Patterns & Kits
+### 🔀 Patterns & Kits
**Patterns** — 10 slots per section (Synth A, Synth B, Drums), each with its own step data:
@@ -153,13 +154,13 @@ With record enabled and playback running, pad hits write steps at the playhead.
|-----|--------|
| `1` through `8` | Switch to kit slot |
-### Dual Synths
+### 🎹 Dual Synths
TextStep features two independent polyphonic synthesizers (**Synth A** and **Synth B**), each with its own patterns, kits, 2 oscillators + sub, noise, 2 ADSR envelopes, 24dB resonant filter, and LFO with 6 waveforms. Toggle all synth panels with `F2`. Each synth has its own pattern/kit selectors shown in the transport bar.
Synth notes are triggered with `z` `x` `c` `v` when a synth grid is focused, with `Up/Down` for pitch and `(` `)` for octave shifts.
-### Scenes
+### 🎬 Scenes
Scenes are snapshots of the full instrument state: which pattern and kit is selected for drums, Synth A, and Synth B, plus BPM and swing. Use them to arrange your track.
@@ -173,7 +174,7 @@ Scenes are snapshots of the full instrument state: which pattern and kit is sele
| `D` | Delete scene |
| `Esc` | Close browser |
-### File Operations
+### 💾 File Operations
| Key | Action |
|-----|--------|
@@ -189,7 +190,7 @@ Scenes are snapshots of the full instrument state: which pattern and kit is sele
Projects are stored as JSON in `~/Library/Application Support/textstep/projects/`.
-## Architecture
+## 🏗️ Architecture
```
┌──────────────────────────────────────────────────┐
@@ -219,14 +220,14 @@ Projects are stored as JSON in `~/Library/Application Support/textstep/projects/
Communication is lock-free via bounded crossbeam channels. The audio thread never blocks.
-### DSP — All From Scratch
+### 🔧 DSP — All From Scratch
Every sound is synthesized in real-time with no external DSP dependencies:
- **Drum voices** — TR-808/909-inspired kicks (sine + pitch envelope + resonant impulse), noise-blended snares, 6-oscillator metallic banks for hats and rides (Mutable Instruments Plaits-style inharmonic ratios), ring-modulated open hats, bandpass claps, detuned pulse cowbells, FM toms
-- **Synth voice** — dual oscillators, sub, noise, two ADSR envelopes, resonant SVF filter, 6-waveform LFO
-- **Effects** — Schroeder/Freeverb reverb (4 comb + 2 allpass), tempo-synced filtered delay, asymmetric tube saturator, feedforward RMS glue compressor with soft knee
-- **Primitives** — 1-pole HP/LP filters, state-variable filter, xorshift32 noise, tanh waveshaping
+- **Synth voice** — dual PolyBLEP anti-aliased oscillators, sub, noise, two ADSR envelopes, resonant SVF filter, 6-waveform LFO
+- **Effects** — 8-line FDN reverb with Householder feedback matrix (native stereo), tempo-synced filtered delay, 2×-oversampled asymmetric tube saturator, feedforward RMS glue compressor with soft knee, true-peak lookahead limiter, sidechain envelope follower
+- **Primitives** — 1-pole HP/LP filters, state-variable filter, xorshift32 noise, stereo decorrelation
### Source Map
@@ -238,7 +239,7 @@ Every sound is synthesized in real-time with no external DSP dependencies:
| `src/sequencer/` | Data: patterns, transport, project serialization |
| `src/presets/` | Preset browser: drum/synth sounds and patterns by genre |
-### Color Palette
+### 🎨 Color Palette
Hardware/synthwave aesthetic — all rendered with UTF-8 block characters on a dark background.
@@ -311,7 +312,7 @@ textStep/
└── LICENSE # GPL v2
```
-## Contributing
+## 🤝 Contributing
Contributions are welcome! Please ensure:
@@ -329,6 +330,8 @@ This project is licensed under the [GNU General Public License v2.0](LICENSE).
-**Built with Rust** · **All DSP from scratch** · **Zero audio dependencies**
+🦀 **Built with Rust** · 🔊 **All DSP from scratch** · 🎵 **Zero audio dependencies**
+
+*"Any sufficiently advanced terminal is indistinguishable from a drum machine."*
diff --git a/docs/superpowers/plans/2026-03-16-audio-quality-improvements.md b/docs/superpowers/plans/2026-03-16-audio-quality-improvements.md
new file mode 100644
index 0000000..e2806ed
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-16-audio-quality-improvements.md
@@ -0,0 +1,178 @@
+# Audio Quality Improvements Implementation Plan
+
+> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development to implement this plan.
+
+**Goal:** Add 5 professional audio DSP features: sidechain compression, anti-aliased oscillators, lookahead limiter, 2× oversampling, and FDN reverb.
+
+**Architecture:** Each DSP feature is a self-contained struct in `effects.rs` (or `synth_voice.rs` for PolyBLEP), wired into the audio callback in `engine.rs`. The FDN reverb replaces the drum bus Schroeder reverb only (synth reverbs stay lightweight). The lookahead limiter replaces the `soft_clip(tanh)` on master output.
+
+**Tech Stack:** Rust, all DSP from scratch (no external crates), cpal audio callback at 48kHz.
+
+---
+
+## Task 1: PolyBLEP Anti-Aliased Oscillators
+
+**Files:**
+- Modify: `src/audio/synth_voice.rs` — `Oscillator::tick()` method
+
+**What:** Add PolyBLEP (Polynomial Band-Limited Step) correction to saw and square waveforms. Sine and noise don't alias and need no changes.
+
+**Implementation:**
+
+Add a `poly_blep()` function:
+```rust
+/// PolyBLEP correction: smooths discontinuities in saw/square to reduce aliasing.
+/// t = current phase (0..1), dt = phase increment per sample.
+fn poly_blep(t: f64, dt: f64) -> f64 {
+ if t < dt {
+ let t = t / dt;
+ 2.0 * t - t * t - 1.0
+ } else if t > 1.0 - dt {
+ let t = (t - 1.0) / dt;
+ t * t + 2.0 * t + 1.0
+ } else {
+ 0.0
+ }
+}
+```
+
+In `Oscillator::tick()`, apply correction after computing raw waveform:
+- **Saw:** `raw_saw - poly_blep(phase, inc)` (subtract because saw has a falling edge at wrap)
+- **Square:** `raw_square + poly_blep(phase, inc) - poly_blep((phase + 1.0 - pw) % 1.0, inc)`
+
+**Test:** Add test that measures spectral energy above Nyquist/2 for saw wave at high frequency — PolyBLEP version should have less alias energy than naive version.
+
+---
+
+## Task 2: FDN Reverb (Drum Bus)
+
+**Files:**
+- Modify: `src/audio/effects.rs` — add `FdnReverb` struct
+- Modify: `src/audio/engine.rs` — replace `drum_reverb: ReverbEffect` with `drum_reverb: FdnReverb`
+
+**What:** 8-delay-line Feedback Delay Network with Hadamard mixing matrix. Keeps the existing early reflections tap system. Much denser and richer than the 4-comb Schroeder.
+
+**Delay line lengths** (prime, scaled to sample_rate/48000):
+`[1009, 1201, 1427, 1609, 1847, 2087, 2311, 2707]` (~21–56ms range)
+
+**Signal flow per sample:**
+1. Read all 8 delay outputs
+2. Apply per-line damping (one-pole LP)
+3. Mix via Hadamard matrix × feedback coefficient
+4. Add input to each line
+5. Write back to delay lines
+6. Output = sum of delay outputs × (1/√8)
+
+**Hadamard 8×8** (Householder reflection is simpler): `H[i][j] = if i==j { 0.75 } else { -0.25 }` — this is `I - 2/N * ones`.
+
+**Interface:** Same as ReverbEffect — `set_params(amount, damping)` and `tick(input) -> f32`. Also add `tick_stereo(input) -> (f32, f32)` using odd/even delay lines for L/R.
+
+**Test:** Verify decay, verify stereo decorrelation (L ≠ R after impulse).
+
+---
+
+## Task 3: 2× Oversampler
+
+**Files:**
+- Modify: `src/audio/effects.rs` — add `Oversampler2x` struct
+- Modify: `src/audio/effects.rs` — wrap `TubeSaturator::tick()` with oversampling
+- Modify: `src/audio/drum_voice.rs` — wrap `apply_drive()` with oversampling
+
+**What:** Process nonlinear stages at double sample rate to push aliasing products above audible range. Uses linear interpolation for upsampling, averaging for downsampling (simple, effective for 2×).
+
+```rust
+pub struct Oversampler2x {
+ prev_in: f32,
+}
+
+impl Oversampler2x {
+ pub fn new() -> Self { Self { prev_in: 0.0 } }
+
+ pub fn tick
f32>(&mut self, input: f32, mut f: F) -> f32 {
+ let mid = (self.prev_in + input) * 0.5;
+ self.prev_in = input;
+ let y0 = f(mid);
+ let y1 = f(input);
+ (y0 + y1) * 0.5
+ }
+}
+```
+
+**Integration:**
+- `TubeSaturator`: add `oversampler: Oversampler2x` field, use in `tick()`
+- `apply_drive()` in drum_voice.rs: add `Oversampler2x` field to each voice that uses drive
+
+**Test:** Process a loud high-frequency sine through saturator with/without oversampling, verify alias products are reduced.
+
+---
+
+## Task 4: Lookahead Limiter
+
+**Files:**
+- Modify: `src/audio/effects.rs` — add `LookaheadLimiter` struct
+- Modify: `src/audio/engine.rs` — replace `soft_clip()` calls with limiter
+
+**What:** True peak limiter with 1ms lookahead delay. Sees peaks before they arrive, applies smooth gain reduction to prevent clipping while preserving transient shape.
+
+```rust
+pub struct LookaheadLimiter {
+ buf_l: Vec,
+ buf_r: Vec,
+ pos: usize,
+ len: usize, // lookahead in samples (~48 at 48kHz)
+ threshold: f32, // 0.95 (leave headroom for DAC)
+ gain: f32, // current gain (1.0 = no reduction)
+ attack_coeff: f32, // fast attack (~0.1ms)
+ release_coeff: f32, // slow release (~50ms)
+}
+```
+
+**Signal flow:**
+1. Write incoming L/R to circular buffer
+2. Scan lookahead window for peak (max absolute value)
+3. If peak > threshold: target_gain = threshold / peak
+4. Smooth gain with attack/release envelope
+5. Read delayed L/R from buffer, apply gain
+6. Return limited L/R
+
+**Interface:** `tick_stereo(l: f32, r: f32) -> (f32, f32)`
+
+**Test:** Feed a sudden spike — verify output never exceeds threshold, verify quiet signals pass unmodified.
+
+---
+
+## Task 5: Sidechain Compression
+
+**Files:**
+- Modify: `src/audio/effects.rs` — add `SidechainEnvelope` struct
+- Modify: `src/audio/engine.rs` — wire kick detection → synth ducking
+
+**What:** When kick fires, duck synths by ~6dB with fast attack / medium release. Classic technique for kick/bass separation.
+
+```rust
+pub struct SidechainEnvelope {
+ level: f32, // current envelope level (0..1)
+ attack_coeff: f32, // fast attack (~1ms)
+ release_coeff: f32, // medium release (~80ms)
+}
+```
+
+**Signal flow in engine.rs:**
+1. After drum voice tick loop, detect kick amplitude: `let kick_level = self.drum_voices[0].tick()` (kick is track 0, already computed)
+2. Feed kick signal into `SidechainEnvelope`
+3. Compute duck gain: `1.0 - envelope * depth` (depth ~0.5 = 6dB duck)
+4. Apply duck gain to `synth_a_out` and `synth_b_out` before mixing
+
+**Test:** Verify that when envelope is triggered, gain < 1.0; when idle, gain = 1.0.
+
+---
+
+## Wiring Order
+
+1. PolyBLEP — self-contained in synth_voice.rs
+2. FDN Reverb — add struct, swap drum_reverb type in engine.rs
+3. Oversampler — add struct, integrate into TubeSaturator + drum voices
+4. Lookahead Limiter — add struct, replace soft_clip in engine.rs
+5. Sidechain — add struct, wire kick→synth ducking in engine.rs
+
+Each feature gets its own commit.
diff --git a/src/audio/drum_voice.rs b/src/audio/drum_voice.rs
index 0f04a24..7e1826e 100644
--- a/src/audio/drum_voice.rs
+++ b/src/audio/drum_voice.rs
@@ -340,8 +340,8 @@ impl DrumVoiceDsp for KickVoice {
self.click_svf.set_freq(click_filter_freq, 0.18, self.sr);
self.click_svf.reset();
- // Sub-oscillator: one octave below the body fundamental
- self.sub_freq = self.freq_base * 0.5;
+ // Sub-oscillator: a fourth below the body fundamental (~41 Hz at default)
+ self.sub_freq = self.freq_base * 0.75;
self.sub_phase = 0.0;
self.sub_env = 0.35; // subtle reinforcement, not overwhelming
// Decay tracks body but slightly shorter — support without boom
@@ -370,9 +370,11 @@ impl DrumVoiceDsp for KickVoice {
self.phase -= 1.0;
}
let sine = (self.phase * std::f64::consts::TAU).sin() as f32;
+ // 2nd harmonic for mid-range presence on smaller speakers
+ let harmonic2 = (self.phase * 2.0 * std::f64::consts::TAU).sin() as f32 * 0.25;
// Body LP filter + envelope
- let body = self.body_lp.tick(sine) * self.body_env;
+ let body = self.body_lp.tick(sine + harmonic2) * self.body_env;
self.body_env *= self.body_decay;
// ── Path B: click impulse ──
@@ -390,7 +392,7 @@ impl DrumVoiceDsp for KickVoice {
let click = self.click_svf.lp() * self.click_env * self.click_level;
self.click_env *= self.click_decay;
- // ── Path C: sub-oscillator (one octave below for chest-hitting low-end) ──
+ // ── Path C: sub-oscillator (a fourth below for chest-hitting low-end) ──
self.sub_phase += self.sub_freq as f64 / self.sr;
if self.sub_phase >= 1.0 {
@@ -404,8 +406,8 @@ impl DrumVoiceDsp for KickVoice {
let raw = body + click + sub;
let driven = apply_drive(raw, self.drive);
- // Deactivate when both envelopes are spent
- if self.body_env < 1e-6 && self.click_env < 1e-6 {
+ // Deactivate when all envelopes are spent
+ if self.body_env < 1e-6 && self.click_env < 1e-6 && self.sub_env < 1e-6 {
self.active = false;
}
diff --git a/src/audio/effects.rs b/src/audio/effects.rs
index d1c10bb..4957098 100644
--- a/src/audio/effects.rs
+++ b/src/audio/effects.rs
@@ -525,6 +525,7 @@ pub struct TubeSaturator {
lp_coeff: f32, // LP filter coefficient
dc_block: f32, // DC blocker state
sample_rate: f32,
+ oversampler: Oversampler2x, // 2× oversampling for clean harmonics
}
impl TubeSaturator {
@@ -537,6 +538,7 @@ impl TubeSaturator {
lp_coeff: 0.3,
dc_block: 0.0,
sample_rate,
+ oversampler: Oversampler2x::new(),
}
}
@@ -566,24 +568,16 @@ impl TubeSaturator {
let gain = 1.0 + self.drive * 1.5;
let x = (input + self.bias_smooth) * gain;
- // Asymmetric waveshaping — gentle curves that stay musical
- // Positive half: soft saturation via x / (1 + |x|) — gentler than tanh
- // Negative half: slightly more compressed via tanh
- // The asymmetry between them creates even harmonics
- let shaped = if x >= 0.0 {
- x / (1.0 + x.abs())
- } else {
- (x * 0.8).tanh() / (0.8_f32).tanh()
- };
-
- // Normalize back to roughly input level
- let normalized = shaped / (1.0_f32 / (1.0 + 1.0_f32)); // ≈ shaped * 2.0
- let _ = normalized; // using a simpler approach below
-
- // Simple gain compensation: at gain=2.5, tanh(2.5)≈0.99, so output ≈ 1.0
- // We want output ≈ input level, so divide by the transfer function at gain level
+ // Asymmetric waveshaping at 2× sample rate for clean harmonics
let ref_level = if gain > 1.0 { gain / (1.0 + gain) } else { 1.0 };
- let compensated = shaped / ref_level;
+ let compensated = self.oversampler.tick(x, |s| {
+ let shaped = if s >= 0.0 {
+ s / (1.0 + s.abs())
+ } else {
+ (s * 0.8).tanh() / (0.8_f32).tanh()
+ };
+ shaped / ref_level
+ });
// Output transformer: one-pole LP to roll off harsh upper harmonics
self.lp_state += self.lp_coeff * (compensated - self.lp_state);
@@ -633,6 +627,326 @@ fn linear_to_db(linear: f32) -> f32 {
20.0 * linear.log10()
}
+// ---------------------------------------------------------------------------
+// FdnReverb: 8-delay-line Feedback Delay Network
+// ---------------------------------------------------------------------------
+
+/// 8-delay-line Feedback Delay Network reverb.
+/// Much denser and richer than Schroeder: Householder feedback matrix preserves energy,
+/// prime-length delays prevent flutter echoes, per-line damping controls brightness.
+pub struct FdnReverb {
+ delays: Vec, // single flat buffer for all 8 delay lines
+ lengths: [usize; 8],
+ offsets: [usize; 8],
+ positions: [usize; 8],
+ damping_state: [f32; 8],
+ damping: f32,
+ feedback: f32,
+ wet: f32,
+ // Early reflections (same as before)
+ er_buf: Vec,
+ er_buf_size: usize,
+ er_pos: usize,
+ er_taps: [usize; 5],
+ er_gains: [f32; 5],
+ er_wet: f32,
+}
+
+// Prime delay lengths tuned for 48kHz (~21-56ms range)
+const FDN_BASE_LENGTHS: [usize; 8] = [1009, 1201, 1427, 1609, 1847, 2087, 2311, 2707];
+
+impl FdnReverb {
+ pub fn new(sample_rate: f64) -> Self {
+ let scale = sample_rate / 48000.0;
+ let mut lengths = [0usize; 8];
+ let mut offsets = [0usize; 8];
+ let mut offset = 0;
+ for i in 0..8 {
+ lengths[i] = (FDN_BASE_LENGTHS[i] as f64 * scale) as usize;
+ offsets[i] = offset;
+ offset += lengths[i];
+ }
+ let total = offset;
+
+ // Early reflections: 5 taps at 3ms, 7ms, 11ms, 17ms, 23ms
+ let er_delays_ms: [f64; 5] = [3.0, 7.0, 11.0, 17.0, 23.0];
+ let er_buf_size = (sample_rate * 0.03) as usize + 1;
+ let er_taps: [usize; 5] = [
+ (er_delays_ms[0] * 0.001 * sample_rate) as usize,
+ (er_delays_ms[1] * 0.001 * sample_rate) as usize,
+ (er_delays_ms[2] * 0.001 * sample_rate) as usize,
+ (er_delays_ms[3] * 0.001 * sample_rate) as usize,
+ (er_delays_ms[4] * 0.001 * sample_rate) as usize,
+ ];
+ let er_gains: [f32; 5] = [0.35, 0.25, 0.20, 0.15, 0.10];
+
+ Self {
+ delays: vec![0.0; total],
+ lengths,
+ offsets,
+ positions: [0; 8],
+ damping_state: [0.0; 8],
+ damping: 0.25,
+ feedback: 0.7,
+ wet: 0.3,
+ er_buf: vec![0.0; er_buf_size],
+ er_buf_size,
+ er_pos: 0,
+ er_taps,
+ er_gains,
+ er_wet: 0.3,
+ }
+ }
+
+ pub fn set_params(&mut self, amount: f32, damping: f32) {
+ self.feedback = (0.40 + amount * 0.45).min(0.85);
+ self.damping = damping;
+ self.wet = amount * 0.6;
+ self.er_wet = amount * 0.4;
+ }
+
+ /// Process one mono sample, return mono wet output.
+ pub fn tick(&mut self, input: f32) -> f32 {
+ // Early reflections
+ let mut er_sum = 0.0_f32;
+ for i in 0..5 {
+ let tap_pos = if self.er_pos >= self.er_taps[i] {
+ self.er_pos - self.er_taps[i]
+ } else {
+ self.er_buf_size - (self.er_taps[i] - self.er_pos)
+ };
+ er_sum += self.er_buf[tap_pos] * self.er_gains[i];
+ }
+ self.er_buf[self.er_pos] = input;
+ self.er_pos = (self.er_pos + 1) % self.er_buf_size;
+
+ // Read all 8 delay lines and apply damping
+ let mut read_vals = [0.0_f32; 8];
+ for i in 0..8 {
+ let pos = self.positions[i];
+ let raw = self.delays[self.offsets[i] + pos];
+ // One-pole LP damping
+ self.damping_state[i] = raw * (1.0 - self.damping) + self.damping_state[i] * self.damping;
+ read_vals[i] = self.damping_state[i];
+ }
+
+ // Householder feedback matrix: H = I - (2/N) * ones
+ // For N=8: diagonal = 0.75, off-diagonal = -0.25
+ // mixed[i] = 0.75 * read[i] - 0.25 * sum(read[j!=i])
+ // Equivalently: mixed[i] = read[i] - 0.25 * sum(all read)
+ let sum: f32 = read_vals.iter().sum();
+ let mut mixed = [0.0_f32; 8];
+ for i in 0..8 {
+ mixed[i] = read_vals[i] - 0.25 * sum;
+ }
+
+ // Write back: input + feedback * mixed
+ for i in 0..8 {
+ let pos = self.positions[i];
+ self.delays[self.offsets[i] + pos] = input + mixed[i] * self.feedback;
+ self.positions[i] = (pos + 1) % self.lengths[i];
+ }
+
+ // Output: sum of delay reads normalized
+ let out = sum * 0.125; // 1/8
+ out * self.wet + er_sum * self.er_wet
+ }
+
+ /// Stereo output: odd delay lines -> left, even -> right for decorrelation.
+ pub fn tick_stereo(&mut self, input: f32) -> (f32, f32) {
+ // Early reflections (shared mono)
+ let mut er_sum = 0.0_f32;
+ for i in 0..5 {
+ let tap_pos = if self.er_pos >= self.er_taps[i] {
+ self.er_pos - self.er_taps[i]
+ } else {
+ self.er_buf_size - (self.er_taps[i] - self.er_pos)
+ };
+ er_sum += self.er_buf[tap_pos] * self.er_gains[i];
+ }
+ self.er_buf[self.er_pos] = input;
+ self.er_pos = (self.er_pos + 1) % self.er_buf_size;
+
+ let mut read_vals = [0.0_f32; 8];
+ for i in 0..8 {
+ let pos = self.positions[i];
+ let raw = self.delays[self.offsets[i] + pos];
+ self.damping_state[i] = raw * (1.0 - self.damping) + self.damping_state[i] * self.damping;
+ read_vals[i] = self.damping_state[i];
+ }
+
+ let sum: f32 = read_vals.iter().sum();
+ let mut mixed = [0.0_f32; 8];
+ for i in 0..8 {
+ mixed[i] = read_vals[i] - 0.25 * sum;
+ }
+
+ for i in 0..8 {
+ let pos = self.positions[i];
+ self.delays[self.offsets[i] + pos] = input + mixed[i] * self.feedback;
+ self.positions[i] = (pos + 1) % self.lengths[i];
+ }
+
+ // Split odd/even for stereo
+ let left = (read_vals[0] + read_vals[2] + read_vals[4] + read_vals[6]) * 0.25;
+ let right = (read_vals[1] + read_vals[3] + read_vals[5] + read_vals[7]) * 0.25;
+ let er_l = er_sum * 0.6;
+ let er_r = er_sum * 0.4;
+
+ (left * self.wet + er_l * self.er_wet, right * self.wet + er_r * self.er_wet)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Oversampler2x: 2x oversampler for clean nonlinear processing
+// ---------------------------------------------------------------------------
+
+/// 2x oversampler for clean nonlinear processing.
+/// Processes saturation/distortion at double sample rate to reduce aliasing.
+pub struct Oversampler2x {
+ prev_in: f32,
+}
+
+impl Oversampler2x {
+ pub fn new() -> Self {
+ Self { prev_in: 0.0 }
+ }
+
+ /// Process one sample through a nonlinear function at 2x rate.
+ /// Uses linear interpolation for upsampling, averaging for downsampling.
+ #[inline]
+ pub fn tick f32>(&mut self, input: f32, mut f: F) -> f32 {
+ let mid = (self.prev_in + input) * 0.5;
+ self.prev_in = input;
+ let y0 = f(mid);
+ let y1 = f(input);
+ (y0 + y1) * 0.5
+ }
+}
+
+// ---------------------------------------------------------------------------
+// LookaheadLimiter: true peak limiter with lookahead delay
+// ---------------------------------------------------------------------------
+
+/// True peak limiter with lookahead delay.
+/// Sees peaks before they arrive and applies smooth gain reduction,
+/// preserving transient shape while preventing clipping.
+pub struct LookaheadLimiter {
+ buf_l: Vec,
+ buf_r: Vec,
+ pos: usize,
+ len: usize,
+ threshold: f32,
+ gain: f32,
+ attack_coeff: f32,
+ release_coeff: f32,
+}
+
+impl LookaheadLimiter {
+ pub fn new(sample_rate: f64) -> Self {
+ let len = (sample_rate * 0.001) as usize; // 1ms lookahead
+ let len = len.max(1);
+
+ // Attack: ~0.1ms (catches transients)
+ let attack_coeff = (-1.0 / (0.0001 * sample_rate)).exp() as f32;
+ // Release: ~50ms (smooth recovery)
+ let release_coeff = (-1.0 / (0.05 * sample_rate)).exp() as f32;
+
+ Self {
+ buf_l: vec![0.0; len],
+ buf_r: vec![0.0; len],
+ pos: 0,
+ len,
+ threshold: 0.95,
+ gain: 1.0,
+ attack_coeff,
+ release_coeff,
+ }
+ }
+
+ /// Process one stereo frame. Returns limited (l, r).
+ pub fn tick_stereo(&mut self, in_l: f32, in_r: f32) -> (f32, f32) {
+ // Find peak in lookahead window (scan full buffer)
+ let mut peak = 0.0_f32;
+ for i in 0..self.len {
+ peak = peak.max(self.buf_l[i].abs()).max(self.buf_r[i].abs());
+ }
+ // Also consider incoming samples
+ peak = peak.max(in_l.abs()).max(in_r.abs());
+
+ // Compute target gain
+ let target_gain = if peak > self.threshold {
+ self.threshold / peak
+ } else {
+ 1.0
+ };
+
+ // Smooth gain with separate attack/release
+ if target_gain < self.gain {
+ self.gain = self.attack_coeff * self.gain + (1.0 - self.attack_coeff) * target_gain;
+ } else {
+ self.gain = self.release_coeff * self.gain + (1.0 - self.release_coeff) * target_gain;
+ }
+
+ // Read delayed output
+ let out_l = self.buf_l[self.pos] * self.gain;
+ let out_r = self.buf_r[self.pos] * self.gain;
+
+ // Write new input
+ self.buf_l[self.pos] = in_l;
+ self.buf_r[self.pos] = in_r;
+ self.pos = (self.pos + 1) % self.len;
+
+ (out_l, out_r)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// SidechainEnvelope: envelope follower for sidechain compression
+// ---------------------------------------------------------------------------
+
+/// Envelope follower for sidechain compression.
+/// Tracks the amplitude of a trigger signal (e.g. kick drum) and produces
+/// a gain reduction signal for ducking other instruments.
+pub struct SidechainEnvelope {
+ level: f32,
+ attack_coeff: f32,
+ release_coeff: f32,
+}
+
+impl SidechainEnvelope {
+ pub fn new(sample_rate: f64) -> Self {
+ // Attack: ~1ms (fast catch of kick transient)
+ let attack_coeff = (-1.0 / (0.001 * sample_rate)).exp() as f32;
+ // Release: ~80ms (natural duck and recovery)
+ let release_coeff = (-1.0 / (0.08 * sample_rate)).exp() as f32;
+
+ Self {
+ level: 0.0,
+ attack_coeff,
+ release_coeff,
+ }
+ }
+
+ /// Feed a sidechain signal sample, returns current envelope level (0..~1).
+ #[inline]
+ pub fn tick(&mut self, input: f32) -> f32 {
+ let abs = input.abs();
+ if abs > self.level {
+ self.level = self.attack_coeff * self.level + (1.0 - self.attack_coeff) * abs;
+ } else {
+ self.level = self.release_coeff * self.level + (1.0 - self.release_coeff) * abs;
+ }
+ self.level
+ }
+
+ /// Compute the gain multiplier for ducking. depth = how much to duck (0.5 = ~6dB).
+ #[inline]
+ pub fn duck_gain(&self, depth: f32) -> f32 {
+ (1.0 - self.level * depth).max(0.0)
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -729,4 +1043,80 @@ mod tests {
assert!(v2 < v1);
}
+ #[test]
+ fn test_fdn_reverb_decays() {
+ let mut reverb = FdnReverb::new(48000.0);
+ reverb.set_params(1.0, 0.5);
+ reverb.tick(1.0);
+ let mut last = 1.0_f32;
+ for _ in 0..48000 {
+ last = reverb.tick(0.0);
+ }
+ assert!(last.abs() < 0.1, "FDN reverb should decay after 1s, got {}", last);
+ }
+
+ #[test]
+ fn test_fdn_reverb_stereo_differs() {
+ let mut reverb = FdnReverb::new(48000.0);
+ reverb.set_params(0.5, 0.3);
+ reverb.tick_stereo(1.0);
+ // After some samples, L and R should differ (decorrelation)
+ let mut l_differs_r = false;
+ for _ in 0..2000 {
+ let (l, r) = reverb.tick_stereo(0.0);
+ if (l - r).abs() > 0.001 {
+ l_differs_r = true;
+ break;
+ }
+ }
+ assert!(l_differs_r, "Stereo FDN should produce different L/R outputs");
+ }
+
+ #[test]
+ fn test_lookahead_limiter_clamps_peaks() {
+ let mut limiter = LookaheadLimiter::new(48000.0);
+ // Feed silence to fill buffer, then a loud spike
+ for _ in 0..100 {
+ limiter.tick_stereo(0.0, 0.0);
+ }
+ // Feed a spike
+ for _ in 0..50 {
+ limiter.tick_stereo(2.0, 2.0);
+ }
+ // Read output — should be limited
+ let mut max_out = 0.0_f32;
+ for _ in 0..200 {
+ let (l, r) = limiter.tick_stereo(0.0, 0.0);
+ max_out = max_out.max(l.abs()).max(r.abs());
+ }
+ assert!(max_out < 1.0, "Limiter should prevent output > threshold, got {}", max_out);
+ }
+
+ #[test]
+ fn test_sidechain_envelope_follows() {
+ let mut env = SidechainEnvelope::new(48000.0);
+ // Feed silence — level should be ~0
+ for _ in 0..1000 {
+ env.tick(0.0);
+ }
+ assert!(env.level < 0.01);
+ // Feed loud signal — level should rise
+ for _ in 0..100 {
+ env.tick(0.8);
+ }
+ assert!(env.level > 0.3, "Envelope should follow loud input, got {}", env.level);
+ // Duck gain should reduce
+ assert!(env.duck_gain(0.5) < 0.9);
+ }
+
+ #[test]
+ fn test_oversampler_passthrough() {
+ let mut os = Oversampler2x::new();
+ // Linear function should pass through unchanged
+ let out = os.tick(1.0, |x| x);
+ // First sample: mid = (0.0 + 1.0) * 0.5 = 0.5, output = (0.5 + 1.0) * 0.5 = 0.75
+ // (slight latency artifact from interpolation — expected)
+ assert!(out > 0.5 && out < 1.0);
+ }
+
}
diff --git a/src/audio/engine.rs b/src/audio/engine.rs
index 9f1c84d..0bfdd32 100644
--- a/src/audio/engine.rs
+++ b/src/audio/engine.rs
@@ -7,8 +7,8 @@ use crossbeam_channel::{Receiver, Sender};
use crate::audio::clock::SequencerClock;
use crate::audio::display_buffer::AudioDisplayBuffer;
use crate::audio::drum_voice::{create_drum_voices, DrumVoiceDsp};
-use crate::audio::effects::{DelayEffect, GlueCompressor, ReverbEffect, TubeSaturator};
-use crate::audio::mixer::{effective_mute, per_track_saturate, soft_clip};
+use crate::audio::effects::{DelayEffect, FdnReverb, GlueCompressor, LookaheadLimiter, ReverbEffect, SidechainEnvelope, TubeSaturator};
+use crate::audio::mixer::{effective_mute, per_track_saturate};
use crate::audio::synth_voice::SynthVoice;
use crate::messages::{AudioToUi, SynthId, UiToAudio};
use crate::params::EffectParams;
@@ -132,13 +132,17 @@ pub struct AudioEngine {
synth_b: SynthInstance,
// Send effects (drum bus)
- drum_reverb: ReverbEffect,
+ drum_reverb: FdnReverb,
drum_delay: DelayEffect,
compressor: GlueCompressor,
crush_compressor: GlueCompressor, // parallel "New York" compression
drum_saturator: TubeSaturator,
effect_params: EffectParams,
+ // Master bus
+ limiter: LookaheadLimiter,
+ sidechain: SidechainEnvelope,
+
// Display buffer (shared with UI)
display_buf: Arc,
peak_tracker: f32,
@@ -151,7 +155,7 @@ pub struct AudioEngine {
impl AudioEngine {
pub fn new(sample_rate: f64, rx: Receiver, tx: Sender, display_buf: Arc) -> Self {
let effect_params = EffectParams::default();
- let mut drum_reverb = ReverbEffect::new(sample_rate);
+ let mut drum_reverb = FdnReverb::new(sample_rate);
let mut drum_delay = DelayEffect::new();
drum_reverb.set_params(effect_params.reverb_amount, effect_params.reverb_damping);
drum_delay.set_params(
@@ -183,6 +187,8 @@ impl AudioEngine {
crush_compressor,
drum_saturator,
effect_params,
+ limiter: LookaheadLimiter::new(sample_rate),
+ sidechain: SidechainEnvelope::new(sample_rate),
display_buf,
peak_tracker: 0.0,
rx,
@@ -432,9 +438,14 @@ impl AudioEngine {
let mut drum_dry_mono: f32 = 0.0;
let mut reverb_send: f32 = 0.0;
let mut delay_send: f32 = 0.0;
+ let mut kick_sample: f32 = 0.0;
for track in 0..NUM_DRUM_TRACKS {
let sample = self.drum_voices[track].tick();
+ // Capture kick for sidechain (track 0, before mute check)
+ if track == 0 {
+ kick_sample = sample;
+ }
if !effective_mute(track, &muted, &soloed) {
let p = &self.drum_pattern.params[track];
let voiced = per_track_saturate(sample) * p.volume;
@@ -499,8 +510,7 @@ impl AudioEngine {
let mut synth_dry: f32 = 0.0;
if !self.synth_a.pattern.params.mute {
synth_dry = synth_sample;
- reverb_send += synth_sample * self.synth_a.pattern.params.send_reverb;
- delay_send += synth_sample * self.synth_a.pattern.params.send_delay;
+ // Synth uses its own reverb/delay instances — don't also feed drum bus
}
let sa_sat = self.synth_a.saturator.tick(synth_dry);
let sa_reverb = self.synth_a.reverb.tick(sa_sat * self.synth_a.pattern.params.send_reverb);
@@ -548,8 +558,7 @@ impl AudioEngine {
let mut synth_dry: f32 = 0.0;
if !self.synth_b.pattern.params.mute {
synth_dry = synth_sample;
- reverb_send += synth_sample * self.synth_b.pattern.params.send_reverb;
- delay_send += synth_sample * self.synth_b.pattern.params.send_delay;
+ // Synth uses its own reverb/delay instances — don't also feed drum bus
}
let sb_sat = self.synth_b.saturator.tick(synth_dry);
let sb_reverb = self.synth_b.reverb.tick(sb_sat * self.synth_b.pattern.params.send_reverb);
@@ -557,33 +566,54 @@ impl AudioEngine {
sb_sat + sb_reverb + sb_delay
};
- // Process send effects (drum bus)
- let reverb_out = self.drum_reverb.tick(reverb_send);
+ // Process send effects (drum bus) — FDN reverb with native stereo output
+ let (reverb_l, reverb_r) = self.drum_reverb.tick_stereo(reverb_send);
let delay_out = self.drum_delay.tick(delay_send);
+ // Sidechain: kick ducks synths for separation (depth from effect params)
+ self.sidechain.tick(kick_sample);
+ let duck = if self.effect_params.sidechain_amount > 0.001 {
+ self.sidechain.duck_gain(self.effect_params.sidechain_amount)
+ } else {
+ 1.0
+ };
+
// Apply crossfader gain: center (0.5) = both full, extremes fade one out
let xf = self.crossfader;
let gain_a = if xf <= 0.5 { 1.0 } else { 2.0 * (1.0 - xf) };
let gain_b = if xf >= 0.5 { 1.0 } else { 2.0 * xf };
- // Mix: per-instrument saturated signals + wet effects → headroom → master volume → compressor → clip
- // Both synths centered (mono to both channels)
- let mono_wet = synth_a_out * gain_a + synth_b_out * gain_b + reverb_out + delay_out;
- let mixed_l = (drum_sat_l + mono_wet) * 0.5 * self.master_volume;
- let mixed_r = (drum_sat_r + mono_wet) * 0.5 * self.master_volume;
+ // Stereo mix: synths panned slightly to opposite sides, ducked by kick
+ // Synth A slightly left (pan ~0.38), Synth B slightly right (pan ~0.62)
+ // Precomputed equal-power gains: cos/sin(0.38 * π/2) ≈ 0.826/0.564
+ let sa_scaled = synth_a_out * gain_a * duck;
+ let sb_scaled = synth_b_out * gain_b * duck;
+ let synth_l = sa_scaled * 0.826 + sb_scaled * 0.564;
+ let synth_r = sa_scaled * 0.564 + sb_scaled * 0.826;
+
+ // Stereo wet bus: FDN reverb is natively stereo, delay decorrelated via reverb spread
+ let wet_l = synth_l + reverb_l + delay_out;
+ let wet_r = synth_r + reverb_r + delay_out;
+
+ let mixed_l = (drum_sat_l + wet_l) * 0.7 * self.master_volume;
+ let mixed_r = (drum_sat_r + wet_r) * 0.7 * self.master_volume;
// Linked stereo compression with parallel "crush" bus
let mono = (mixed_l + mixed_r) * 0.5;
let compressed = self.compressor.tick(mono);
let comp_gain = if mono.abs() > 1e-10 { compressed / mono } else { 1.0 };
- // Parallel "crush" compression: heavily compressed copy blended at 30%
+ // Parallel "crush" compression: only active when compressor knob is engaged.
// Adds body and sustain without killing transients (New York compression)
let crush = self.crush_compressor.tick(mono);
+ let crush_blend = if self.effect_params.compressor_amount > 0.001 { 0.3 } else { 0.0 };
let crush_gain = if mono.abs() > 1e-10 { crush / mono } else { 1.0 };
- let parallel_gain = comp_gain + crush_gain * 0.3;
+ let parallel_gain = comp_gain + crush_gain * crush_blend;
- let out_l = soft_clip(mixed_l * parallel_gain);
- let out_r = soft_clip(mixed_r * parallel_gain);
+ // Lookahead limiter replaces crude tanh soft_clip — preserves transients
+ let (out_l, out_r) = self.limiter.tick_stereo(
+ mixed_l * parallel_gain,
+ mixed_r * parallel_gain,
+ );
frame[0] = out_l;
if frame.len() > 1 {
diff --git a/src/audio/mixer.rs b/src/audio/mixer.rs
index 6f110c1..a4cbdce 100644
--- a/src/audio/mixer.rs
+++ b/src/audio/mixer.rs
@@ -1,4 +1,4 @@
-// Summing mixer: mute/solo logic, soft clip
+// Summing mixer: mute/solo logic, soft clip, stereo decorrelation
/// Returns `true` if the given track should be effectively muted.
///
@@ -20,17 +20,45 @@ pub fn soft_clip(x: f32) -> f32 {
x.tanh()
}
-/// Gentle per-track saturation using cubic soft-clip.
-/// Adds subtle odd harmonics and tames peaks without killing transients.
-/// More transparent than tanh — preserves the first ~0.8 of dynamic range linearly.
+/// Per-track saturation: linear below ±1.0 to preserve drum transients,
+/// soft-limiting above ±1.0 to tame extreme peaks.
#[inline]
pub fn per_track_saturate(x: f32) -> f32 {
- if x > 1.0 {
- 2.0 / 3.0 + (x - 1.0) / (1.0 + (x - 1.0) * (x - 1.0))
- } else if x < -1.0 {
- -2.0 / 3.0 + (x + 1.0) / (1.0 + (x + 1.0) * (x + 1.0))
+ let abs_x = x.abs();
+ if abs_x <= 1.0 {
+ x
} else {
- x - (x * x * x) / 3.0
+ // Soft limit: asymptotically approaches ±2.0
+ let excess = abs_x - 1.0;
+ x.signum() * (1.0 + excess / (1.0 + excess))
+ }
+}
+
+/// Micro-delay for stereo decorrelation.
+/// Shifts one channel by a fraction of a millisecond to create spatial width
+/// from mono signals (reverb, delay returns). Mono-compatible at short delays.
+pub struct MicroDelay {
+ buf: Vec,
+ pos: usize,
+ len: usize,
+}
+
+impl MicroDelay {
+ pub fn new(delay_ms: f64, sample_rate: f64) -> Self {
+ let len = ((delay_ms * 0.001 * sample_rate) as usize).max(1);
+ Self {
+ buf: vec![0.0; len],
+ pos: 0,
+ len,
+ }
+ }
+
+ #[inline]
+ pub fn tick(&mut self, input: f32) -> f32 {
+ let out = self.buf[self.pos];
+ self.buf[self.pos] = input;
+ self.pos = (self.pos + 1) % self.len;
+ out
}
}
@@ -108,6 +136,19 @@ mod tests {
assert!((y - x).abs() < 0.02);
}
+ #[test]
+ fn test_per_track_saturate_linear_below_unity() {
+ // Below ±1.0, the saturator must be transparent to preserve drum transients
+ for &x in &[0.3_f32, 0.5, 0.7, 0.8, 0.95, -0.5, -0.8] {
+ let y = per_track_saturate(x);
+ assert!(
+ (y - x).abs() < 0.01,
+ "per_track_saturate({}) = {}, expected ~{} (must be linear below unity)",
+ x, y, x
+ );
+ }
+ }
+
#[test]
fn test_per_track_saturate_limits() {
let y = per_track_saturate(2.0);
diff --git a/src/audio/synth_voice.rs b/src/audio/synth_voice.rs
index f0ea39c..16542b8 100644
--- a/src/audio/synth_voice.rs
+++ b/src/audio/synth_voice.rs
@@ -85,8 +85,11 @@ impl OnePoleLP {
// ---------------------------------------------------------------------------
// PolyBLEP anti-aliasing correction for discontinuities
// ---------------------------------------------------------------------------
+
+/// PolyBLEP correction: smooths discontinuities in saw/square to reduce aliasing.
+/// t = current phase (0..1), dt = phase increment per sample.
#[inline]
-fn poly_blep(t: f32, dt: f32) -> f32 {
+fn poly_blep(t: f64, dt: f64) -> f64 {
if t < dt {
let t = t / dt;
2.0 * t - t * t - 1.0
@@ -134,10 +137,10 @@ impl Oscillator {
let pw = 0.05 + param * 0.9;
let mut out = if self.phase < pw { 1.0 } else { -1.0 };
// PolyBLEP correction at rising edge (phase=0) and falling edge (phase=pw)
- out += poly_blep(self.phase, inc);
+ out += poly_blep(self.phase as f64, inc as f64) as f32;
let mut t = self.phase - pw;
if t < 0.0 { t += 1.0; }
- out -= poly_blep(t, inc);
+ out -= poly_blep(t as f64, inc as f64) as f32;
self.phase += inc;
if self.phase >= 1.0 {
self.phase -= 1.0;
@@ -147,7 +150,7 @@ impl Oscillator {
Waveform::Saw => {
// Primary saw with PolyBLEP anti-aliasing
let mut saw1 = 2.0 * self.phase - 1.0;
- saw1 -= poly_blep(self.phase, inc);
+ saw1 -= poly_blep(self.phase as f64, inc as f64) as f32;
self.phase += inc;
if self.phase >= 1.0 {
self.phase -= 1.0;
@@ -160,7 +163,7 @@ impl Oscillator {
let detune = param * 0.02; // up to ~1 semitone
let inc2 = freq_hz * (1.0 + detune) / self.sample_rate;
let mut saw2 = 2.0 * self.phase2 - 1.0;
- saw2 -= poly_blep(self.phase2, inc2);
+ saw2 -= poly_blep(self.phase2 as f64, inc2 as f64) as f32;
self.phase2 += inc2;
if self.phase2 >= 1.0 {
self.phase2 -= 1.0;
@@ -503,3 +506,25 @@ impl SynthVoice {
output * params.volume
}
}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_poly_blep_reduces_aliasing() {
+ // A naive saw at high frequency has more energy in upper harmonics (aliasing)
+ // PolyBLEP should reduce this
+ let phase = 0.001; // just after transition
+ let dt = 0.01; // ~4800 Hz at 48kHz
+ let correction = poly_blep(phase, dt);
+ assert!(correction.abs() > 0.0, "PolyBLEP should correct near transitions");
+
+ // Far from transition, no correction
+ let correction_far = poly_blep(0.5, dt);
+ assert!(correction_far.abs() < 1e-10, "PolyBLEP should be zero far from transitions");
+ }
+}
diff --git a/src/keys.rs b/src/keys.rs
index 98178ed..775bf44 100644
--- a/src/keys.rs
+++ b/src/keys.rs
@@ -456,6 +456,37 @@ pub fn handle_key(app: &mut App, key: KeyEvent) {
return;
}
+ // Sidechain: Shift+D cycles duck depth (Off → Light → Medium → Heavy → Max → Off)
+ KeyCode::Char('D') => {
+ let cur = app.effect_params.sidechain_amount;
+ app.effect_params.sidechain_amount = if cur < 0.01 {
+ 0.25
+ } else if cur < 0.30 {
+ 0.50
+ } else if cur < 0.55 {
+ 0.75
+ } else if cur < 0.80 {
+ 1.0
+ } else {
+ 0.0
+ };
+ app.send_effect_params();
+ app.dirty = true;
+ let label = if app.effect_params.sidechain_amount < 0.01 {
+ "Off"
+ } else if app.effect_params.sidechain_amount < 0.30 {
+ "Light"
+ } else if app.effect_params.sidechain_amount < 0.55 {
+ "Medium"
+ } else if app.effect_params.sidechain_amount < 0.80 {
+ "Heavy"
+ } else {
+ "Max"
+ };
+ app.show_status(format!("Sidechain: {}", label));
+ return;
+ }
+
// Randomize current page params (Alt+R)
KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::ALT) => {
randomize_page_params(app);
diff --git a/src/params.rs b/src/params.rs
index 2c624d7..9843ce4 100644
--- a/src/params.rs
+++ b/src/params.rs
@@ -23,10 +23,14 @@ pub struct EffectParams {
pub drum_volume: f32, // 0.0-1.0: drum bus output volume
#[serde(default = "default_crossfader")]
pub crossfader: f32, // 0.0-1.0: synth A/B crossfader (0=A, 0.5=center, 1=B)
+ #[serde(default = "default_sidechain")]
+ pub sidechain_amount: f32, // 0.0-1.0: kick→synth sidechain duck depth (0=off)
}
+fn default_sidechain() -> f32 { 0.5 }
+
fn default_master_volume() -> f32 { 0.8 }
-fn default_drum_volume() -> f32 { 0.8 }
+fn default_drum_volume() -> f32 { 1.0 }
fn default_crossfader() -> f32 { 0.5 }
impl Default for EffectParams {
@@ -41,8 +45,9 @@ impl Default for EffectParams {
master_volume: 0.8,
drum_saturator_drive: 0.0,
synth_saturator_drive: 0.0,
- drum_volume: 0.8,
+ drum_volume: 1.0,
crossfader: 0.5,
+ sidechain_amount: 0.5,
}
}
}
diff --git a/src/ui/help_overlay.rs b/src/ui/help_overlay.rs
index 9dfefbc..219ed36 100644
--- a/src/ui/help_overlay.rs
+++ b/src/ui/help_overlay.rs
@@ -58,13 +58,13 @@ fn row3<'a>(
/// covering transport, navigation, editing, and pattern management shortcuts.
pub fn render_help(f: &mut Frame, area: Rect) {
let block = Block::default()
- .title(" Key Bindings (? to close) ")
+ .title(" 🎹 Key Bindings (? to close) ")
.title_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow));
let lines = vec![
- hdr("Transport", "Navigation", "Sound & Editing"),
+ hdr("▶ Transport", "🧭 Navigation", "🎛 Sound & Editing"),
row3("Space", "Play / Pause",
"Tab", "Next section",
"Shift+M", "Mute track"),
@@ -88,9 +88,12 @@ pub fn render_help(f: &mut Frame, area: Rect) {
"F2", "Toggle synths"),
row3("< / >", "Swing ±5%",
"~", "Spectrum/VU",
- "( / )", "Crossfader A/B"),
+ "Shift+D", "Sidechain"),
+ row3("( / )", "Crossfader A/B",
+ "", "",
+ "", ""),
Line::from(Span::raw("")),
- hdr("Patterns & Kits", "Synth A + B Grid", "File / Project"),
+ hdr("🔀 Patterns & Kits", "🎹 Synth A + B", "💾 File / Project"),
row3("q w e r t ..", "Pattern 1-10",
"Up / Down", "Pitch ±semitone",
"Ctrl+S", "Save project"),
@@ -103,20 +106,23 @@ pub fn render_help(f: &mut Frame, area: Rect) {
row3("1 2 3 .. 8", "Switch kit 1-8",
"z x c v ..", "Synth notes",
"Ctrl+K", "Save kit"),
+ row3("", "",
+ "", "",
+ "Ctrl+J", "Load kit"),
Line::from(Span::raw("")),
- hdr("Drum Pads", "Synth Knobs", "Dual Synth"),
+ hdr("🥁 Drum Pads", "🎬 Scenes (Ctrl+E)","📁 More"),
row3("z x c v b n m ,", "Trigger drums",
- "Arrows", "Navigate grid",
- "Tab to SA/SB", "Focus synth"),
+ "Return", "Queue scene",
+ "Ctrl+P", "Preset browser"),
row3("(rec + play)", "Write at play",
- "Shift+Up/Dn", "Adjust value",
- "Pat/Kit/Loop", "Per-synth"),
+ "!", "Immediate switch",
+ "Ctrl+L", "Pattern browser"),
+ row3("(rec + stop)", "Step-entry",
+ "S", "Save to slot",
+ "Ctrl+C/Q", "Quit"),
row3("", "",
- "Alt+Up/Dn", "Adjust+audition",
- "Click A/B", "Mute synth A/B"),
- row3("", "",
- "", "",
- "Click C", "Center crossfader"),
+ "R / D", "Rename / Delete",
+ "?", "This help"),
];
let paragraph = Paragraph::new(lines).block(block);
diff --git a/src/ui/transport_bar.rs b/src/ui/transport_bar.rs
index 56fc786..3e5b3f2 100644
--- a/src/ui/transport_bar.rs
+++ b/src/ui/transport_bar.rs
@@ -176,16 +176,20 @@ pub fn render_transport(f: &mut Frame, area: Rect, app: &App) {
let vol = app.effect_params.master_volume;
let comp = app.effect_params.compressor_amount;
let sat = app.effect_params.drum_saturator_drive;
+ let sc = app.effect_params.sidechain_amount;
let gauge_line = Line::from(vec![
Span::styled("VOL ", gauge_label_style),
gauge_spans(vol, 6, gauge_fill_style, gauge_empty_style),
- Span::raw(" "),
+ Span::raw(" "),
Span::styled("CMP ", gauge_label_style),
gauge_spans(comp, 4, gauge_fill_style, gauge_empty_style),
- Span::raw(" "),
+ Span::raw(" "),
Span::styled("SAT ", gauge_label_style),
gauge_spans(sat, 4, gauge_fill_style, gauge_empty_style),
+ Span::raw(" "),
+ Span::styled("SC ", gauge_label_style),
+ gauge_spans(sc, 4, gauge_fill_style, gauge_empty_style),
]);
let paragraph = Paragraph::new(vec![