diff --git a/src/app.rs b/src/app.rs index 8e2679c..50574b9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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, pub focused_deck: usize, pub file_selector: Option, + /// 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, audio: Option, /// 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 { @@ -268,8 +319,10 @@ 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::Enter) => { self.stop_focused(); 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 _ = self.audio.as_ref().unwrap().cmd_tx.send(AudioCmd::Play(deck)); + 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); } diff --git a/src/deck.rs b/src/deck.rs index 6d5695a..941e998 100644 --- a/src/deck.rs +++ b/src/deck.rs @@ -52,16 +52,19 @@ 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 { pub fn icon(&self) -> &'static str { match self { - Self::Playing => "▶", - Self::Paused => "⏸", - Self::Stopped => "⏹", - Self::Cued => "⏮", + Self::Playing => "▶", + Self::Paused => "⏸", + Self::Stopped => "⏹", + Self::WaitingForSync => "⏳", } } } @@ -99,6 +102,10 @@ pub struct Deck { /// BPM estimate from the decode-time analyzer; `None` until detected. pub bpm: Option, + + /// 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, } 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 { + 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 { + (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 = 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); + } +} diff --git a/src/dsp.rs b/src/dsp.rs index c8ad54e..3818087 100644 --- a/src/dsp.rs +++ b/src/dsp.rs @@ -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 { - let fs = Hertz::::from_hz(sample_rate as f32).unwrap(); - let f0 = Hertz::::from_hz(cutoff_hz.clamp(HP_OPEN_HZ, LP_OPEN_HZ)).unwrap(); - Coefficients::::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 { - let fs = Hertz::::from_hz(sample_rate as f32).unwrap(); - let f0 = Hertz::::from_hz(cutoff_hz.clamp(HP_OPEN_HZ, LP_OPEN_HZ)).unwrap(); - Coefficients::::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::() / 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::::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(¶ms, &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(¶ms, &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() { diff --git a/src/tui.rs b/src/tui.rs index 3fc44b4..74d4bca 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -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) - } else { - "CTOF flat".to_string() + // 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