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:
+106
-19
@@ -21,7 +21,7 @@ use ratatui::{backend::CrosstermBackend, Terminal};
|
|||||||
use crate::{
|
use crate::{
|
||||||
audio::{AudioCmd, AudioEngine, AudioStatus},
|
audio::{AudioCmd, AudioEngine, AudioStatus},
|
||||||
deck::{Deck, DeckId, PlayState},
|
deck::{Deck, DeckId, PlayState},
|
||||||
dsp::DeckParams,
|
dsp::{self, DeckParams},
|
||||||
file_selector::FileSelector,
|
file_selector::FileSelector,
|
||||||
osc::{self, OscAppCmd},
|
osc::{self, OscAppCmd},
|
||||||
tui,
|
tui,
|
||||||
@@ -33,6 +33,10 @@ pub struct App {
|
|||||||
pub decks: Vec<Deck>,
|
pub decks: Vec<Deck>,
|
||||||
pub focused_deck: usize,
|
pub focused_deck: usize,
|
||||||
pub file_selector: Option<FileSelector>,
|
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>,
|
audio: Option<AudioEngine>,
|
||||||
/// Per-deck atomic params, shared with the audio engine's realtime callback.
|
/// Per-deck atomic params, shared with the audio engine's realtime callback.
|
||||||
@@ -60,6 +64,7 @@ impl App {
|
|||||||
decks,
|
decks,
|
||||||
focused_deck: 0,
|
focused_deck: 0,
|
||||||
file_selector: None,
|
file_selector: None,
|
||||||
|
master_deck: None,
|
||||||
audio: None,
|
audio: None,
|
||||||
params: Vec::new(),
|
params: Vec::new(),
|
||||||
osc_rx,
|
osc_rx,
|
||||||
@@ -166,8 +171,25 @@ impl App {
|
|||||||
self.dirty = true;
|
self.dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
AudioStatus::Bpm { deck, bpm } => {
|
AudioStatus::BeatGrid { deck, beats, bpm } => {
|
||||||
self.decks[deck as usize].bpm = Some(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;
|
self.dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,13 +262,42 @@ impl App {
|
|||||||
|
|
||||||
fn sync_play_state_from_audio_cmd(&mut self, cmd: &AudioCmd) {
|
fn sync_play_state_from_audio_cmd(&mut self, cmd: &AudioCmd) {
|
||||||
match cmd {
|
match cmd {
|
||||||
AudioCmd::Play(d) => self.decks[*d as usize].play_state = PlayState::Playing,
|
AudioCmd::Play(d) => {
|
||||||
AudioCmd::Pause(d) => self.decks[*d as usize].play_state = PlayState::Paused,
|
self.decks[*d as usize].play_state = PlayState::Playing;
|
||||||
AudioCmd::Stop(d) => self.decks[*d as usize].play_state = PlayState::Stopped,
|
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 ─────────────────────────────────────────────────────────────
|
// ─── Keyboard ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn handle_key(&mut self, key: KeyEvent) -> Result<bool> {
|
fn handle_key(&mut self, key: KeyEvent) -> Result<bool> {
|
||||||
@@ -268,8 +319,10 @@ impl App {
|
|||||||
(KeyModifiers::SHIFT, KeyCode::Char('Z')) => { self.open_selector(1); self.dirty = true; }
|
(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::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; }
|
||||||
(KeyModifiers::NONE, KeyCode::Enter) => { self.stop_focused(); 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::Left) => { self.seek_relative(-0.01); self.dirty = true; }
|
||||||
(KeyModifiers::NONE, KeyCode::Right) => { 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_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(0.05); 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('p')) => { self.nudge_reverb(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; }
|
(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 deck = self.focused_id();
|
||||||
let d = &mut self.decks[self.focused_deck];
|
if !self.decks[self.focused_deck].is_loaded() { return; }
|
||||||
if !d.is_loaded() { return; }
|
|
||||||
match d.play_state {
|
match self.decks[self.focused_deck].play_state {
|
||||||
PlayState::Playing => {
|
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));
|
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
|
||||||
let _ = self.audio.as_ref().unwrap().cmd_tx.send(AudioCmd::Play(deck));
|
&& 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();
|
let deck = self.focused_id();
|
||||||
self.decks[self.focused_deck].play_state = PlayState::Stopped;
|
self.decks[self.focused_deck].play_state = PlayState::Stopped;
|
||||||
let _ = self.audio.as_ref().unwrap().cmd_tx.send(AudioCmd::Stop(deck));
|
let _ = self.audio.as_ref().unwrap().cmd_tx.send(AudioCmd::Stop(deck));
|
||||||
|
self.maybe_release_master(deck);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn seek_relative(&mut self, delta: f32) {
|
fn seek_relative(&mut self, delta: f32) {
|
||||||
@@ -413,12 +496,16 @@ impl App {
|
|||||||
|
|
||||||
fn nudge_cutoff(&mut self, delta: f32) {
|
fn nudge_cutoff(&mut self, delta: f32) {
|
||||||
let deck = self.focused_id();
|
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);
|
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) {
|
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.decks[deck as usize].cutoff = value;
|
||||||
self.params[deck as usize].cutoff.store(value, Ordering::Relaxed);
|
self.params[deck as usize].cutoff.store(value, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
|
|||||||
+92
-5
@@ -52,16 +52,19 @@ pub enum PlayState {
|
|||||||
Stopped,
|
Stopped,
|
||||||
Playing,
|
Playing,
|
||||||
Paused,
|
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 {
|
impl PlayState {
|
||||||
pub fn icon(&self) -> &'static str {
|
pub fn icon(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Playing => "▶",
|
Self::Playing => "▶",
|
||||||
Self::Paused => "⏸",
|
Self::Paused => "⏸",
|
||||||
Self::Stopped => "⏹",
|
Self::Stopped => "⏹",
|
||||||
Self::Cued => "⏮",
|
Self::WaitingForSync => "⏳",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,6 +102,10 @@ pub struct Deck {
|
|||||||
|
|
||||||
/// BPM estimate from the decode-time analyzer; `None` until detected.
|
/// BPM estimate from the decode-time analyzer; `None` until detected.
|
||||||
pub bpm: Option<f32>,
|
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 {
|
impl Deck {
|
||||||
@@ -122,6 +129,7 @@ impl Deck {
|
|||||||
vu: (0.0, 0.0),
|
vu: (0.0, 0.0),
|
||||||
waveform_bands: Vec::new(),
|
waveform_bands: Vec::new(),
|
||||||
bpm: None,
|
bpm: None,
|
||||||
|
beat_grid: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,3 +157,82 @@ impl Deck {
|
|||||||
(s / 60, s % 60)
|
(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
@@ -14,34 +14,70 @@
|
|||||||
//! block. No `Mutex`, no `pw-cli`.
|
//! block. No `Mutex`, no `pw-cli`.
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::AtomicU64;
|
||||||
|
|
||||||
use atomic_float::AtomicF32;
|
use atomic_float::AtomicF32;
|
||||||
use biquad::{
|
use biquad::{Biquad as _, Coefficients, DirectForm1};
|
||||||
Biquad as _, Coefficients, DirectForm1, Hertz, Q_BUTTERWORTH_F32, Type,
|
|
||||||
};
|
|
||||||
use ssstretch::Stretch;
|
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 ────────────────────────────────────────────────────────────────
|
// ─── Limits ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
pub const PITCH_MIN: f32 = -12.0;
|
pub const PITCH_MIN: f32 = -12.0;
|
||||||
pub const PITCH_MAX: f32 = 12.0;
|
pub const PITCH_MAX: f32 = 12.0;
|
||||||
pub const TEMPO_MIN: f32 = 0.5;
|
pub const TEMPO_MIN: f32 = 0.5;
|
||||||
pub const TEMPO_MAX: f32 = 2.0;
|
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;
|
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 LP_OPEN_HZ: f32 = 20_000.0;
|
||||||
const HP_OPEN_HZ: f32 = 20.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 ─────────────────────────────────────────────
|
// ─── Lock-free parameter state ─────────────────────────────────────────────
|
||||||
|
|
||||||
pub struct DeckParams {
|
pub struct DeckParams {
|
||||||
pub tempo: AtomicF32, // 0.5..2.0 (output_frames = input_frames / tempo)
|
pub tempo: AtomicF32, // 0.5..2.0 (output_frames = input_frames / tempo)
|
||||||
pub pitch: AtomicF32, // semitones, -12..+12
|
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 cutoff: AtomicF32,
|
||||||
pub reverb_wet: AtomicF32, // 0..1
|
pub reverb_wet: AtomicF32, // 0..1
|
||||||
pub fader: AtomicF32, // 0..1.5
|
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 {
|
impl DeckParams {
|
||||||
@@ -52,19 +88,45 @@ impl DeckParams {
|
|||||||
cutoff: AtomicF32::new(0.0),
|
cutoff: AtomicF32::new(0.0),
|
||||||
reverb_wet: AtomicF32::new(0.0),
|
reverb_wet: AtomicF32::new(0.0),
|
||||||
fader: AtomicF32::new(0.8),
|
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 ─────────────────────────────────────────────────────────────
|
// ─── DSP graph ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
struct Cached {
|
struct Cached {
|
||||||
tempo: f32,
|
tempo: f32,
|
||||||
pitch: 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,
|
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,
|
reverb_wet: f32,
|
||||||
fader: 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
|
/// `Stretch` wraps a `cxx::UniquePtr` to a C++ object which is `!Send` by
|
||||||
@@ -117,7 +179,8 @@ impl DeckDsp {
|
|||||||
reverb: SchroederStereo::new(sample_rate),
|
reverb: SchroederStereo::new(sample_rate),
|
||||||
cached: Cached {
|
cached: Cached {
|
||||||
tempo: 1.0, pitch: 0.0, cutoff: 0.0,
|
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_l: Vec::new(),
|
||||||
in_r: Vec::new(),
|
in_r: Vec::new(),
|
||||||
@@ -143,37 +206,79 @@ impl DeckDsp {
|
|||||||
let out_frames = out.len() / 2;
|
let out_frames = out.len() / 2;
|
||||||
if out_frames == 0 { return 0; }
|
if out_frames == 0 { return 0; }
|
||||||
|
|
||||||
// 1. Snapshot params and react to changes.
|
// 1. Snapshot user-facing params.
|
||||||
let p = Cached {
|
let target_tempo = params.tempo.load(std::sync::atomic::Ordering::Relaxed)
|
||||||
tempo: params.tempo.load(std::sync::atomic::Ordering::Relaxed)
|
.clamp(TEMPO_MIN, TEMPO_MAX);
|
||||||
.clamp(TEMPO_MIN, TEMPO_MAX),
|
let target_pitch = params.pitch.load(std::sync::atomic::Ordering::Relaxed)
|
||||||
pitch: params.pitch.load(std::sync::atomic::Ordering::Relaxed)
|
.clamp(PITCH_MIN, PITCH_MAX);
|
||||||
.clamp(PITCH_MIN, PITCH_MAX),
|
let target_cutoff = params.cutoff.load(std::sync::atomic::Ordering::Relaxed)
|
||||||
cutoff: params.cutoff.load(std::sync::atomic::Ordering::Relaxed)
|
.clamp(-CUTOFF_RANGE, CUTOFF_RANGE);
|
||||||
.clamp(-CUTOFF_RANGE, CUTOFF_RANGE),
|
let reverb_wet = params.reverb_wet.load(std::sync::atomic::Ordering::Relaxed)
|
||||||
reverb_wet: params.reverb_wet.load(std::sync::atomic::Ordering::Relaxed)
|
.clamp(0.0, 1.0);
|
||||||
.clamp(0.0, 1.0),
|
let fader = params.fader.load(std::sync::atomic::Ordering::Relaxed)
|
||||||
fader: params.fader.load(std::sync::atomic::Ordering::Relaxed)
|
.clamp(0.0, FADER_MAX);
|
||||||
.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 {
|
// 1d. Resolve LP/HP corner frequencies. The bass-kill floor blends
|
||||||
self.stretcher.set_transpose_semitones(p.pitch, None);
|
// on top of the slewed user HP so it lifts smoothly too.
|
||||||
}
|
let (lp_hz, user_hp_hz) = cutoff_to_freqs(slewed_cutoff);
|
||||||
if (p.cutoff - self.cached.cutoff).abs() > 0.5 {
|
let hp_hz = (user_hp_hz.max(HP_OPEN_HZ) * (1.0 - bk_amount)
|
||||||
let (lp_hz, hp_hz) = cutoff_to_freqs(p.cutoff);
|
+ 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_l.update_coefficients(make_lp(self.sample_rate, lp_hz));
|
||||||
self.lp_r.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_l.update_coefficients(make_hp(self.sample_rate, hp_hz));
|
||||||
self.hp_r.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.
|
// 2. Pull enough input for `out_frames` output, given tempo.
|
||||||
// Signalsmith Stretch maps `input_samples → output_samples`
|
// Signalsmith Stretch maps `input_samples → output_samples`
|
||||||
// where time-stretch ratio = input_samples / output_samples.
|
// where time-stretch ratio = input_samples / output_samples.
|
||||||
// tempo = playback speed multiplier, so input = out_frames * tempo.
|
// 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 total = pcm.len() / 2;
|
||||||
let available = total.saturating_sub(start_frame);
|
let available = total.saturating_sub(start_frame);
|
||||||
let take_in = want_in.min(available);
|
let take_in = want_in.min(available);
|
||||||
@@ -215,11 +320,11 @@ impl DeckDsp {
|
|||||||
r = self.hp_r.run(r);
|
r = self.hp_r.run(r);
|
||||||
|
|
||||||
let (rl, rr) = self.reverb.process(l, r);
|
let (rl, rr) = self.reverb.process(l, r);
|
||||||
l = l * (1.0 - p.reverb_wet) + rl * 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;
|
r = r * (1.0 - p_reverb_wet) + rr * p_reverb_wet;
|
||||||
|
|
||||||
l *= p.fader;
|
l *= p_fader;
|
||||||
r *= p.fader;
|
r *= p_fader;
|
||||||
|
|
||||||
out[i * 2] = l;
|
out[i * 2] = l;
|
||||||
out[i * 2 + 1] = r;
|
out[i * 2 + 1] = r;
|
||||||
@@ -231,26 +336,57 @@ impl DeckDsp {
|
|||||||
|
|
||||||
// ─── Cutoff → LP/HP frequency mapping ──────────────────────────────────────
|
// ─── Cutoff → LP/HP frequency mapping ──────────────────────────────────────
|
||||||
|
|
||||||
fn cutoff_to_freqs(value: f32) -> (f32, f32) {
|
/// Map a cutoff position in `[-1, +1]` to (lp_hz, hp_hz). Frequency is
|
||||||
if value < 0.0 {
|
/// perceived logarithmically, so the position is mapped exponentially:
|
||||||
((-value).clamp(HP_OPEN_HZ, LP_OPEN_HZ), HP_OPEN_HZ)
|
/// pos → 0 ⇒ both filters wide open (LP 20 kHz, HP 20 Hz)
|
||||||
} else if value > 0.0 {
|
/// pos → +1 ⇒ HP swept up to `HP_MAX_HZ` (LP stays open)
|
||||||
(LP_OPEN_HZ, value.clamp(HP_OPEN_HZ, LP_OPEN_HZ))
|
/// 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 {
|
} else {
|
||||||
(LP_OPEN_HZ, HP_OPEN_HZ)
|
(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> {
|
fn make_lp(sample_rate: u32, cutoff_hz: f32) -> Coefficients<f32> {
|
||||||
let fs = Hertz::<f32>::from_hz(sample_rate as f32).unwrap();
|
let f0 = cutoff_hz.clamp(HP_OPEN_HZ, LP_OPEN_HZ);
|
||||||
let f0 = Hertz::<f32>::from_hz(cutoff_hz.clamp(HP_OPEN_HZ, LP_OPEN_HZ)).unwrap();
|
let fs = sample_rate as f32;
|
||||||
Coefficients::<f32>::from_params(Type::LowPass, fs, f0, Q_BUTTERWORTH_F32).unwrap()
|
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> {
|
fn make_hp(sample_rate: u32, cutoff_hz: f32) -> Coefficients<f32> {
|
||||||
let fs = Hertz::<f32>::from_hz(sample_rate as f32).unwrap();
|
let f0 = cutoff_hz.clamp(HP_OPEN_HZ, LP_OPEN_HZ);
|
||||||
let f0 = Hertz::<f32>::from_hz(cutoff_hz.clamp(HP_OPEN_HZ, LP_OPEN_HZ)).unwrap();
|
let fs = sample_rate as f32;
|
||||||
Coefficients::<f32>::from_params(Type::HighPass, fs, f0, Q_BUTTERWORTH_F32).unwrap()
|
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 ───────────────────────────────────────────────
|
// ─── Stereo Schroeder reverb ───────────────────────────────────────────────
|
||||||
@@ -379,11 +515,12 @@ mod tests {
|
|||||||
(samples.iter().map(|x| x * x).sum::<f32>() / n).sqrt()
|
(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]
|
#[test]
|
||||||
fn lowpass_cuts_highs() {
|
fn lowpass_cuts_highs() {
|
||||||
let params = DeckParams::new();
|
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 mut dsp = DeckDsp::new(SR);
|
||||||
|
|
||||||
let pcm = sine(5000.0, SR as usize); // 1 s of 5 kHz
|
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);
|
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]
|
#[test]
|
||||||
fn highpass_cuts_lows() {
|
fn highpass_cuts_lows() {
|
||||||
let params = DeckParams::new();
|
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 mut dsp = DeckDsp::new(SR);
|
||||||
|
|
||||||
let pcm = sine(100.0, SR as usize);
|
let pcm = sine(100.0, SR as usize);
|
||||||
@@ -455,6 +593,93 @@ mod tests {
|
|||||||
in_fast, in_normal, ratio);
|
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(¶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.
|
/// Reverb produces non-zero tail after the dry signal stops.
|
||||||
#[test]
|
#[test]
|
||||||
fn reverb_has_tail() {
|
fn reverb_has_tail() {
|
||||||
|
|||||||
+55
-14
@@ -57,7 +57,14 @@ fn draw_base(f: &mut Frame, app: &App, area: Rect) {
|
|||||||
|
|
||||||
for (col_i, &deck_idx) in row_idxs.iter().enumerate() {
|
for (col_i, &deck_idx) in row_idxs.iter().enumerate() {
|
||||||
if deck_idx < app.decks.len() {
|
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 color = deck_color(deck.id);
|
||||||
|
|
||||||
let border_style = if focused {
|
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)
|
Style::default().fg(Color::DarkGray)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Title: deck label + play state + truncated track name + node IDs
|
let max_name = area.width.saturating_sub(28) as usize;
|
||||||
let max_name = area.width.saturating_sub(22) as usize;
|
|
||||||
let track_display = deck.track_name.as_deref().unwrap_or("[no track]");
|
let track_display = deck.track_name.as_deref().unwrap_or("[no track]");
|
||||||
let track_display = if track_display.len() > max_name {
|
let track_display = if track_display.len() > max_name {
|
||||||
format!("{}…", &track_display[..max_name.saturating_sub(1)])
|
format!("{}…", &track_display[..max_name.saturating_sub(1)])
|
||||||
} else {
|
} else {
|
||||||
track_display.to_string()
|
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!(
|
let title = format!(
|
||||||
" Deck {} {} {}{}",
|
" {}Deck {} {} {}{}",
|
||||||
deck.id.label(), deck.play_state.icon(), track_display, bpm
|
star, deck.id.label(), deck.play_state.icon(), track_display, bpm_disp
|
||||||
);
|
);
|
||||||
|
|
||||||
let outer = Block::default()
|
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
|
// Effects: pitch, tempo, cutoff, reverb — render in a 2×2 grid of text
|
||||||
let pitch_str = format!("PTCH {:+.1}st", deck.pitch);
|
let pitch_str = format!("PTCH {:+.1}st", deck.pitch);
|
||||||
let tempo_str = format!("TMPO {:.2}×", deck.tempo);
|
let tempo_str = format!("TMPO {:.2}×", deck.tempo);
|
||||||
let cutoff_str = if deck.cutoff < -50.0 {
|
// Cutoff is a *position* in [-1, +1]; resolve it to the actual LP/HP
|
||||||
format!("CTOF LP {:.0}Hz", -deck.cutoff)
|
// corner frequencies the DSP is using so the OSD shows real Hz.
|
||||||
} else if deck.cutoff > 50.0 {
|
let cutoff_str = {
|
||||||
format!("CTOF HP {:.0}Hz", deck.cutoff)
|
let (lp_hz, hp_hz) = crate::dsp::cutoff_to_freqs(deck.cutoff);
|
||||||
} else {
|
if deck.cutoff >= crate::dsp::CUTOFF_DEADZONE {
|
||||||
"CTOF flat".to_string()
|
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 reverb_str = format!("REVB {:.0}%", deck.reverb * 100.0);
|
||||||
|
|
||||||
let pitch_active = deck.pitch.abs() > 0.05;
|
let pitch_active = deck.pitch.abs() > 0.05;
|
||||||
let tempo_active = (deck.tempo - 1.0).abs() > 0.005;
|
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 reverb_active = deck.reverb > 0.01;
|
||||||
|
|
||||||
let eff = |s: &str, active: bool| -> Span {
|
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 },
|
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
|
/// Map per-band peaks (bass, mid, high) ∈ [0,1] to an RGB color via additive
|
||||||
|
|||||||
Reference in New Issue
Block a user