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. ♫ [![Rust](https://img.shields.io/badge/Rust-1.70%2B-orange?logo=rust&logoColor=white)](https://www.rust-lang.org/) [![License: GPL v2](https://img.shields.io/badge/License-GPL_v2-blue.svg)](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 [![Platform: Linux](https://img.shields.io/badge/Platform-Linux-lightgrey?logo=linux&logoColor=white)](https://www.kernel.org/) [![Audio: CoreAudio / ALSA](https://img.shields.io/badge/Audio-CoreAudio_|_ALSA-green)](https://developer.apple.com/documentation/coreaudio) [![TUI: ratatui](https://img.shields.io/badge/TUI-ratatui-purple)](https://ratatui.rs/) -[![Lines of Code](https://img.shields.io/badge/Lines_of_Code-20k-informational)]() -[![Tests](https://img.shields.io/badge/Tests-48_passing-brightgreen)]() +[![Lines of Code](https://img.shields.io/badge/Lines_of_Code-17k-informational)]() +[![Tests](https://img.shields.io/badge/Tests-55_passing-brightgreen)]() ![TextStep Demo](assets/demo.gif) @@ -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![