Carry over in-progress DSP + waveform rework (working-tree state at migration)

Committed as-is from the working tree so the tree builds under the bumped
deps; this is uncommitted local work being migrated with the repo.
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-13 17:16:01 +02:00
parent caa66b88bb
commit 4af8ef88b2
4 changed files with 522 additions and 82 deletions
+104 -17
View File
@@ -21,7 +21,7 @@ use ratatui::{backend::CrosstermBackend, Terminal};
use crate::{
audio::{AudioCmd, AudioEngine, AudioStatus},
deck::{Deck, DeckId, PlayState},
dsp::DeckParams,
dsp::{self, DeckParams},
file_selector::FileSelector,
osc::{self, OscAppCmd},
tui,
@@ -33,6 +33,10 @@ pub struct App {
pub decks: Vec<Deck>,
pub focused_deck: usize,
pub file_selector: Option<FileSelector>,
/// The deck other decks sync against. Auto-elected as the first deck
/// to transition into Playing while no master is set; cleared and
/// re-elected when the current master stops.
pub master_deck: Option<DeckId>,
audio: Option<AudioEngine>,
/// Per-deck atomic params, shared with the audio engine's realtime callback.
@@ -60,6 +64,7 @@ impl App {
decks,
focused_deck: 0,
file_selector: None,
master_deck: None,
audio: None,
params: Vec::new(),
osc_rx,
@@ -166,8 +171,25 @@ impl App {
self.dirty = true;
}
AudioStatus::Bpm { deck, bpm } => {
self.decks[deck as usize].bpm = Some(bpm);
AudioStatus::BeatGrid { deck, beats, bpm } => {
let d = &mut self.decks[deck as usize];
d.bpm = Some(bpm);
d.beat_grid = beats.clone();
// Mirror the grid into the audio engine so the SyncPlay
// command handler can do bar-math without locking the deck.
let _ = self.audio.as_ref().unwrap().cmd_tx.send(
AudioCmd::SetBeatGrid { deck, beats, bpm }
);
self.dirty = true;
}
AudioStatus::SyncQueued { deck, .. } => {
self.decks[deck as usize].play_state = PlayState::WaitingForSync;
self.dirty = true;
}
AudioStatus::SyncFired { deck } => {
self.decks[deck as usize].play_state = PlayState::Playing;
self.dirty = true;
}
@@ -240,13 +262,42 @@ impl App {
fn sync_play_state_from_audio_cmd(&mut self, cmd: &AudioCmd) {
match cmd {
AudioCmd::Play(d) => self.decks[*d as usize].play_state = PlayState::Playing,
AudioCmd::Pause(d) => self.decks[*d as usize].play_state = PlayState::Paused,
AudioCmd::Stop(d) => self.decks[*d as usize].play_state = PlayState::Stopped,
AudioCmd::Play(d) => {
self.decks[*d as usize].play_state = PlayState::Playing;
self.maybe_elect_master(*d);
}
AudioCmd::Pause(d) => {
self.decks[*d as usize].play_state = PlayState::Paused;
self.maybe_release_master(*d);
}
AudioCmd::Stop(d) => {
self.decks[*d as usize].play_state = PlayState::Stopped;
self.maybe_release_master(*d);
}
AudioCmd::SyncPlay { deck, .. } => {
self.decks[*deck as usize].play_state = PlayState::WaitingForSync;
}
_ => {}
}
}
/// Promote `deck` to master if there isn't one already.
fn maybe_elect_master(&mut self, deck: DeckId) {
if self.master_deck.is_none() {
self.master_deck = Some(deck);
}
}
/// If the stopped/paused deck was the master, hand the title over to any
/// other deck that is still playing, or clear it.
fn maybe_release_master(&mut self, deck: DeckId) {
if self.master_deck != Some(deck) { return; }
let next = self.decks.iter()
.find(|d| d.id != deck && d.play_state == PlayState::Playing)
.map(|d| d.id);
self.master_deck = next;
}
// ─── Keyboard ─────────────────────────────────────────────────────────────
fn handle_key(&mut self, key: KeyEvent) -> Result<bool> {
@@ -268,7 +319,9 @@ impl App {
(KeyModifiers::SHIFT, KeyCode::Char('Z')) => { self.open_selector(1); self.dirty = true; }
(KeyModifiers::SHIFT, KeyCode::Char('X')) => { self.open_selector(3); self.dirty = true; }
(KeyModifiers::NONE, KeyCode::Char(' ')) => { self.toggle_play(); self.dirty = true; }
(KeyModifiers::NONE, KeyCode::Char(' ')) => { self.toggle_play(false); self.dirty = true; }
// Shift+Space bypasses auto-sync and starts immediately.
(KeyModifiers::SHIFT, KeyCode::Char(' ')) => { self.toggle_play(true); self.dirty = true; }
(KeyModifiers::NONE, KeyCode::Enter) => { self.stop_focused(); self.dirty = true; }
(KeyModifiers::NONE, KeyCode::Left) => { self.seek_relative(-0.01); self.dirty = true; }
(KeyModifiers::NONE, KeyCode::Right) => { self.seek_relative(0.01); self.dirty = true; }
@@ -285,8 +338,8 @@ impl App {
(KeyModifiers::NONE, KeyCode::Char('.')) => { self.nudge_tempo(0.01); self.dirty = true; }
(KeyModifiers::NONE, KeyCode::Char(',')) => { self.nudge_tempo(-0.01); self.dirty = true; }
(KeyModifiers::NONE, KeyCode::Char('=')) => { self.nudge_cutoff(500.0); self.dirty = true; }
(KeyModifiers::NONE, KeyCode::Char('-')) => { self.nudge_cutoff(-500.0); self.dirty = true; }
(KeyModifiers::NONE, KeyCode::Char('=')) => { self.nudge_cutoff(0.05); self.dirty = true; }
(KeyModifiers::NONE, KeyCode::Char('-')) => { self.nudge_cutoff(-0.05); self.dirty = true; }
(KeyModifiers::NONE, KeyCode::Char('p')) => { self.nudge_reverb(0.05); self.dirty = true; }
(KeyModifiers::NONE, KeyCode::Char('o')) => { self.nudge_reverb(-0.05); self.dirty = true; }
@@ -330,18 +383,47 @@ impl App {
}
}
fn toggle_play(&mut self) {
/// `force_immediate=true` (Shift+Space) bypasses the sync flow and starts
/// the focused deck immediately even when another deck is the master.
fn toggle_play(&mut self, force_immediate: bool) {
let deck = self.focused_id();
let d = &mut self.decks[self.focused_deck];
if !d.is_loaded() { return; }
match d.play_state {
if !self.decks[self.focused_deck].is_loaded() { return; }
match self.decks[self.focused_deck].play_state {
PlayState::Playing => {
d.play_state = PlayState::Paused;
self.decks[self.focused_deck].play_state = PlayState::Paused;
let _ = self.audio.as_ref().unwrap().cmd_tx.send(AudioCmd::Pause(deck));
self.maybe_release_master(deck);
}
PlayState::WaitingForSync => {
// Cancel the pending sync and go straight to pause-state so a
// subsequent Space starts fresh.
self.decks[self.focused_deck].play_state = PlayState::Stopped;
let _ = self.audio.as_ref().unwrap().cmd_tx.send(AudioCmd::Stop(deck));
}
_ => {
d.play_state = PlayState::Playing;
let should_sync = !force_immediate
&& match self.master_deck {
Some(m) if m != deck => {
!self.decks[m as usize].beat_grid.is_empty()
&& !self.decks[self.focused_deck].beat_grid.is_empty()
}
_ => false,
};
if should_sync {
let master = self.master_deck.unwrap();
self.decks[self.focused_deck].play_state = PlayState::WaitingForSync;
let _ = self.audio.as_ref().unwrap().cmd_tx.send(AudioCmd::SyncPlay {
deck,
master,
bars_to_wait: 1,
bass_kill_bars: 4,
});
} else {
self.decks[self.focused_deck].play_state = PlayState::Playing;
let _ = self.audio.as_ref().unwrap().cmd_tx.send(AudioCmd::Play(deck));
self.maybe_elect_master(deck);
}
}
}
}
@@ -350,6 +432,7 @@ impl App {
let deck = self.focused_id();
self.decks[self.focused_deck].play_state = PlayState::Stopped;
let _ = self.audio.as_ref().unwrap().cmd_tx.send(AudioCmd::Stop(deck));
self.maybe_release_master(deck);
}
fn seek_relative(&mut self, delta: f32) {
@@ -413,12 +496,16 @@ impl App {
fn nudge_cutoff(&mut self, delta: f32) {
let deck = self.focused_id();
let new_val = (self.decks[self.focused_deck].cutoff + delta).clamp(-20_000.0, 20_000.0);
let new_val = (self.decks[self.focused_deck].cutoff + delta)
.clamp(-dsp::CUTOFF_RANGE, dsp::CUTOFF_RANGE);
self.apply_cutoff(deck, new_val);
}
/// `value` is a *position* in `[-1, +1]` — see `DeckParams::cutoff`.
/// OSC controllers that previously sent Hz here will need to send a
/// normalized position now.
fn apply_cutoff(&mut self, deck: DeckId, value: f32) {
let value = value.clamp(-20_000.0, 20_000.0);
let value = value.clamp(-dsp::CUTOFF_RANGE, dsp::CUTOFF_RANGE);
self.decks[deck as usize].cutoff = value;
self.params[deck as usize].cutoff.store(value, Ordering::Relaxed);
}
+89 -2
View File
@@ -52,7 +52,10 @@ pub enum PlayState {
Stopped,
Playing,
Paused,
Cued,
/// Sync-queued: the deck is armed and will start at the master's next
/// bar boundary. UI only — the audio engine's `sync_pending` is the
/// source of truth.
WaitingForSync,
}
impl PlayState {
@@ -61,7 +64,7 @@ impl PlayState {
Self::Playing => "▶",
Self::Paused => "⏸",
Self::Stopped => "⏹",
Self::Cued => "⏮",
Self::WaitingForSync => "⏳",
}
}
}
@@ -99,6 +102,10 @@ pub struct Deck {
/// BPM estimate from the decode-time analyzer; `None` until detected.
pub bpm: Option<f32>,
/// Sample-frame positions of detected beats. Empty until decode finishes.
/// Treated as a 4/4 grid: beat 0 = downbeat of bar 0, etc.
pub beat_grid: Vec<u64>,
}
impl Deck {
@@ -122,6 +129,7 @@ impl Deck {
vu: (0.0, 0.0),
waveform_bands: Vec::new(),
bpm: None,
beat_grid: Vec::new(),
}
}
@@ -149,3 +157,82 @@ impl Deck {
(s / 60, s % 60)
}
}
/// Beats per bar in our automix grid (4/4 time).
pub const BEATS_PER_BAR: usize = 4;
/// Smallest sample position `≥ pos` that lands on a bar boundary derived
/// from `beats[0]` + every `BEATS_PER_BAR` beats. The first detected beat
/// is treated as beat 1 of bar 1 (no downbeat labelling from the detector).
///
/// `bars_ahead`: how many bars to wait. 1 = next bar.
///
/// Returns `None` if the track has fewer beats than the requested boundary.
pub fn next_bar_sample(beats: &[u64], pos: u64, bars_ahead: u32) -> Option<u64> {
if beats.is_empty() { return None; }
let bars_ahead = bars_ahead.max(1) as usize;
// Index of the first beat at or after `pos`.
let next_beat_idx = beats.partition_point(|&b| b < pos);
if next_beat_idx >= beats.len() { return None; }
// Round up to the next bar boundary (multiple of BEATS_PER_BAR), then
// skip `bars_ahead - 1` more bars.
let bar_idx = next_beat_idx.div_ceil(BEATS_PER_BAR) * BEATS_PER_BAR;
let target_idx = bar_idx + (bars_ahead - 1) * BEATS_PER_BAR;
beats.get(target_idx).copied()
}
#[cfg(test)]
mod tests {
use super::*;
/// Build a 32-beat grid at 120 BPM, sample_rate 44100. Each beat = 22050.
fn grid() -> Vec<u64> {
(0..32u64).map(|n| n * 22050).collect()
}
#[test]
fn next_bar_at_zero_returns_bar_one() {
let g = grid();
// pos = 0 → next beat is beat 0 → bar 0 boundary at beats[0]
assert_eq!(next_bar_sample(&g, 0, 1), Some(0));
}
#[test]
fn next_bar_from_mid_bar_jumps_to_next_bar() {
let g = grid();
// Halfway through bar 0 (between beats 1 and 2): pos in samples
let pos = 22050 + 5000;
// Next beat is index 2; rounded up to bar = 4; beats[4] = 88200
assert_eq!(next_bar_sample(&g, pos, 1), Some(4 * 22050));
}
#[test]
fn next_bar_at_downbeat_returns_that_downbeat() {
let g = grid();
// pos = bar 1 (beat 4) exactly → next beat idx 4, which IS a bar
assert_eq!(next_bar_sample(&g, 4 * 22050, 1), Some(4 * 22050));
}
#[test]
fn bars_ahead_skips_ahead() {
let g = grid();
// pos at beat 5 → next bar at beat 8; 2 bars ahead → beat 12
let pos = 5 * 22050;
assert_eq!(next_bar_sample(&g, pos, 2), Some(12 * 22050));
}
#[test]
fn empty_grid_returns_none() {
let g: Vec<u64> = vec![];
assert_eq!(next_bar_sample(&g, 0, 1), None);
}
#[test]
fn past_end_returns_none() {
let g = grid();
// pos past last beat
assert_eq!(next_bar_sample(&g, 100 * 22050, 1), None);
}
}
+269 -44
View File
@@ -14,34 +14,70 @@
//! block. No `Mutex`, no `pw-cli`.
use std::sync::Arc;
use std::sync::atomic::AtomicU64;
use atomic_float::AtomicF32;
use biquad::{
Biquad as _, Coefficients, DirectForm1, Hertz, Q_BUTTERWORTH_F32, Type,
};
use biquad::{Biquad as _, Coefficients, DirectForm1};
use ssstretch::Stretch;
/// Butterworth Q (≈ 0.7071). Local copy — `biquad::Q_BUTTERWORTH_F32` exists but
/// we no longer use the crate's coefficient builder (it has a 4× cutoff-frequency
/// bug; see `make_lp` / `make_hp`).
const Q_BUTTERWORTH: f32 = std::f32::consts::FRAC_1_SQRT_2;
// ─── Limits ────────────────────────────────────────────────────────────────
pub const PITCH_MIN: f32 = -12.0;
pub const PITCH_MAX: f32 = 12.0;
pub const TEMPO_MIN: f32 = 0.5;
pub const TEMPO_MAX: f32 = 2.0;
pub const CUTOFF_RANGE: f32 = 20_000.0; // ±20 kHz
/// Cutoff is a *position* in `[-1, +1]`, not a raw frequency. Mapped
/// exponentially to LP/HP Hz in `cutoff_to_freqs` so each ±0.05 nudge feels
/// like an equal musical change.
pub const CUTOFF_RANGE: f32 = 1.0;
/// Below this absolute position the cutoff is treated as flat (both filters
/// fully open). Matches the dj-cutoff.lua deadzone.
pub const CUTOFF_DEADZONE: f32 = 0.02;
pub const FADER_MAX: f32 = 1.5;
/// Wide-open park values: filters always stay in the chain at these corners
/// when not actively cutting, so we never rebuild a biquad mid-stream.
const LP_OPEN_HZ: f32 = 20_000.0;
const HP_OPEN_HZ: f32 = 20.0;
/// Sweep endpoints. Asymmetric on purpose — the useful musical range is
/// narrower on the HP side (everything above ~4 kHz leaves only air) than on
/// the LP side (below ~120 Hz you've killed the sub too). Same values as the
/// mpv `dj-cutoff.lua` script so the two pieces of software feel identical.
const HP_MAX_HZ: f32 = 4000.0; // cutoff position = +1
const LP_MIN_HZ: f32 = 120.0; // cutoff position = -1
// ─── Lock-free parameter state ─────────────────────────────────────────────
pub struct DeckParams {
pub tempo: AtomicF32, // 0.5..2.0 (output_frames = input_frames / tempo)
pub pitch: AtomicF32, // semitones, -12..+12
/// < 0 → lowpass at |value| Hz; > 0 → highpass at value Hz; 0 → bypass.
/// Single-knob filter position in `[-1, +1]`:
/// negative → lowpass closes from 20 kHz down toward 120 Hz;
/// positive → highpass opens from 20 Hz up toward 4 kHz;
/// near zero (|pos| < CUTOFF_DEADZONE) → flat.
/// See `cutoff_to_freqs` for the exact exponential mapping.
pub cutoff: AtomicF32,
pub reverb_wet: AtomicF32, // 0..1
pub fader: AtomicF32, // 0..1.5
/// Source-frame read cursor published by the realtime callback at the end
/// of every block. Other decks read this lock-free for sync alignment.
pub position: AtomicU64,
/// Bass-kill timer in **wall-clock output frames**. When > 0, the DSP
/// forces an HP filter at 250 Hz over whatever the user's cutoff is,
/// ramping back to user control over the final 25 % of the timer.
/// Decremented per block by the realtime callback.
pub bass_kill_remaining_out_frames: AtomicU64,
/// Total length the bass-kill timer was armed for; used to compute the
/// fade ratio so the DSP knows when to begin its ramp-off.
pub bass_kill_initial_out_frames: AtomicU64,
}
impl DeckParams {
@@ -52,19 +88,45 @@ impl DeckParams {
cutoff: AtomicF32::new(0.0),
reverb_wet: AtomicF32::new(0.0),
fader: AtomicF32::new(0.8),
position: AtomicU64::new(0),
bass_kill_remaining_out_frames: AtomicU64::new(0),
bass_kill_initial_out_frames: AtomicU64::new(0),
})
}
}
/// Frequency the bass-kill HP is parked at while fully active.
const BASS_KILL_HZ: f32 = 250.0;
/// Fraction of the bass-kill timer over which the cutoff ramps from
/// `BASS_KILL_HZ` back toward the user's setting (linear).
const BASS_KILL_RAMP_FRAC: f32 = 0.25;
/// Cutoff-smoothing time constant (seconds). The realtime callback slews the
/// effective cutoff toward the user's target with a one-pole at this τ, so
/// `-` / `+` keystrokes glide instead of stepping. ~50 ms feels responsive
/// but kills the click. Smoothing is in *signed Hz* space — the value passes
/// through 0 (both filters wide open) when sweeping from LP to HP.
const CUTOFF_SMOOTH_TAU_S: f32 = 0.05;
// ─── DSP graph ─────────────────────────────────────────────────────────────
#[derive(Clone, Copy)]
struct Cached {
tempo: f32,
pitch: f32,
/// Slewed cutoff position (signed Hz, matches `DeckParams::cutoff`'s
/// convention). One-pole-tracked target from the user's atomic; the
/// biquads are rebuilt from this every block so transitions glide.
cutoff: f32,
/// Last LP/HP frequencies the biquads were actually rebuilt for. Used
/// to skip the coefficient rebuild when the slewed cutoff hasn't moved
/// enough to matter (< 0.5 Hz on either filter).
lp_hz: f32,
hp_hz: f32,
reverb_wet: f32,
fader: f32,
/// Last bass-kill blend amount applied (0 = off, 1 = full kill).
bk_amount: f32,
}
/// `Stretch` wraps a `cxx::UniquePtr` to a C++ object which is `!Send` by
@@ -117,7 +179,8 @@ impl DeckDsp {
reverb: SchroederStereo::new(sample_rate),
cached: Cached {
tempo: 1.0, pitch: 0.0, cutoff: 0.0,
reverb_wet: 0.0, fader: 0.8,
lp_hz: LP_OPEN_HZ, hp_hz: HP_OPEN_HZ,
reverb_wet: 0.0, fader: 0.8, bk_amount: 0.0,
},
in_l: Vec::new(),
in_r: Vec::new(),
@@ -143,37 +206,79 @@ impl DeckDsp {
let out_frames = out.len() / 2;
if out_frames == 0 { return 0; }
// 1. Snapshot params and react to changes.
let p = Cached {
tempo: params.tempo.load(std::sync::atomic::Ordering::Relaxed)
.clamp(TEMPO_MIN, TEMPO_MAX),
pitch: params.pitch.load(std::sync::atomic::Ordering::Relaxed)
.clamp(PITCH_MIN, PITCH_MAX),
cutoff: params.cutoff.load(std::sync::atomic::Ordering::Relaxed)
.clamp(-CUTOFF_RANGE, CUTOFF_RANGE),
reverb_wet: params.reverb_wet.load(std::sync::atomic::Ordering::Relaxed)
.clamp(0.0, 1.0),
fader: params.fader.load(std::sync::atomic::Ordering::Relaxed)
.clamp(0.0, FADER_MAX),
// 1. Snapshot user-facing params.
let target_tempo = params.tempo.load(std::sync::atomic::Ordering::Relaxed)
.clamp(TEMPO_MIN, TEMPO_MAX);
let target_pitch = params.pitch.load(std::sync::atomic::Ordering::Relaxed)
.clamp(PITCH_MIN, PITCH_MAX);
let target_cutoff = params.cutoff.load(std::sync::atomic::Ordering::Relaxed)
.clamp(-CUTOFF_RANGE, CUTOFF_RANGE);
let reverb_wet = params.reverb_wet.load(std::sync::atomic::Ordering::Relaxed)
.clamp(0.0, 1.0);
let fader = params.fader.load(std::sync::atomic::Ordering::Relaxed)
.clamp(0.0, FADER_MAX);
if (target_pitch - self.cached.pitch).abs() > f32::EPSILON {
self.stretcher.set_transpose_semitones(target_pitch, None);
}
// 1b. Slew the cutoff toward target with a one-pole low-pass.
// Block duration ≈ out_frames / sample_rate; coefficient
// `a = 1 - exp(-Δt/τ)`. Slewing in signed Hz means a sweep
// from -500 → +500 passes through 0 (both filters open),
// so the LP smoothly releases as the HP smoothly closes.
let dt = out_frames as f32 / self.sample_rate as f32;
let smooth_a = (1.0 - (-dt / CUTOFF_SMOOTH_TAU_S).exp()).clamp(0.0, 1.0);
let slewed_cutoff = self.cached.cutoff
+ (target_cutoff - self.cached.cutoff) * smooth_a;
// 1c. Bass-kill amount from atomics.
let bk_remaining = params.bass_kill_remaining_out_frames
.load(std::sync::atomic::Ordering::Relaxed);
let bk_initial = params.bass_kill_initial_out_frames
.load(std::sync::atomic::Ordering::Relaxed);
let bk_amount: f32 = if bk_remaining == 0 || bk_initial == 0 {
0.0
} else {
let ratio = bk_remaining as f32 / bk_initial as f32; // 1.0 at start → 0.0 at end
if ratio > BASS_KILL_RAMP_FRAC { 1.0 } else { ratio / BASS_KILL_RAMP_FRAC }
};
if (p.pitch - self.cached.pitch).abs() > f32::EPSILON {
self.stretcher.set_transpose_semitones(p.pitch, None);
}
if (p.cutoff - self.cached.cutoff).abs() > 0.5 {
let (lp_hz, hp_hz) = cutoff_to_freqs(p.cutoff);
// 1d. Resolve LP/HP corner frequencies. The bass-kill floor blends
// on top of the slewed user HP so it lifts smoothly too.
let (lp_hz, user_hp_hz) = cutoff_to_freqs(slewed_cutoff);
let hp_hz = (user_hp_hz.max(HP_OPEN_HZ) * (1.0 - bk_amount)
+ BASS_KILL_HZ * bk_amount)
.clamp(HP_OPEN_HZ, LP_OPEN_HZ);
if (lp_hz - self.cached.lp_hz).abs() > 0.5 {
self.lp_l.update_coefficients(make_lp(self.sample_rate, lp_hz));
self.lp_r.update_coefficients(make_lp(self.sample_rate, lp_hz));
self.cached.lp_hz = lp_hz;
}
if (hp_hz - self.cached.hp_hz).abs() > 0.5 {
self.hp_l.update_coefficients(make_hp(self.sample_rate, hp_hz));
self.hp_r.update_coefficients(make_hp(self.sample_rate, hp_hz));
self.cached.hp_hz = hp_hz;
}
self.cached = p;
self.cached.tempo = target_tempo;
self.cached.pitch = target_pitch;
self.cached.cutoff = slewed_cutoff;
self.cached.reverb_wet = reverb_wet;
self.cached.fader = fader;
self.cached.bk_amount = bk_amount;
// Capture the final per-sample scalars used below.
let p_tempo = target_tempo;
let p_reverb_wet = reverb_wet;
let p_fader = fader;
// 2. Pull enough input for `out_frames` output, given tempo.
// Signalsmith Stretch maps `input_samples → output_samples`
// where time-stretch ratio = input_samples / output_samples.
// tempo = playback speed multiplier, so input = out_frames * tempo.
let want_in = ((out_frames as f32 * p.tempo).round() as usize).max(1);
let want_in = ((out_frames as f32 * p_tempo).round() as usize).max(1);
let total = pcm.len() / 2;
let available = total.saturating_sub(start_frame);
let take_in = want_in.min(available);
@@ -215,11 +320,11 @@ impl DeckDsp {
r = self.hp_r.run(r);
let (rl, rr) = self.reverb.process(l, r);
l = l * (1.0 - p.reverb_wet) + rl * p.reverb_wet;
r = r * (1.0 - p.reverb_wet) + rr * p.reverb_wet;
l = l * (1.0 - p_reverb_wet) + rl * p_reverb_wet;
r = r * (1.0 - p_reverb_wet) + rr * p_reverb_wet;
l *= p.fader;
r *= p.fader;
l *= p_fader;
r *= p_fader;
out[i * 2] = l;
out[i * 2 + 1] = r;
@@ -231,26 +336,57 @@ impl DeckDsp {
// ─── Cutoff → LP/HP frequency mapping ──────────────────────────────────────
fn cutoff_to_freqs(value: f32) -> (f32, f32) {
if value < 0.0 {
((-value).clamp(HP_OPEN_HZ, LP_OPEN_HZ), HP_OPEN_HZ)
} else if value > 0.0 {
(LP_OPEN_HZ, value.clamp(HP_OPEN_HZ, LP_OPEN_HZ))
/// Map a cutoff position in `[-1, +1]` to (lp_hz, hp_hz). Frequency is
/// perceived logarithmically, so the position is mapped exponentially:
/// pos → 0 ⇒ both filters wide open (LP 20 kHz, HP 20 Hz)
/// pos → +1 ⇒ HP swept up to `HP_MAX_HZ` (LP stays open)
/// pos → -1 ⇒ LP swept down to `LP_MIN_HZ` (HP stays open)
/// Same mapping as the mpv `dj-cutoff.lua` script so the two stay in lockstep.
pub fn cutoff_to_freqs(position: f32) -> (f32, f32) {
let p = position.clamp(-CUTOFF_RANGE, CUTOFF_RANGE);
if p >= CUTOFF_DEADZONE {
let hp = HP_OPEN_HZ * (HP_MAX_HZ / HP_OPEN_HZ).powf(p);
(LP_OPEN_HZ, hp.clamp(HP_OPEN_HZ, LP_OPEN_HZ))
} else if p <= -CUTOFF_DEADZONE {
let lp = LP_OPEN_HZ * (LP_MIN_HZ / LP_OPEN_HZ).powf(-p);
(lp.clamp(HP_OPEN_HZ, LP_OPEN_HZ), HP_OPEN_HZ)
} else {
(LP_OPEN_HZ, HP_OPEN_HZ)
}
}
/// RBJ Audio EQ Cookbook Butterworth LP biquad. Computes coefficients
/// directly because `biquad::Coefficients::from_params` in 0.5.0 has a
/// 4× cutoff-frequency bug (uses `omega = π·f0/(2·fs)` instead of
/// `2π·f0/fs`).
fn make_lp(sample_rate: u32, cutoff_hz: f32) -> Coefficients<f32> {
let fs = Hertz::<f32>::from_hz(sample_rate as f32).unwrap();
let f0 = Hertz::<f32>::from_hz(cutoff_hz.clamp(HP_OPEN_HZ, LP_OPEN_HZ)).unwrap();
Coefficients::<f32>::from_params(Type::LowPass, fs, f0, Q_BUTTERWORTH_F32).unwrap()
let f0 = cutoff_hz.clamp(HP_OPEN_HZ, LP_OPEN_HZ);
let fs = sample_rate as f32;
let w0 = 2.0 * std::f32::consts::PI * f0 / fs;
let (sin_w0, cos_w0) = w0.sin_cos();
let alpha = sin_w0 / (2.0 * Q_BUTTERWORTH);
let a0 = 1.0 + alpha;
let b0 = (1.0 - cos_w0) * 0.5;
let b1 = 1.0 - cos_w0;
let b2 = (1.0 - cos_w0) * 0.5;
let a1 = -2.0 * cos_w0;
let a2 = 1.0 - alpha;
Coefficients { a1: a1 / a0, a2: a2 / a0, b0: b0 / a0, b1: b1 / a0, b2: b2 / a0 }
}
fn make_hp(sample_rate: u32, cutoff_hz: f32) -> Coefficients<f32> {
let fs = Hertz::<f32>::from_hz(sample_rate as f32).unwrap();
let f0 = Hertz::<f32>::from_hz(cutoff_hz.clamp(HP_OPEN_HZ, LP_OPEN_HZ)).unwrap();
Coefficients::<f32>::from_params(Type::HighPass, fs, f0, Q_BUTTERWORTH_F32).unwrap()
let f0 = cutoff_hz.clamp(HP_OPEN_HZ, LP_OPEN_HZ);
let fs = sample_rate as f32;
let w0 = 2.0 * std::f32::consts::PI * f0 / fs;
let (sin_w0, cos_w0) = w0.sin_cos();
let alpha = sin_w0 / (2.0 * Q_BUTTERWORTH);
let a0 = 1.0 + alpha;
let b0 = (1.0 + cos_w0) * 0.5;
let b1 = -(1.0 + cos_w0);
let b2 = (1.0 + cos_w0) * 0.5;
let a1 = -2.0 * cos_w0;
let a2 = 1.0 - alpha;
Coefficients { a1: a1 / a0, a2: a2 / a0, b0: b0 / a0, b1: b1 / a0, b2: b2 / a0 }
}
// ─── Stereo Schroeder reverb ───────────────────────────────────────────────
@@ -379,11 +515,12 @@ mod tests {
(samples.iter().map(|x| x * x).sum::<f32>() / n).sqrt()
}
/// Lowpass at 1 kHz attenuates a 5 kHz sine more than -10 dB.
/// Lowpass at position −1 (LP at LP_MIN_HZ = 120 Hz) attenuates a 5 kHz
/// sine well past -10 dB.
#[test]
fn lowpass_cuts_highs() {
let params = DeckParams::new();
params.cutoff.store(-1000.0, std::sync::atomic::Ordering::Relaxed);
params.cutoff.store(-1.0, std::sync::atomic::Ordering::Relaxed);
let mut dsp = DeckDsp::new(SR);
let pcm = sine(5000.0, SR as usize); // 1 s of 5 kHz
@@ -407,11 +544,12 @@ mod tests {
assert!(ratio_db < -10.0, "5 kHz through 1 kHz LP only attenuated {:.1} dB", ratio_db);
}
/// Highpass at 1 kHz attenuates a 100 Hz sine more than -10 dB.
/// Highpass at position +1 (HP at HP_MAX_HZ = 4 kHz) attenuates a 100 Hz
/// sine well past -10 dB.
#[test]
fn highpass_cuts_lows() {
let params = DeckParams::new();
params.cutoff.store(1000.0, std::sync::atomic::Ordering::Relaxed);
params.cutoff.store(1.0, std::sync::atomic::Ordering::Relaxed);
let mut dsp = DeckDsp::new(SR);
let pcm = sine(100.0, SR as usize);
@@ -455,6 +593,93 @@ mod tests {
in_fast, in_normal, ratio);
}
/// Sanity check: biquad HP at 250 Hz on its own attenuates 100 Hz heavily.
#[test]
fn raw_biquad_hp_attenuates() {
let mut hp = DirectForm1::<f32>::new(make_hp(SR, 250.0));
let mut in_sum = 0.0f32;
let mut out_sum = 0.0f32;
let mut in_sq = 0.0f32;
let mut out_sq = 0.0f32;
let n = SR as usize;
for i in 0..n {
let s = (2.0 * PI * 100.0 * i as f32 / SR as f32).sin() * 0.5;
let y = hp.run(s);
if i > SR as usize / 5 {
in_sum += s; in_sq += s * s;
out_sum += y; out_sq += y * y;
}
}
let _ = (in_sum, out_sum);
let in_rms = (in_sq / n as f32).sqrt();
let out_rms = (out_sq / n as f32).sqrt();
let db = 20.0 * (out_rms / in_rms).log10();
eprintln!("raw HP@250 on 100 Hz: in_rms={:.4} out_rms={:.4} db={:.1}", in_rms, out_rms, db);
assert!(db < -10.0, "raw biquad HP failed: {:.1} dB", db);
}
/// Bass-kill: a sustained 100 Hz sine through full bass-kill should be
/// attenuated more than the same signal through bass-kill-off.
#[test]
fn bass_kill_attenuates_lows() {
// Common setup: no user cutoff override, 100 Hz bass tone.
let measure = |bk_remaining: u64, bk_initial: u64| -> f32 {
let params = DeckParams::new();
params.bass_kill_remaining_out_frames
.store(bk_remaining, std::sync::atomic::Ordering::Relaxed);
params.bass_kill_initial_out_frames
.store(bk_initial, std::sync::atomic::Ordering::Relaxed);
let mut dsp = DeckDsp::new(SR);
let pcm = sine(100.0, SR as usize);
let mut out = vec![0.0f32; SR as usize * 2];
let block = 1024;
let mut cursor = 0usize;
for chunk_start in (0..SR as usize).step_by(block) {
let end = (chunk_start + block).min(SR as usize);
let block_frames = end - chunk_start;
let mut block_out = vec![0.0f32; block_frames * 2];
let consumed = dsp.process(&params, &pcm, cursor, &mut block_out);
cursor += consumed;
out[chunk_start * 2..end * 2].copy_from_slice(&block_out);
}
// Skip first 0.2 s for transient settle.
rms(&out[(SR as usize / 5) * 2..])
};
let rms_off = measure(0, 0);
let rms_full = measure(SR as u64, SR as u64); // 1 s, ratio 1.0 = full kill
let ratio_db = 20.0 * (rms_full / rms_off).log10();
assert!(ratio_db < -6.0,
"bass-kill on 100 Hz only attenuated {:.1} dB (off rms {:.4}, on rms {:.4})",
ratio_db, rms_off, rms_full);
}
/// Cutoff smoothing: a target jump from 0 to -1.0 (full LP) should not
/// reach the target in a single block. After one ~12 ms block at the
/// 50 ms slew τ, the effective position should be roughly -0.2, NOT -1.0.
/// Without smoothing, the biquad jumped audibly when nudging with `-`/`=`.
#[test]
fn cutoff_slews_instead_of_jumping() {
let params = DeckParams::new();
params.cutoff.store(-1.0, std::sync::atomic::Ordering::Relaxed);
let mut dsp = DeckDsp::new(SR);
let block = 512;
let pcm = sine(440.0, block * 4);
let mut out = vec![0.0f32; block * 2];
let _ = dsp.process(&params, &pcm, 0, &mut out);
let cached_after = dsp.cached.cutoff;
// 1 - exp(-0.012/0.05) ≈ 0.21 → first block pulls by ~21 %.
assert!(cached_after > -0.5,
"expected slewed cutoff position well above -0.5 after one block; got {}",
cached_after);
assert!(cached_after < 0.0,
"expected slew direction toward target; got {}", cached_after);
}
/// Reverb produces non-zero tail after the dry signal stops.
#[test]
fn reverb_has_tail() {
+53 -12
View File
@@ -57,7 +57,14 @@ fn draw_base(f: &mut Frame, app: &App, area: Rect) {
for (col_i, &deck_idx) in row_idxs.iter().enumerate() {
if deck_idx < app.decks.len() {
draw_deck(f, &app.decks[deck_idx], cols[col_i], app.focused_deck == deck_idx);
let is_master = app.master_deck == Some(app.decks[deck_idx].id);
draw_deck(
f,
&app.decks[deck_idx],
cols[col_i],
app.focused_deck == deck_idx,
is_master,
);
}
}
}
@@ -74,7 +81,7 @@ fn deck_color(id: DeckId) -> Color {
}
}
fn draw_deck(f: &mut Frame, deck: &Deck, area: Rect, focused: bool) {
fn draw_deck(f: &mut Frame, deck: &Deck, area: Rect, focused: bool, is_master: bool) {
let color = deck_color(deck.id);
let border_style = if focused {
@@ -83,18 +90,25 @@ fn draw_deck(f: &mut Frame, deck: &Deck, area: Rect, focused: bool) {
Style::default().fg(Color::DarkGray)
};
// Title: deck label + play state + truncated track name + node IDs
let max_name = area.width.saturating_sub(22) as usize;
let max_name = area.width.saturating_sub(28) as usize;
let track_display = deck.track_name.as_deref().unwrap_or("[no track]");
let track_display = if track_display.len() > max_name {
format!("{}…", &track_display[..max_name.saturating_sub(1)])
} else {
track_display.to_string()
};
let bpm = deck.bpm.map(|b| format!(" {:.1} BPM ", b)).unwrap_or_default();
// BPM: show original alone when tempo == 1.0, else "orig × ratio = eff".
let bpm_disp = match deck.bpm {
Some(b) if (deck.tempo - 1.0).abs() > 1e-3 => format!(
" {:.1} (×{:.3} = {:.1}) ", b, deck.tempo, b * deck.tempo
),
Some(b) => format!(" {:.1} BPM ", b),
None => String::new(),
};
let star = if is_master { "★ " } else { "" };
let title = format!(
" Deck {} {} {}{}",
deck.id.label(), deck.play_state.icon(), track_display, bpm
" {}Deck {} {} {}{}",
star, deck.id.label(), deck.play_state.icon(), track_display, bpm_disp
);
let outer = Block::default()
@@ -268,18 +282,23 @@ fn draw_info_effects(f: &mut Frame, deck: &Deck, area: Rect) {
// Effects: pitch, tempo, cutoff, reverb — render in a 2×2 grid of text
let pitch_str = format!("PTCH {:+.1}st", deck.pitch);
let tempo_str = format!("TMPO {:.2}×", deck.tempo);
let cutoff_str = if deck.cutoff < -50.0 {
format!("CTOF LP {:.0}Hz", -deck.cutoff)
} else if deck.cutoff > 50.0 {
format!("CTOF HP {:.0}Hz", deck.cutoff)
// Cutoff is a *position* in [-1, +1]; resolve it to the actual LP/HP
// corner frequencies the DSP is using so the OSD shows real Hz.
let cutoff_str = {
let (lp_hz, hp_hz) = crate::dsp::cutoff_to_freqs(deck.cutoff);
if deck.cutoff >= crate::dsp::CUTOFF_DEADZONE {
format!("CTOF HP {:.0}Hz ({:+.0}%)", hp_hz, deck.cutoff * 100.0)
} else if deck.cutoff <= -crate::dsp::CUTOFF_DEADZONE {
format!("CTOF LP {:.0}Hz ({:+.0}%)", lp_hz, deck.cutoff * 100.0)
} else {
"CTOF flat".to_string()
}
};
let reverb_str = format!("REVB {:.0}%", deck.reverb * 100.0);
let pitch_active = deck.pitch.abs() > 0.05;
let tempo_active = (deck.tempo - 1.0).abs() > 0.005;
let cutoff_active = deck.cutoff.abs() > 50.0;
let cutoff_active = deck.cutoff.abs() >= crate::dsp::CUTOFF_DEADZONE;
let reverb_active = deck.reverb > 0.01;
let eff = |s: &str, active: bool| -> Span {
@@ -466,6 +485,28 @@ fn draw_waveform(f: &mut Frame, deck: &Deck, area: Rect, focus_color: Color) {
Rect { y: area.y + row as u16, height: 1, ..area },
);
}
// ── Beat-grid ticks overlay (bottom row) ───────────────────────────────
//
// Direct buffer access so the ticks paint on top of the already-rendered
// band-colored bottom row without clobbering surrounding cells.
if !bands.is_empty() && !deck.beat_grid.is_empty() {
let left_sample = (left_bucket * PEAK_BUCKET) as i64;
let col_samples = (buckets_per_col * PEAK_BUCKET) as f64;
let buf = f.buffer_mut();
let tick_y = area.y + (n_rows as u16 - 1);
for &beat in &deck.beat_grid {
let col_f = (beat as i64 - left_sample) as f64 / col_samples;
if col_f < 0.0 || col_f >= width as f64 { continue; }
let x = area.x + col_f as u16;
if let Some(cell) = buf.cell_mut((x, tick_y)) {
// Don't trample the playhead marker.
if cell.symbol() != "┃" {
cell.set_char('·').set_fg(Color::White);
}
}
}
}
}
/// Map per-band peaks (bass, mid, high) ∈ [0,1] to an RGB color via additive