From 39095f3a4a2401ca92359be723cd6b4c51b32190 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Wed, 27 May 2026 22:04:08 +0200 Subject: [PATCH] internal process dsp, skipping ladspa pipewire attempt, and reworked waveforms --- Cargo.toml | 6 + README.md | 51 +++- src/app.rs | 428 ++++++++++++------------------ src/audio.rs | 614 +++++++++++++++++++++++++++++++------------ src/deck.rs | 80 +++--- src/dsp.rs | 489 ++++++++++++++++++++++++++++++++++ src/effect_chain.rs | 267 ------------------- src/file_selector.rs | 2 +- src/main.rs | 6 +- src/osc.rs | 18 +- src/tui.rs | 483 +++++++++++++++++++++++++++------- 11 files changed, 1606 insertions(+), 838 deletions(-) create mode 100644 src/dsp.rs delete mode 100644 src/effect_chain.rs diff --git a/Cargo.toml b/Cargo.toml index a670e71..c96a29d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,12 @@ parking_lot = "0.12" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +# In-process DSP +ssstretch = "0.1" # Signalsmith Stretch — independent pitch + tempo +biquad = "0.5" # IIR biquad filters for cutoff and band split +atomic_float = "1" # AtomicF32 for lock-free param state +timestretch = "0.4" # detect_bpm / BeatGrid for waveform overlay + [profile.release] opt-level = 3 lto = true diff --git a/README.md b/README.md index 7b20334..b7b1c12 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Terminal DJ suite — Rust + Ratatui + PipeWire graph DSP. │ PipeWire Graph │ │ │ │ djdeck.deck.a ──► input.djdeck.chain.a │ -│ [rubberband LADSPA] │ +│ [pitch → lpL/lpR → reverb] │ │ output.djdeck.chain.a ──► default sink │ │ │ │ (same for z, s, x) │ @@ -32,29 +32,55 @@ Terminal DJ suite — Rust + Ratatui + PipeWire graph DSP. ``` Each deck stream outputs raw decoded PCM. The PipeWire graph handles all DSP: -- **Pitch shifting** and **time stretching** — rubberband LADSPA via `libpipewire-module-filter-chain` +- **Pitch shifting** — rubberband `Semitones` port (requires `ladspa-rubberband`) +- **Cutoff filter** — `lowpass_iir` + `highpass_iir` (swh-plugins), `Cutoff Frequency` port: + - Negative value → lowpass (kill highs), positive → highpass (kill lows), 0 → flat +- **Reverb** — `plate` `Dry/wet mix` port (swh-plugins): 0=dry → 1=wet - **Volume (fader)** — `wpctl set-volume` on the stream node -- **Pitch/Tempo params** — `pw-cli set-param Props '{ params = [...] }'` +- **Tempo** — varispeed in audio engine; rubberband compensates pitch automatically ## Dependencies ### System +**Required:** ```bash # Arch pacman -S pipewire wireplumber pipewire-audio ladspa rubberband # Ubuntu / Debian -apt install pipewire wireplumber pipewire-audio ladspa-sdk rubberband-ladspa +apt install pipewire wireplumber ladspa-sdk rubberband-ladspa # Fedora dnf install pipewire wireplumber ladspa rubberband-ladspa ``` +**Required** (cutoff + reverb): +```bash +# Arch +pacman -S swh-plugins + +# Ubuntu / Debian +apt install swh-plugins + +# Fedora +dnf install swh-plugins +``` + Verify the rubberband LADSPA plugin is present: ```bash -analyseplugin ladspa-rubberband.so -# should list labels including: rubberband_pitchshifter_stereo +python3 -c " +import ctypes; lib = ctypes.CDLL('/usr/lib/ladspa/ladspa-rubberband.so') +lib.ladspa_descriptor.restype = ctypes.c_void_p +class D(ctypes.Structure): + _fields_ = [('uid',ctypes.c_ulong),('label',ctypes.c_char_p),('props',ctypes.c_int),('name',ctypes.c_char_p)] +i=0 +while True: + d=lib.ladspa_descriptor(i) + if not d: break + print(ctypes.cast(d,ctypes.POINTER(D)).contents.label.decode()); i+=1 +" +# should include: rubberband-pitchshifter-stereo ``` ### Rust @@ -77,10 +103,11 @@ cargo build --release Logs → `/tmp/djdeck.log`. Set `RUST_LOG=debug` for verbose output. On startup djdeck will: -1. Load `libpipewire-module-filter-chain` for each deck (rubberband LADSPA) -2. Start 4 PipeWire playback streams, each targeting its filter-chain sink +1. Start 4 PipeWire playback streams, each targeting its filter-chain sink +2. Load `libpipewire-module-filter-chain` for each deck via `pw-cli load-module` (pitch → lpL/lpR → reverb) 3. Wait 800ms for PipeWire to settle, then resolve node IDs via `pw-dump` -4. Start the TUI and OSC server +4. Explicitly link each chain's output to the default sink via `pw-link -L` +5. Start the TUI and OSC server Node IDs for each deck are shown in the deck panel title: `[pw:stream/filter]`. @@ -96,8 +123,10 @@ Node IDs for each deck are shown in the deck panel title: `[pw:stream/filter]`. | `c` | Set cue point | | `v` | Jump to cue point | | `↑ / ↓` | Fader ±5% (via `wpctl set-volume`) | -| `[ / ]` | Pitch ±0.5 semitones (via `pw-cli`, rubberband) | -| `, / .` | Tempo ±1% (via `pw-cli`, rubberband) | +| `[ / ]` | Pitch ±0.5 semitones (rubberband `Semitones` port) | +| `, / .` | Tempo ±1% (varispeed; rubberband compensates pitch) | +| `- / =` | Cutoff ±500 (negative=LP kills highs, positive=HP kills lows, 0=flat) | +| `o / p` | Reverb wet ±5% (requires swh-plugins) | | `q` | Quit (unloads filter-chain modules) | **File selector:** type to filter, `↑/↓` navigate, `Enter` load, `Esc` cancel. diff --git a/src/app.rs b/src/app.rs index 46da958..8e2679c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,17 +1,14 @@ /// App — top-level state and event loop. /// -/// Startup sequence: -/// 1. Load filter-chain modules for all 4 decks via pw-cli -/// 2. Start PipeWire audio streams (they auto-connect to filter-chain sinks) -/// 3. Resolve PipeWire node IDs (pw-dump) with a short settle delay -/// 4. Start OSC server -/// 5. Run TUI + event loop +/// Startup: +/// 1. Start the audio engine (streams autoconnect to the default sink). +/// 2. Start the OSC server. +/// 3. Run the TUI event loop. /// -/// Runtime param changes: -/// Fader → wpctl set-volume -/// Pitch → pw-cli set-param Props { params = ["pitch:Pitch scale" ""] } -/// Tempo → pw-cli set-param Props { params = ["pitch:Time ratio" ""] } -use std::{io, time::Duration}; +/// All effect changes are lock-free atomic writes into the per-deck +/// `DeckParams` shared with the realtime DSP callback — no subprocesses, +/// no PipeWire object IDs, no `node.target` routing. +use std::{io, sync::atomic::Ordering, time::Duration}; use anyhow::Result; use crossterm::{ @@ -24,59 +21,30 @@ use ratatui::{backend::CrosstermBackend, Terminal}; use crate::{ audio::{AudioCmd, AudioEngine, AudioStatus}, deck::{Deck, DeckId, PlayState}, - effect_chain::EffectChain, + dsp::DeckParams, file_selector::FileSelector, osc::{self, OscAppCmd}, tui, }; use std::path::PathBuf; +use std::sync::Arc; pub struct App { - pub decks: Vec, + pub decks: Vec, pub focused_deck: usize, pub file_selector: Option, - audio: AudioEngine, - chains: Vec, // one per deck, in order - osc_rx: crossbeam_channel::Receiver, + audio: Option, + /// Per-deck atomic params, shared with the audio engine's realtime callback. + /// Populated when the audio engine starts. + params: Vec>, + osc_rx: crossbeam_channel::Receiver, audio_dir: PathBuf, - - /// Track if we need to redraw the UI - dirty: bool, + dirty: bool, } impl App { pub fn new(audio_dir: Option) -> Result { - // 1. Load filter-chain modules for all 4 decks - tracing::info!("Loading PipeWire filter-chain modules…"); - let mut chains = Vec::new(); - for i in 0..4 { - let deck_id = DeckId::from_index(i).unwrap(); - match EffectChain::load(deck_id) { - Ok(chain) => { - tracing::info!("Filter-chain loaded for deck {}", deck_id.label()); - chains.push(chain); - } - Err(e) => { - tracing::warn!( - "Filter-chain load failed for deck {} (rubberband LADSPA not installed?): {e}", - deck_id.label() - ); - // Push a stub with no module_id so the rest of the app still works - chains.push(EffectChain { - deck: deck_id, - module_id: None, - node_id: None, - node_name: EffectChain::node_name_for(deck_id), - }); - } - } - } - - // 2. Start audio engine (PipeWire streams) - let audio = AudioEngine::new()?; - - // 3. OSC channel let (osc_tx, osc_rx) = crossbeam_channel::bounded(128); osc::start_osc_server(osc_tx)?; @@ -84,14 +52,16 @@ impl App { .map(|i| Deck::new(DeckId::from_index(i).unwrap())) .collect(); - let audio_dir = audio_dir.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); + let audio_dir = audio_dir.unwrap_or_else(|| { + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) + }); Ok(Self { decks, focused_deck: 0, file_selector: None, - audio, - chains, + audio: None, + params: Vec::new(), osc_rx, audio_dir, dirty: true, @@ -102,12 +72,23 @@ impl App { enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen)?; - let backend = CrosstermBackend::new(stdout); + let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; - // Give PipeWire a moment to settle before resolving node IDs - tokio::time::sleep(Duration::from_millis(800)).await; - self.resolve_all_node_ids(); + let engine = AudioEngine::new()?; + self.params = engine.params.clone(); + self.audio = Some(engine); + + // Seed the realtime params with each deck's initial mix state so the + // first audio block reflects the on-screen defaults exactly. + for (i, p) in self.params.iter().enumerate() { + let d = &self.decks[i]; + p.fader.store(d.fader, Ordering::Relaxed); + p.pitch.store(d.pitch, Ordering::Relaxed); + p.tempo.store(d.tempo, Ordering::Relaxed); + p.cutoff.store(d.cutoff, Ordering::Relaxed); + p.reverb_wet.store(d.reverb, Ordering::Relaxed); + } let result = self.event_loop(&mut terminal).await; @@ -117,67 +98,38 @@ impl App { result } - /// Query pw-dump and fill in filter_node_id for each deck. - fn resolve_all_node_ids(&mut self) { - for (i, chain) in self.chains.iter_mut().enumerate() { - if chain.module_id.is_none() { continue; } - match chain.resolve_node_id() { - Ok(()) => { - self.decks[i].filter_node_id = chain.node_id; - tracing::info!( - "Deck {} filter node id = {:?}", - chain.deck.label(), - chain.node_id - ); - } - Err(e) => tracing::warn!("Could not resolve filter node for deck {}: {e}", chain.deck.label()), - } - } - } - async fn event_loop( &mut self, terminal: &mut Terminal>, ) -> Result<()> { - // Track if any deck is playing to determine frame rate - let mut playing = false; - let mut last_frame_time = std::time::Instant::now(); + let mut last_frame = std::time::Instant::now(); loop { - // Check if any deck is playing - playing = self.decks.iter().any(|d| d.play_state == PlayState::Playing); - - // Use adaptive frame rate: 60fps when playing, 30fps when idle - let frame_interval = if playing { - Duration::from_millis(16) // ~60fps + let playing = self.decks.iter().any(|d| d.play_state == PlayState::Playing); + let interval = if playing { + Duration::from_millis(16) } else { - Duration::from_millis(33) // ~30fps + Duration::from_millis(33) }; - // Only redraw if dirty or if we're at the right time - if self.dirty || last_frame_time.elapsed() >= frame_interval { + if self.dirty || last_frame.elapsed() >= interval { terminal.draw(|f| tui::draw(f, self))?; self.dirty = false; - last_frame_time = std::time::Instant::now(); + last_frame = std::time::Instant::now(); } - // Drain audio status (limit to 100 messages per frame to prevent spinning) - let mut status_count = 0; - while let Ok(status) = self.audio.status_rx.try_recv() { + let mut n = 0; + while let Ok(status) = self.audio.as_ref().unwrap().status_rx.try_recv() { self.handle_audio_status(status); - status_count += 1; - if status_count >= 100 { - break; - } + n += 1; + if n >= 200 { break; } } - // Drain OSC commands while let Ok(cmd) = self.osc_rx.try_recv() { if self.handle_osc_cmd(cmd)? { return Ok(()); } } - // Keyboard (use adaptive poll interval) - if event::poll(frame_interval)? { + if event::poll(interval)? { if let Event::Key(key) = event::read()? { if self.handle_key(key)? { return Ok(()); } } @@ -189,39 +141,62 @@ impl App { fn handle_audio_status(&mut self, status: AudioStatus) { match status { - AudioStatus::Loaded { deck, duration_samples, sample_rate, name, stream_node_id } => { + AudioStatus::Metadata { deck, name, artist, album, duration_samples, sample_rate } => { let d = &mut self.decks[deck as usize]; - d.duration = duration_samples; - d.sample_rate = sample_rate; d.track_name = Some(name.clone()); - d.position = 0; - d.play_state = PlayState::Stopped; - // stream_node_id from audio engine is 0 (placeholder); resolve properly - if stream_node_id > 0 { - d.stream_node_id = Some(stream_node_id); - } else { - // Try to find our stream node in pw-dump by node name - if let Some(id) = find_pw_node_id( - &format!("djdeck.deck.{}", deck.node_suffix()) - ) { - d.stream_node_id = Some(id); - } + d.artist = artist; + d.album = album; + if duration_samples > 0 { + d.duration = duration_samples; } + d.sample_rate = sample_rate; + d.play_state = PlayState::Stopped; + d.position = 0; + d.waveform_bands.clear(); + d.bpm = None; osc::send_osc( &format!("/deck/{}/loaded", deck as usize), vec![rosc::OscType::String(name)], ); + self.dirty = true; } + + AudioStatus::WaveformChunk { deck, bands, .. } => { + self.decks[deck as usize].waveform_bands.extend_from_slice(&bands); + self.dirty = true; + } + + AudioStatus::Bpm { deck, bpm } => { + self.decks[deck as usize].bpm = Some(bpm); + self.dirty = true; + } + + AudioStatus::PcmChunk { deck, load_gen, frames, sample_rate } => { + let _ = self.audio.as_ref().unwrap().cmd_tx.send( + crate::audio::AudioCmd::AppendPcm { deck, load_gen, frames } + ); + self.decks[deck as usize].sample_rate = sample_rate; + } + + AudioStatus::Ready { deck, load_gen, duration_samples, sample_rate } => { + let d = &mut self.decks[deck as usize]; + d.duration = duration_samples; + d.sample_rate = sample_rate; + let _ = self.audio.as_ref().unwrap().cmd_tx.send( + crate::audio::AudioCmd::MarkDecodingDone { deck, load_gen } + ); + self.dirty = true; + } + AudioStatus::Position { deck, position } => { self.decks[deck as usize].position = position; self.dirty = true; osc::send_osc( &format!("/deck/{}/position", deck as usize), - vec![rosc::OscType::Float( - self.decks[deck as usize].progress() - )], + vec![rosc::OscType::Float(self.decks[deck as usize].progress())], ); } + AudioStatus::Vu { deck, l, r } => { let d = &mut self.decks[deck as usize]; d.vu.0 = (d.vu.0 * 0.65).max(l); @@ -232,10 +207,12 @@ impl App { vec![rosc::OscType::Float(l), rosc::OscType::Float(r)], ); } + AudioStatus::TrackEnded(deck) => { self.decks[deck as usize].play_state = PlayState::Stopped; self.dirty = true; } + AudioStatus::Error { deck, msg } => { tracing::error!("Deck {:?}: {}", deck, msg); self.decks[deck as usize].track_name = Some(format!("ERR: {}", msg)); @@ -244,17 +221,19 @@ impl App { } } - // ─── OSC commands ───────────────────────────────────────────────────────── + // ─── OSC ────────────────────────────────────────────────────────────────── fn handle_osc_cmd(&mut self, cmd: OscAppCmd) -> Result { match cmd { OscAppCmd::Audio(audio_cmd) => { self.sync_play_state_from_audio_cmd(&audio_cmd); - let _ = self.audio.cmd_tx.send(audio_cmd); + let _ = self.audio.as_ref().unwrap().cmd_tx.send(audio_cmd); } - OscAppCmd::SetFader { deck, value } => self.apply_fader(deck, value), - OscAppCmd::SetPitch { deck, semitones } => self.apply_pitch(deck, semitones), - OscAppCmd::SetTempo { deck, multiplier } => self.apply_tempo(deck, multiplier), + OscAppCmd::SetFader { deck, value } => self.apply_fader(deck, value), + OscAppCmd::SetPitch { deck, semitones } => self.apply_pitch(deck, semitones), + OscAppCmd::SetTempo { deck, multiplier } => self.apply_tempo(deck, multiplier), + OscAppCmd::SetCutoff { deck, hz } => self.apply_cutoff(deck, hz), + OscAppCmd::SetReverb { deck, wet } => self.apply_reverb(deck, wet), } Ok(false) } @@ -276,102 +255,41 @@ impl App { } match (key.modifiers, key.code) { - (_, KeyCode::Char('q')) => return Ok(true), - (KeyModifiers::CONTROL, KeyCode::Char('c')) => return Ok(true), + (_, KeyCode::Char('q')) + | (KeyModifiers::CONTROL, KeyCode::Char('c')) => return Ok(true), - // Deck focus - (KeyModifiers::NONE, KeyCode::Char('a')) => { - self.focused_deck = 0; - self.dirty = true; - }, - (KeyModifiers::NONE, KeyCode::Char('z')) => { - self.focused_deck = 1; - self.dirty = true; - }, - (KeyModifiers::NONE, KeyCode::Char('s')) => { - self.focused_deck = 2; - self.dirty = true; - }, - (KeyModifiers::NONE, KeyCode::Char('x')) => { - self.focused_deck = 3; - self.dirty = true; - }, + (KeyModifiers::NONE, KeyCode::Char('a')) => { self.focused_deck = 0; self.dirty = true; } + (KeyModifiers::NONE, KeyCode::Char('s')) => { self.focused_deck = 2; self.dirty = true; } + (KeyModifiers::NONE, KeyCode::Char('z')) => { self.focused_deck = 1; self.dirty = true; } + (KeyModifiers::NONE, KeyCode::Char('x')) => { self.focused_deck = 3; self.dirty = true; } - // Open file selector - (KeyModifiers::SHIFT, KeyCode::Char('A')) => { - self.open_selector(0); - self.dirty = true; - }, - (KeyModifiers::SHIFT, KeyCode::Char('Z')) => { - self.open_selector(1); - self.dirty = true; - }, - (KeyModifiers::SHIFT, KeyCode::Char('S')) => { - self.open_selector(2); - self.dirty = true; - }, - (KeyModifiers::SHIFT, KeyCode::Char('X')) => { - self.open_selector(3); - self.dirty = true; - }, + (KeyModifiers::SHIFT, KeyCode::Char('A')) => { self.open_selector(0); self.dirty = true; } + (KeyModifiers::SHIFT, KeyCode::Char('S')) => { self.open_selector(2); 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; } - // Transport - (KeyModifiers::NONE, KeyCode::Char(' ')) => { - self.toggle_play(); - 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; - }, + (KeyModifiers::NONE, KeyCode::Char(' ')) => { self.toggle_play(); 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; } - // Cue - (KeyModifiers::NONE, KeyCode::Char('c')) => { - self.set_cue(); - self.dirty = true; - }, - (KeyModifiers::NONE, KeyCode::Char('v')) => { - self.goto_cue(); - self.dirty = true; - }, + (KeyModifiers::NONE, KeyCode::Char('c')) => { self.set_cue(); self.dirty = true; } + (KeyModifiers::NONE, KeyCode::Char('v')) => { self.goto_cue(); self.dirty = true; } - // Fader → wpctl - (KeyModifiers::NONE, KeyCode::Up) => { - self.nudge_fader(0.05); - self.dirty = true; - }, - (KeyModifiers::NONE, KeyCode::Down) => { - self.nudge_fader(-0.05); - self.dirty = true; - }, + (KeyModifiers::NONE, KeyCode::Up) => { self.nudge_fader(0.05); self.dirty = true; } + (KeyModifiers::NONE, KeyCode::Down) => { self.nudge_fader(-0.05); self.dirty = true; } - // Pitch → pw-cli filter-chain Props - (KeyModifiers::NONE, KeyCode::Char(']')) => { - self.nudge_pitch(0.5); - self.dirty = true; - }, - (KeyModifiers::NONE, KeyCode::Char('[')) => { - self.nudge_pitch(-0.5); - self.dirty = true; - }, + (KeyModifiers::NONE, KeyCode::Char(']')) => { self.nudge_pitch(0.5); self.dirty = true; } + (KeyModifiers::NONE, KeyCode::Char('[')) => { self.nudge_pitch(-0.5); self.dirty = true; } - // Tempo → pw-cli filter-chain Props - (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(-500.0); 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; } _ => {} } @@ -390,22 +308,21 @@ impl App { let deck = sel.deck; self.decks[deck as usize].track = Some(path.clone()); self.focused_deck = deck as usize; - let _ = self.audio.cmd_tx.send(AudioCmd::Load { deck, path }); + let _ = self.audio.as_ref().unwrap().cmd_tx.send(AudioCmd::Load { deck, path }); } } self.file_selector = None; } - KeyCode::Char(c) => { self.file_selector.as_mut().unwrap().push_char(c); } + KeyCode::Char(c) => { self.file_selector.as_mut().unwrap().push_char(c); } _ => {} } + self.dirty = true; Ok(false) } // ─── Helpers ────────────────────────────────────────────────────────────── - fn focused_id(&self) -> DeckId { - DeckId::from_index(self.focused_deck).unwrap() - } + fn focused_id(&self) -> DeckId { DeckId::from_index(self.focused_deck).unwrap() } fn open_selector(&mut self, idx: usize) { if let Some(id) = DeckId::from_index(idx) { @@ -420,11 +337,11 @@ impl App { match d.play_state { PlayState::Playing => { d.play_state = PlayState::Paused; - let _ = self.audio.cmd_tx.send(AudioCmd::Pause(deck)); + let _ = self.audio.as_ref().unwrap().cmd_tx.send(AudioCmd::Pause(deck)); } _ => { d.play_state = PlayState::Playing; - let _ = self.audio.cmd_tx.send(AudioCmd::Play(deck)); + let _ = self.audio.as_ref().unwrap().cmd_tx.send(AudioCmd::Play(deck)); } } } @@ -432,20 +349,20 @@ impl App { fn stop_focused(&mut self) { let deck = self.focused_id(); self.decks[self.focused_deck].play_state = PlayState::Stopped; - let _ = self.audio.cmd_tx.send(AudioCmd::Stop(deck)); + let _ = self.audio.as_ref().unwrap().cmd_tx.send(AudioCmd::Stop(deck)); } fn seek_relative(&mut self, delta: f32) { let deck = self.focused_id(); - let pos = (self.decks[self.focused_deck].progress() + delta).clamp(0.0, 1.0); - let _ = self.audio.cmd_tx.send(AudioCmd::Seek { deck, position: pos }); + let pos = (self.decks[self.focused_deck].progress() + delta).clamp(0.0, 1.0); + let _ = self.audio.as_ref().unwrap().cmd_tx.send(AudioCmd::Seek { deck, position: pos }); } fn set_cue(&mut self) { let deck = self.focused_id(); - let pos = self.decks[self.focused_deck].position; + let pos = self.decks[self.focused_deck].position; self.decks[self.focused_deck].cue_point = Some(pos); - let _ = self.audio.cmd_tx.send(AudioCmd::SetCue(deck)); + let _ = self.audio.as_ref().unwrap().cmd_tx.send(AudioCmd::SetCue(deck)); } fn goto_cue(&mut self) { @@ -453,27 +370,25 @@ impl App { if let Some(p) = self.decks[self.focused_deck].cue_point { self.decks[self.focused_deck].position = p; } - let _ = self.audio.cmd_tx.send(AudioCmd::GotoCue(deck)); + let _ = self.audio.as_ref().unwrap().cmd_tx.send(AudioCmd::GotoCue(deck)); } + // ─── Effect appliers: lock-free writes into shared DeckParams ──────────── + fn nudge_fader(&mut self, delta: f32) { - let deck = self.focused_id(); - let new_val = (self.decks[self.focused_deck].fader + delta).clamp(0.0, 1.0); + let deck = self.focused_id(); + let new_val = (self.decks[self.focused_deck].fader + delta).clamp(0.0, 1.5); self.apply_fader(deck, new_val); } fn apply_fader(&mut self, deck: DeckId, value: f32) { - let value = value.clamp(0.0, 1.0); + let value = value.clamp(0.0, 1.5); self.decks[deck as usize].fader = value; - if let Some(node_id) = self.decks[deck as usize].stream_node_id { - if let Err(e) = EffectChain::set_volume(node_id, value) { - tracing::warn!("set_volume deck {}: {e}", deck.label()); - } - } + self.params[deck as usize].fader.store(value, Ordering::Relaxed); } fn nudge_pitch(&mut self, delta: f32) { - let deck = self.focused_id(); + let deck = self.focused_id(); let new_val = (self.decks[self.focused_deck].pitch + delta).clamp(-12.0, 12.0); self.apply_pitch(deck, new_val); } @@ -481,13 +396,11 @@ impl App { fn apply_pitch(&mut self, deck: DeckId, semitones: f32) { let semitones = semitones.clamp(-12.0, 12.0); self.decks[deck as usize].pitch = semitones; - if let Err(e) = self.chains[deck as usize].set_pitch(semitones) { - tracing::warn!("set_pitch deck {}: {e}", deck.label()); - } + self.params[deck as usize].pitch.store(semitones, Ordering::Relaxed); } fn nudge_tempo(&mut self, delta: f32) { - let deck = self.focused_id(); + let deck = self.focused_id(); let new_val = (self.decks[self.focused_deck].tempo + delta).clamp(0.5, 2.0); self.apply_tempo(deck, new_val); } @@ -495,25 +408,30 @@ impl App { fn apply_tempo(&mut self, deck: DeckId, multiplier: f32) { let multiplier = multiplier.clamp(0.5, 2.0); self.decks[deck as usize].tempo = multiplier; - if let Err(e) = self.chains[deck as usize].set_tempo(multiplier) { - tracing::warn!("set_tempo deck {}: {e}", deck.label()); - } + self.params[deck as usize].tempo.store(multiplier, Ordering::Relaxed); + } + + 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); + self.apply_cutoff(deck, new_val); + } + + fn apply_cutoff(&mut self, deck: DeckId, value: f32) { + let value = value.clamp(-20_000.0, 20_000.0); + self.decks[deck as usize].cutoff = value; + self.params[deck as usize].cutoff.store(value, Ordering::Relaxed); + } + + fn nudge_reverb(&mut self, delta: f32) { + let deck = self.focused_id(); + let new_val = (self.decks[self.focused_deck].reverb + delta).clamp(0.0, 1.0); + self.apply_reverb(deck, new_val); + } + + fn apply_reverb(&mut self, deck: DeckId, wet: f32) { + let wet = wet.clamp(0.0, 1.0); + self.decks[deck as usize].reverb = wet; + self.params[deck as usize].reverb_wet.store(wet, Ordering::Relaxed); } } - -// ─── pw-dump node ID lookup ──────────────────────────────────────────────────── - -/// Find a PipeWire node's object ID by its node.name property, via pw-dump. -fn find_pw_node_id(node_name: &str) -> Option { - let out = std::process::Command::new("pw-dump").output().ok()?; - let json: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?; - let arr = json.as_array()?; - for obj in arr { - if obj.get("type")?.as_str()? != "PipeWire:Interface:Node" { continue; } - let props = obj.get("info")?.get("props")?; - if props.get("node.name")?.as_str()? == node_name { - return obj.get("id")?.as_u64().map(|i| i as u32); - } - } - None -} diff --git a/src/audio.rs b/src/audio.rs index 04e083f..a9d403f 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -3,11 +3,12 @@ /// Responsibilities: /// - Decode audio files (symphonia) into interleaved stereo f32 PCM /// - Drive one PipeWire playback stream per deck -/// - Each stream targets the deck's filter-chain sink node -/// (djdeck.chain. input side) so all DSP happens in the graph -/// - Report position, VU, and load status back to the app via channel -/// -/// No DSP happens here. Pitch/tempo/EQ all live in the PipeWire graph. +/// - Run the per-deck DSP graph (`crate::dsp::DeckDsp`) inside the realtime +/// process callback: pitch + tempo + cutoff + reverb + fader, all in-process +/// - Stream connects with AUTOCONNECT to the default sink (no filter chains, +/// no `node.target`) +/// - Effect parameters flow lock-free via `Arc` atomics shared +/// with the app use std::{ path::PathBuf, sync::{Arc, Mutex}, @@ -21,13 +22,16 @@ use symphonia::core::{ codecs::DecoderOptions, formats::FormatOptions, io::MediaSourceStream, - meta::MetadataOptions, + meta::{MetadataOptions, StandardTagKey}, probe::Hint, }; -use crate::{deck::DeckId, effect_chain::EffectChain}; +use crate::{ + deck::DeckId, + dsp::{DeckDsp, DeckParams}, +}; -// ─── Commands ─────────────────────────────────────────────────────────────── +// ─── Commands ──────────────────────────────────────────────────────────────── #[derive(Debug)] pub enum AudioCmd { @@ -38,124 +42,170 @@ pub enum AudioCmd { Seek { deck: DeckId, position: f32 }, SetCue(DeckId), GotoCue(DeckId), - // Volume is handled by wpctl; we just record it in state. - // Fader changes are applied directly from app.rs via EffectChain::set_volume. + /// Append a decoded PCM chunk (interleaved stereo f32) to a deck's buffer. + /// `load_gen` must match the deck's current generation to guard against + /// stale decodes from a previous Load. + AppendPcm { deck: DeckId, load_gen: u64, frames: Vec }, + /// Signal that decoding is complete for this generation. + MarkDecodingDone { deck: DeckId, load_gen: u64 }, } -// ─── Status updates ───────────────────────────────────────────────────────── +// ─── Status updates ────────────────────────────────────────────────────────── #[derive(Debug, Clone)] pub enum AudioStatus { - Loaded { - deck: DeckId, + /// Sent immediately from file headers — before full decode. + Metadata { + deck: DeckId, + name: String, + artist: Option, + album: Option, + /// Estimated from codec_params; exact after full decode. duration_samples: u64, + sample_rate: u32, + }, + /// Waveform peak data sent in chunks as the file decodes (~4096-sample chunks). + /// Each entry is `[bass_peak, mid_peak, high_peak]` — peak amplitudes after + /// splitting the mono mix through a Linkwitz-Riley-ish crossover (LP 250 Hz, + /// BP 250–4000 Hz, HP 4000 Hz). The UI blends these into per-column color. + WaveformChunk { + deck: DeckId, + bands: Vec<[f32; 3]>, + /// Sample-frame offset of the first peak in this chunk. + frame_offset: u64, + }, + /// BPM estimate from `timestretch::detect_bpm` over the first ~60 s of + /// decoded audio. Emitted once per Load, after `Ready`. + Bpm { deck: DeckId, bpm: f32 }, + /// Decoded PCM chunk — routed back through the app into AudioCmd::AppendPcm. + PcmChunk { + deck: DeckId, + load_gen: u64, + frames: Vec, sample_rate: u32, - name: String, - /// PipeWire object ID of the stream node (for wpctl volume) - stream_node_id: u32, }, - Position { - deck: DeckId, - position: u64, - }, - Vu { - deck: DeckId, - l: f32, - r: f32, + /// Full PCM is in memory; deck is ready to play. + Ready { + deck: DeckId, + load_gen: u64, + duration_samples: u64, + sample_rate: u32, }, + Position { deck: DeckId, position: u64 }, + Vu { deck: DeckId, l: f32, r: f32 }, TrackEnded(DeckId), - Error { - deck: DeckId, - msg: String, - }, + Error { deck: DeckId, msg: String }, } // ─── Per-deck internal state ────────────────────────────────────────────────── struct DeckState { - id: DeckId, + id: DeckId, playing: bool, - pcm: Vec, // interleaved stereo f32 + /// Play was requested before PCM arrived; start as soon as first chunk lands. + pending_play: bool, + /// True once the decode thread has sent every frame (safe to stop at EOF). + decoding_done: bool, + /// Incremented on each Load; guards AppendPcm from a stale decode thread. + load_gen: u64, + pcm: Vec, sample_rate: u32, - read_pos: usize, // byte index into pcm (always even) - cue_pos: usize, - - /// Track last VU update time to rate-limit updates + /// Frame index into `pcm` (no fractional resampling — the DSP stretcher + /// handles tempo). Advanced by the number of frames consumed each block. + read_pos: usize, + cue_pos: usize, + dsp: DeckDsp, + params: Arc, last_vu_update: std::time::Instant, } impl DeckState { - fn new(id: DeckId) -> Self { + fn new(id: DeckId, sample_rate: u32, params: Arc) -> Self { Self { id, - playing: false, - pcm: Vec::new(), - sample_rate: 44100, - read_pos: 0, - cue_pos: 0, + playing: false, + pending_play: false, + decoding_done: true, + load_gen: 0, + pcm: Vec::new(), + sample_rate, + read_pos: 0, + cue_pos: 0, + dsp: DeckDsp::new(sample_rate), + params, last_vu_update: std::time::Instant::now(), } } + + fn total_frames(&self) -> usize { self.pcm.len() / 2 } } -// ─── Public handle ───────────────────────────────────────────────────── +// ─── Public handle ──────────────────────────────────────────────────────────── pub struct AudioEngine { - pub cmd_tx: Sender, + pub cmd_tx: Sender, pub status_rx: Receiver, + /// One `Arc` per deck — the app writes effect parameters into + /// these atomics directly. The realtime callback snapshots them per block. + pub params: Vec>, } impl AudioEngine { pub fn new() -> Result { - let (cmd_tx, cmd_rx) = bounded::(64); - let (status_tx, status_rx) = bounded::(512); + let (cmd_tx, cmd_rx) = bounded::(64); + let (status_tx, status_rx) = bounded::(1024); + + let params: Vec> = (0..4).map(|_| DeckParams::new()).collect(); + let params_for_engine = params.clone(); thread::Builder::new() .name("audio-engine".into()) .spawn(move || { - if let Err(e) = engine_thread(cmd_rx, status_tx) { + if let Err(e) = engine_thread(cmd_rx, status_tx, params_for_engine) { tracing::error!("Audio engine crashed: {e}"); } })?; - Ok(Self { cmd_tx, status_rx }) + Ok(Self { cmd_tx, status_rx, params }) } } -// ─── Engine thread ────────────────────────────────────────────────────── +// ─── Engine thread ──────────────────────────────────────────────────────────── -fn engine_thread(cmd_rx: Receiver, status_tx: Sender) -> Result<()> { +const STREAM_SR: u32 = 44_100; + +fn engine_thread( + cmd_rx: Receiver, + status_tx: Sender, + params: Vec>, +) -> Result<()> { use pipewire::{context::ContextRc, main_loop::MainLoopRc, stream::StreamRc}; let main_loop = MainLoopRc::new(None)?; - let context = ContextRc::new(&main_loop, None)?; - let core = context.connect_rc(None)?; + let context = ContextRc::new(&main_loop, None)?; + let core = context.connect_rc(None)?; - // Shared per-deck state (written by command thread, read by PW callback) let deck_states: Vec>> = (0..4) - .map(|i| Arc::new(Mutex::new(DeckState::new(DeckId::from_index(i).unwrap())))) + .map(|i| { + let id = DeckId::from_index(i).unwrap(); + Arc::new(Mutex::new(DeckState::new(id, STREAM_SR, params[i].clone()))) + }) .collect(); - // Build one PipeWire stream per deck - let mut _streams: Vec = Vec::new(); - let mut _listeners = Vec::new(); + let mut _streams: Vec = Vec::new(); + let mut _listeners = Vec::new(); for (i, ds) in deck_states.iter().enumerate() { - let deck_id = DeckId::from_index(i).unwrap(); - let ds_cb = Arc::clone(ds); + let deck_id = DeckId::from_index(i).unwrap(); + let ds_cb = Arc::clone(ds); let status_tx_cb = status_tx.clone(); - // Target: the input (sink) side of this deck's filter-chain node - let target_name = format!("input.{}", EffectChain::node_name_for(deck_id)); - let props = pipewire::properties::properties! { *pipewire::keys::MEDIA_TYPE => "Audio", *pipewire::keys::MEDIA_CATEGORY => "Playback", *pipewire::keys::MEDIA_ROLE => "Music", *pipewire::keys::APP_NAME => "djdeck", *pipewire::keys::NODE_NAME => format!("djdeck.deck.{}", deck_id.node_suffix()), - // Route to our filter-chain sink - "node.target" => target_name, }; let stream = StreamRc::new( @@ -172,41 +222,47 @@ fn engine_thread(cmd_rx: Receiver, status_tx: Sender) -> if let Some(mut buf) = stream.dequeue_buffer() { let datas = buf.datas_mut(); - if datas.is_empty() { - return; - } - + if datas.is_empty() { return; } let data = &mut datas[0]; - let out_bytes = data.data().unwrap(); - let n_frames = out_bytes.len() / 8; // f32 stereo + let n_frames = out_bytes.len() / 8; let out = unsafe { - std::slice::from_raw_parts_mut(out_bytes.as_ptr() as *mut f32, n_frames * 2) + std::slice::from_raw_parts_mut( + out_bytes.as_ptr() as *mut f32, + n_frames * 2, + ) }; - let mut vu_l = 0.0f32; - let mut vu_r = 0.0f32; - if ds.playing && !ds.pcm.is_empty() { - for frame in 0..n_frames { - if ds.read_pos + 1 >= ds.pcm.len() { + let total = ds.total_frames(); + if ds.read_pos >= total { + if ds.decoding_done { ds.playing = false; - out[frame * 2] = 0.0; - out[frame * 2 + 1] = 0.0; let _ = status_tx_cb.try_send(AudioStatus::TrackEnded(ds.id)); - continue; + } + out.fill(0.0); + } else { + // Split disjoint field borrows so the DSP can read + // `pcm` while mutating its internal state. + let start = ds.read_pos; + let DeckState { dsp, pcm, params, .. } = &mut **ds; + let consumed = dsp.process(params, pcm, start, out); + ds.read_pos = (start + consumed).min(total); + + // VU from the post-DSP output. + let mut vu_l = 0.0f32; + let mut vu_r = 0.0f32; + for i in 0..n_frames { + vu_l = vu_l.max(out[i * 2].abs()); + vu_r = vu_r.max(out[i * 2 + 1].abs()); } - let l = ds.pcm[ds.read_pos]; - let r = ds.pcm[ds.read_pos + 1]; - ds.read_pos += 2; - - out[frame * 2] = l; - out[frame * 2 + 1] = r; - - vu_l = vu_l.max(l.abs()); - vu_r = vu_r.max(r.abs()); + let now = std::time::Instant::now(); + if now.duration_since(ds.last_vu_update) >= std::time::Duration::from_millis(16) { + let _ = status_tx_cb.try_send(AudioStatus::Vu { deck: ds.id, l: vu_l, r: vu_r }); + ds.last_vu_update = now; + } } } else { out.fill(0.0); @@ -216,29 +272,17 @@ fn engine_thread(cmd_rx: Receiver, status_tx: Sender) -> let chunk = data.chunk_mut(); *chunk.offset_mut() = 0; *chunk.stride_mut() = 8; - *chunk.size_mut() = (n_frames * 8) as u32; + *chunk.size_mut() = (n_frames * 8) as u32; } - let pos = (ds.read_pos / 2) as u64; let _ = status_tx_cb.try_send(AudioStatus::Position { deck: ds.id, - position: pos, + position: ds.read_pos as u64, }); - - // Rate-limit VU updates to ~60Hz (16ms) to reduce CPU usage - // while still providing smooth updates during playback - let now = std::time::Instant::now(); - if now.duration_since(ds.last_vu_update) >= std::time::Duration::from_millis(16) { - let _ = status_tx_cb.try_send(AudioStatus::Vu { - deck: ds.id, - l: vu_l, - r: vu_r, - }); - ds.last_vu_update = now; - } } }) - .register()?; // Connect the stream with F32LE stereo format + .register()?; + let pod_bytes = make_f32_stereo_pod()?; let mut params = [pipewire::spa::pod::Pod::from_bytes(&pod_bytes).unwrap()]; stream.connect( @@ -254,7 +298,7 @@ fn engine_thread(cmd_rx: Receiver, status_tx: Sender) -> _streams.push(stream); } - // Command processing thread — decodes files and mutates deck states + // Command processing thread let ds_cmd = deck_states.clone(); let st_cmd = status_tx.clone(); thread::spawn(move || { @@ -267,63 +311,52 @@ fn engine_thread(cmd_rx: Receiver, status_tx: Sender) -> Ok(()) } +// ─── Command handler ────────────────────────────────────────────────────────── + fn handle_cmd(cmd: AudioCmd, states: &[Arc>], status_tx: &Sender) { match cmd { AudioCmd::Load { deck, path } => { - let name = path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown") - .to_string(); - - match decode_file(&path) { - Ok((pcm, sr)) => { - let total = (pcm.len() / 2) as u64; - { - let mut ds = states[deck as usize].lock().unwrap(); - ds.pcm = pcm; - ds.sample_rate = sr; - ds.read_pos = 0; - ds.cue_pos = 0; - ds.playing = false; - } - // stream_node_id: we don't have it from here — the app queries - // pw-dump after connecting. Emit 0 as placeholder; app fills it in. - let _ = status_tx.send(AudioStatus::Loaded { - deck, - duration_samples: total, - sample_rate: sr, - name, - stream_node_id: 0, - }); + let load_gen = { + let mut ds = states[deck as usize].lock().unwrap(); + ds.pcm.clear(); + ds.read_pos = 0; + ds.cue_pos = 0; + ds.playing = false; + ds.pending_play = false; + ds.decoding_done = false; + ds.load_gen = ds.load_gen.wrapping_add(1); + ds.load_gen + }; + let st = status_tx.clone(); + thread::spawn(move || { + if let Err(e) = decode_file_streaming(&path, deck, load_gen, &st) { + let _ = st.send(AudioStatus::Error { deck, msg: e.to_string() }); } - Err(e) => { - let _ = status_tx.send(AudioStatus::Error { - deck, - msg: e.to_string(), - }); - } - } + }); } AudioCmd::Play(deck) => { let mut ds = states[deck as usize].lock().unwrap(); if !ds.pcm.is_empty() { ds.playing = true; + } else { + ds.pending_play = true; } } AudioCmd::Pause(deck) => { let mut ds = states[deck as usize].lock().unwrap(); - ds.playing = false; + ds.playing = false; + ds.pending_play = false; } AudioCmd::Stop(deck) => { let mut ds = states[deck as usize].lock().unwrap(); - ds.playing = false; - ds.read_pos = 0; + ds.playing = false; + ds.pending_play = false; + ds.read_pos = 0; } AudioCmd::Seek { deck, position } => { let mut ds = states[deck as usize].lock().unwrap(); - let total = ds.pcm.len(); - ds.read_pos = ((total as f32 * position.clamp(0.0, 1.0)) as usize / 2) * 2; + let total = ds.total_frames(); + ds.read_pos = ((total as f32 * position.clamp(0.0, 1.0)) as usize).min(total); } AudioCmd::SetCue(deck) => { let mut ds = states[deck as usize].lock().unwrap(); @@ -333,15 +366,46 @@ fn handle_cmd(cmd: AudioCmd, states: &[Arc>], status_tx: &Sende let mut ds = states[deck as usize].lock().unwrap(); ds.read_pos = ds.cue_pos; } + AudioCmd::AppendPcm { deck, load_gen, frames } => { + let mut ds = states[deck as usize].lock().unwrap(); + if ds.load_gen != load_gen { return; } // stale decode + let was_empty = ds.pcm.is_empty(); + ds.pcm.extend_from_slice(&frames); + if was_empty && ds.pending_play { + ds.playing = true; + ds.pending_play = false; + } + } + AudioCmd::MarkDecodingDone { deck, load_gen } => { + let mut ds = states[deck as usize].lock().unwrap(); + if ds.load_gen == load_gen { + ds.decoding_done = true; + } + } } } -// ─── Symphonia decode ───────────────────────────────────────────────────────────── +// ─── Symphonia streaming decode ─────────────────────────────────────────────── + +/// Number of PCM stereo frames per waveform peak bucket (~0.09 s at 44.1 kHz). +const PEAK_BUCKET: usize = 4096; + +/// Open file, emit Metadata immediately, then decode to PCM while streaming +/// both waveform peaks and raw PCM chunks every 64 peak-buckets (~6 s). +fn decode_file_streaming( + path: &PathBuf, + deck: DeckId, + load_gen: u64, + status_tx: &Sender, +) -> Result<()> { + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string(); -/// Decode any supported audio file into interleaved stereo f32 PCM. -fn decode_file(path: &PathBuf) -> Result<(Vec, u32)> { let file = std::fs::File::open(path)?; - let mss = MediaSourceStream::new(Box::new(file), Default::default()); + let mss = MediaSourceStream::new(Box::new(file), Default::default()); let mut hint = Hint::new(); if let Some(ext) = path.extension().and_then(|e| e.to_str()) { @@ -356,6 +420,7 @@ fn decode_file(path: &PathBuf) -> Result<(Vec, u32)> { )?; let mut format = probed.format; + let track = format .tracks() .iter() @@ -363,33 +428,218 @@ fn decode_file(path: &PathBuf) -> Result<(Vec, u32)> { .ok_or_else(|| anyhow!("No audio track found"))? .clone(); - let sr = track.codec_params.sample_rate.unwrap_or(44100); + let sr = track.codec_params.sample_rate.unwrap_or(44100); let channels = track.codec_params.channels.map(|c| c.count()).unwrap_or(2); - let mut decoder = - symphonia::default::get_codecs().make(&track.codec_params, &DecoderOptions::default())?; + + let est_duration = track.codec_params.n_frames.unwrap_or(0); + + let (artist, album) = extract_tags(&mut format); + + let _ = status_tx.send(AudioStatus::Metadata { + deck, + name, + artist, + album, + duration_samples: est_duration, + sample_rate: sr, + }); + + let mut decoder = symphonia::default::get_codecs() + .make(&track.codec_params, &DecoderOptions::default())?; let track_id = track.id; - let mut pcm: Vec = Vec::new(); + + let mut pcm_batch: Vec = Vec::with_capacity(64 * PEAK_BUCKET * 2); + let mut total_frames: u64 = 0; + // Mono accumulator used for BPM detection. Capped at 60 s. + const BPM_MAX_SAMPLES_SECS: u64 = 60; + let bpm_cap = (sr as u64 * BPM_MAX_SAMPLES_SECS) as usize; + let mut mono_for_bpm: Vec = Vec::with_capacity(bpm_cap.min(2_700_000)); + let mut wf = WaveformAccum::new(sr); loop { let packet = match format.next_packet() { Ok(p) => p, - Err(symphonia::core::errors::Error::IoError(_)) => break, - Err(symphonia::core::errors::Error::ResetRequired) => break, + Err(symphonia::core::errors::Error::IoError(_)) => break, + Err(symphonia::core::errors::Error::ResetRequired) => break, Err(e) => return Err(e.into()), }; - if packet.track_id() != track_id { - continue; - } + if packet.track_id() != track_id { continue; } let decoded = decoder.decode(&packet)?; - push_samples(&mut pcm, decoded, channels); + push_samples_and_peaks( + &mut pcm_batch, + &mut total_frames, + decoded, + channels, + &mut wf, + &mut mono_for_bpm, + bpm_cap, + deck, + load_gen, + sr, + status_tx, + ); } - Ok((pcm, sr)) + wf.flush(&mut |_| {}); + if !wf.bands_batch.is_empty() { + let _ = status_tx.send(AudioStatus::WaveformChunk { + deck, + bands: std::mem::take(&mut wf.bands_batch), + frame_offset: wf.bucket_start_frame, + }); + } + if !pcm_batch.is_empty() { + let _ = status_tx.send(AudioStatus::PcmChunk { + deck, load_gen, + frames: pcm_batch, + sample_rate: sr, + }); + } + + let _ = status_tx.send(AudioStatus::Ready { + deck, + load_gen, + duration_samples: total_frames, + sample_rate: sr, + }); + + // BPM detection on the mono accumulator. Runs synchronously in the decode + // thread, so it doesn't compete with the realtime callback. ~hundreds of + // ms for a 60 s clip; the Ready status above means the track is already + // playable before this fires. + if !mono_for_bpm.is_empty() { + let bpm = timestretch::detect_bpm(&mono_for_bpm, sr); + if bpm.is_finite() && bpm > 0.0 { + let _ = status_tx.send(AudioStatus::Bpm { deck, bpm: bpm as f32 }); + } + } + + Ok(()) } -fn push_samples(out: &mut Vec, buf: AudioBufferRef, channels: usize) { +// ─── Per-decode waveform accumulator ────────────────────────────────────────── +// +// Splits the mono mix into bass / mid / high via biquads, then takes the peak +// per band over PEAK_BUCKET frames. Each `bands_batch` entry is one bucket +// `[bass, mid, high]`. Sent off every 64 buckets (~6 s) as a `WaveformChunk`. + +struct WaveformAccum { + bass_lp: biquad::DirectForm1, + mid_bp: biquad::DirectForm1, + high_hp: biquad::DirectForm1, + bucket_bass: f32, + bucket_mid: f32, + bucket_high: f32, + bucket_count: usize, + bands_batch: Vec<[f32; 3]>, + frame_offset: u64, + bucket_start_frame: u64, +} + +impl WaveformAccum { + fn new(sample_rate: u32) -> Self { + use biquad::{Coefficients, DirectForm1, Hertz, Q_BUTTERWORTH_F32, Type}; + let fs = Hertz::::from_hz(sample_rate as f32).unwrap(); + let lp_co = Coefficients::::from_params( + Type::LowPass, fs, Hertz::::from_hz(250.0).unwrap(), Q_BUTTERWORTH_F32 + ).unwrap(); + let bp_co = Coefficients::::from_params( + Type::BandPass, fs, Hertz::::from_hz(1500.0).unwrap(), 0.7 + ).unwrap(); + let hp_co = Coefficients::::from_params( + Type::HighPass, fs, Hertz::::from_hz(4000.0).unwrap(), Q_BUTTERWORTH_F32 + ).unwrap(); + Self { + bass_lp: DirectForm1::::new(lp_co), + mid_bp: DirectForm1::::new(bp_co), + high_hp: DirectForm1::::new(hp_co), + bucket_bass: 0.0, + bucket_mid: 0.0, + bucket_high: 0.0, + bucket_count: 0, + bands_batch: Vec::with_capacity(64), + frame_offset: 0, + bucket_start_frame: 0, + } + } + + #[inline] + fn push_sample(&mut self, mono: f32) { + use biquad::Biquad as _; + let b = self.bass_lp.run(mono).abs(); + let m = self.mid_bp .run(mono).abs(); + let h = self.high_hp.run(mono).abs(); + if b > self.bucket_bass { self.bucket_bass = b; } + if m > self.bucket_mid { self.bucket_mid = m; } + if h > self.bucket_high { self.bucket_high = h; } + self.bucket_count += 1; + self.frame_offset += 1; + + if self.bucket_count >= PEAK_BUCKET { + // Compress: sqrt expands quiet detail. + self.bands_batch.push([ + self.bucket_bass.sqrt(), + self.bucket_mid.sqrt(), + self.bucket_high.sqrt(), + ]); + self.bucket_bass = 0.0; + self.bucket_mid = 0.0; + self.bucket_high = 0.0; + self.bucket_count = 0; + } + } + + /// Flush any in-progress bucket if non-empty. + fn flush(&mut self, _emit: &mut dyn FnMut(&[[f32; 3]])) { + if self.bucket_count > 0 { + self.bands_batch.push([ + self.bucket_bass.sqrt(), + self.bucket_mid.sqrt(), + self.bucket_high.sqrt(), + ]); + self.bucket_bass = 0.0; + self.bucket_mid = 0.0; + self.bucket_high = 0.0; + self.bucket_count = 0; + } + } +} + +fn extract_tags(format: &mut Box) -> (Option, Option) { + let mut artist = None; + let mut album = None; + + if let Some(meta) = format.metadata().current() { + for tag in meta.tags() { + match tag.std_key { + Some(StandardTagKey::Artist) => artist = Some(tag.value.to_string()), + Some(StandardTagKey::AlbumArtist) if artist.is_none() + => artist = Some(tag.value.to_string()), + Some(StandardTagKey::Album) => album = Some(tag.value.to_string()), + _ => {} + } + } + } + + (artist, album) +} + +#[allow(clippy::too_many_arguments)] +fn push_samples_and_peaks( + pcm_batch: &mut Vec, + total_frames: &mut u64, + buf: AudioBufferRef, + channels: usize, + wf: &mut WaveformAccum, + mono_for_bpm: &mut Vec, + bpm_cap: usize, + deck: DeckId, + load_gen: u64, + sample_rate: u32, + status_tx: &Sender, +) { macro_rules! push { ($buf:expr, $scale:expr) => {{ let frames = $buf.frames(); @@ -397,11 +647,35 @@ fn push_samples(out: &mut Vec, buf: AudioBufferRef, channels: usize) { let l = *$buf.chan(0).get(f).unwrap_or(&Default::default()); let r = if channels >= 2 { *$buf.chan(1).get(f).unwrap_or(&Default::default()) - } else { - l - }; - out.push(l as f32 * $scale); - out.push(r as f32 * $scale); + } else { l }; + + let lf = l as f32 * $scale; + let rf = r as f32 * $scale; + pcm_batch.push(lf); + pcm_batch.push(rf); + *total_frames += 1; + + let mono = (lf + rf) * 0.5; + wf.push_sample(mono); + + if mono_for_bpm.len() < bpm_cap { + mono_for_bpm.push(mono); + } + + if wf.bands_batch.len() >= 64 { + let _ = status_tx.send(AudioStatus::WaveformChunk { + deck, + bands: std::mem::take(&mut wf.bands_batch), + frame_offset: wf.bucket_start_frame, + }); + let _ = status_tx.send(AudioStatus::PcmChunk { + deck, + load_gen, + frames: std::mem::take(pcm_batch), + sample_rate, + }); + wf.bucket_start_frame = wf.frame_offset; + } } }}; } @@ -410,13 +684,13 @@ fn push_samples(out: &mut Vec, buf: AudioBufferRef, channels: usize) { AudioBufferRef::F32(b) => push!(b, 1.0_f32), AudioBufferRef::F64(b) => push!(b, 1.0_f32), AudioBufferRef::S16(b) => push!(b, 1.0 / 32768.0_f32), - AudioBufferRef::S32(b) => push!(b, 1.0 / 2147483648.0_f32), - AudioBufferRef::U8(b) => push!(b, 1.0 / 128.0_f32), + AudioBufferRef::S32(b) => push!(b, 1.0 / 2_147_483_648.0_f32), + AudioBufferRef::U8(b) => push!(b, 1.0 / 128.0_f32), _ => {} } } -// ─── PipeWire format pod helper ─────────────────────────────────────────────── +// ─── PipeWire format pod ────────────────────────────────────────────────────── fn make_f32_stereo_pod() -> Result> { use pipewire::spa::param::ParamType; @@ -431,15 +705,13 @@ fn make_f32_stereo_pod() -> Result> { &Value::Object(object!( SpaTypes::ObjectParamFormat, ParamType::EnumFormat, - property!(FormatProperties::MediaType, Id, MediaType::Audio), + property!(FormatProperties::MediaType, Id, MediaType::Audio), property!(FormatProperties::MediaSubtype, Id, MediaSubtype::Raw), - property!(FormatProperties::AudioFormat, Id, AudioFormat::F32LE), - property!(FormatProperties::AudioRate, Int, 44100i32), + property!(FormatProperties::AudioFormat, Id, AudioFormat::F32LE), + property!(FormatProperties::AudioRate, Int, STREAM_SR as i32), property!(FormatProperties::AudioChannels, Int, 2i32), )), - )? - .0 - .into_inner(); + )?.0.into_inner(); Ok(bytes) } diff --git a/src/deck.rs b/src/deck.rs index 9811c7b..6d5695a 100644 --- a/src/deck.rs +++ b/src/deck.rs @@ -1,5 +1,13 @@ use std::path::PathBuf; +// ─── Defaults ─────────────────────────────────────────────────────────────── + +pub const PITCH_DEFAULT: f32 = 0.0; +pub const TEMPO_DEFAULT: f32 = 1.0; +pub const CUTOFF_DEFAULT: f32 = 0.0; +pub const REVERB_DEFAULT: f32 = 0.0; +pub const FADER_DEFAULT: f32 = 0.8; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum DeckId { A = 0, @@ -63,53 +71,57 @@ pub struct Deck { pub id: DeckId, // Track - pub track: Option, + pub track: Option, pub track_name: Option, + pub artist: Option, + pub album: Option, // Transport - pub play_state: PlayState, - pub position: u64, // samples - pub duration: u64, // samples + pub play_state: PlayState, + pub position: u64, + pub duration: u64, pub sample_rate: u32, - pub cue_point: Option, + pub cue_point: Option, - // Per-deck mix controls - /// 0.0 – 1.0, sent to wpctl on the deck's PipeWire stream node - pub fader: f32, + // Mix / effect controls (mirror of the realtime DSP state in audio.rs) + pub fader: f32, // 0.0 .. 1.5 + pub pitch: f32, // semitones, -12 .. +12 + pub tempo: f32, // 0.5 .. 2.0 + pub cutoff: f32, // < 0 lowpass Hz, > 0 highpass Hz, 0 flat + pub reverb: f32, // 0.0 .. 1.0 - // Effect chain parameters (sent to pw-cli / filter-chain node) - /// Semitones -12.0 .. +12.0 - pub pitch: f32, - /// Tempo multiplier 0.5 .. 2.0 - pub tempo: f32, - - // VU (updated by audio engine) + // VU (updated by audio engine at ~60 Hz) pub vu: (f32, f32), - // PipeWire object IDs (populated after graph setup) - /// Object ID of the deck's playback stream node - pub stream_node_id: Option, - /// Object ID of the filter-chain node for this deck - pub filter_node_id: Option, + // Per-bucket band peaks for the waveform: [bass, mid, high], one entry per + // ~0.1 s of audio (PEAK_BUCKET frames). Sent in WaveformChunks during decode. + pub waveform_bands: Vec<[f32; 3]>, + + /// BPM estimate from the decode-time analyzer; `None` until detected. + pub bpm: Option, } impl Deck { pub fn new(id: DeckId) -> Self { Self { id, - track: None, - track_name: None, - play_state: PlayState::Stopped, - position: 0, - duration: 0, + track: None, + track_name: None, + artist: None, + album: None, + play_state: PlayState::Stopped, + position: 0, + duration: 0, sample_rate: 44100, - cue_point: None, - fader: 0.8, - pitch: 0.0, - tempo: 1.0, - vu: (0.0, 0.0), - stream_node_id: None, - filter_node_id: None, + cue_point: None, + fader: FADER_DEFAULT, + pitch: PITCH_DEFAULT, + tempo: TEMPO_DEFAULT, + cutoff: CUTOFF_DEFAULT, + reverb: REVERB_DEFAULT, + vu: (0.0, 0.0), + waveform_bands: Vec::new(), + bpm: None, } } @@ -117,6 +129,10 @@ impl Deck { self.track.is_some() } + pub fn peaks_empty(&self) -> bool { + self.waveform_bands.is_empty() + } + pub fn progress(&self) -> f32 { if self.duration == 0 { 0.0 } else { (self.position as f32 / self.duration as f32).clamp(0.0, 1.0) diff --git a/src/dsp.rs b/src/dsp.rs new file mode 100644 index 0000000..c8ad54e --- /dev/null +++ b/src/dsp.rs @@ -0,0 +1,489 @@ +//! In-process DSP chain per deck. +//! +//! Order: +//! raw PCM at native sample rate +//! → Signalsmith Stretch (tempo + pitch, independent, realtime) +//! → biquad lowpass cascade L/R (cutoff < 0) +//! → biquad highpass cascade L/R (cutoff > 0) +//! → Schroeder stereo reverb (dry ↔ wet) +//! → fader (linear gain) +//! → interleaved stereo to PipeWire stream buffer +//! +//! Params (`DeckParams`) live behind lock-free `AtomicF32`s. The app thread +//! writes them; the realtime callback snapshots them at the top of every +//! block. No `Mutex`, no `pw-cli`. + +use std::sync::Arc; + +use atomic_float::AtomicF32; +use biquad::{ + Biquad as _, Coefficients, DirectForm1, Hertz, Q_BUTTERWORTH_F32, Type, +}; +use ssstretch::Stretch; + +// ─── 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 +pub const FADER_MAX: f32 = 1.5; + +const LP_OPEN_HZ: f32 = 20_000.0; +const HP_OPEN_HZ: f32 = 20.0; + +// ─── 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. + pub cutoff: AtomicF32, + pub reverb_wet: AtomicF32, // 0..1 + pub fader: AtomicF32, // 0..1.5 +} + +impl DeckParams { + pub fn new() -> Arc { + Arc::new(Self { + tempo: AtomicF32::new(1.0), + pitch: AtomicF32::new(0.0), + cutoff: AtomicF32::new(0.0), + reverb_wet: AtomicF32::new(0.0), + fader: AtomicF32::new(0.8), + }) + } +} + +// ─── DSP graph ───────────────────────────────────────────────────────────── + +#[derive(Clone, Copy)] +struct Cached { + tempo: f32, + pitch: f32, + cutoff: f32, + reverb_wet: f32, + fader: f32, +} + +/// `Stretch` wraps a `cxx::UniquePtr` to a C++ object which is `!Send` by +/// default. The Signalsmith Stretch instance is safe to move between threads +/// as long as access is externally synchronised, which `DeckState`'s +/// `Mutex` guarantees. +struct SendStretch(Stretch); +unsafe impl Send for SendStretch {} + +impl std::ops::Deref for SendStretch { + type Target = Stretch; + fn deref(&self) -> &Stretch { &self.0 } +} +impl std::ops::DerefMut for SendStretch { + fn deref_mut(&mut self) -> &mut Stretch { &mut self.0 } +} + +pub struct DeckDsp { + sample_rate: u32, + stretcher: SendStretch, + lp_l: DirectForm1, + lp_r: DirectForm1, + hp_l: DirectForm1, + hp_r: DirectForm1, + reverb: SchroederStereo, + cached: Cached, + // Scratch — sized lazily, kept across calls. + in_l: Vec, + in_r: Vec, + out_l: Vec, + out_r: Vec, +} + +impl DeckDsp { + pub fn new(sample_rate: u32) -> Self { + let mut stretcher = Stretch::new(); + stretcher.preset_default(2, sample_rate as f32); + stretcher.set_transpose_semitones(0.0, None); + + let lp = make_lp(sample_rate, LP_OPEN_HZ); + let hp = make_hp(sample_rate, HP_OPEN_HZ); + + Self { + sample_rate, + stretcher: SendStretch(stretcher), + lp_l: DirectForm1::::new(lp), + lp_r: DirectForm1::::new(lp), + hp_l: DirectForm1::::new(hp), + hp_r: DirectForm1::::new(hp), + reverb: SchroederStereo::new(sample_rate), + cached: Cached { + tempo: 1.0, pitch: 0.0, cutoff: 0.0, + reverb_wet: 0.0, fader: 0.8, + }, + in_l: Vec::new(), + in_r: Vec::new(), + out_l: Vec::new(), + out_r: Vec::new(), + } + } + + /// Process one block. Reads up to `pcm` starting at `start_frame`, + /// writes `out_frames = out.len() / 2` interleaved stereo frames to `out`, + /// returns the number of *input* frames consumed (so the caller can + /// advance its read cursor). + /// + /// If the source runs out of audio mid-block, the tail is filled with + /// silence and a partial input count is returned. + pub fn process( + &mut self, + params: &DeckParams, + pcm: &[f32], // interleaved stereo, native SR + start_frame: usize, // frame index into pcm + out: &mut [f32], // interleaved stereo, len = out_frames * 2 + ) -> usize /* input frames consumed */ { + 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), + }; + + 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); + 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.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 = p; + + // 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 total = pcm.len() / 2; + let available = total.saturating_sub(start_frame); + let take_in = want_in.min(available); + + self.in_l.resize(want_in, 0.0); + self.in_r.resize(want_in, 0.0); + for i in 0..take_in { + let f = start_frame + i; + self.in_l[i] = pcm[f * 2]; + self.in_r[i] = pcm[f * 2 + 1]; + } + // Zero-fill any short tail so the stretcher still receives `want_in`. + for i in take_in..want_in { + self.in_l[i] = 0.0; + self.in_r[i] = 0.0; + } + + self.out_l.resize(out_frames, 0.0); + self.out_r.resize(out_frames, 0.0); + + // 3. Stretch. + let in_ptrs: [*const f32; 2] = [self.in_l.as_ptr(), self.in_r.as_ptr()]; + let mut out_ptrs: [*mut f32; 2] = [self.out_l.as_mut_ptr(), self.out_r.as_mut_ptr()]; + unsafe { + self.stretcher.process( + &in_ptrs, want_in as i32, + &mut out_ptrs, out_frames as i32, + ); + } + + // 4. Biquads, reverb, fader → interleave into `out`. + for i in 0..out_frames { + let mut l = self.out_l[i]; + let mut r = self.out_r[i]; + + l = self.lp_l.run(l); + r = self.lp_r.run(r); + l = self.hp_l.run(l); + 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 *= p.fader; + r *= p.fader; + + out[i * 2] = l; + out[i * 2 + 1] = r; + } + + take_in + } +} + +// ─── 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)) + } else { + (LP_OPEN_HZ, HP_OPEN_HZ) + } +} + +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() +} + +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() +} + +// ─── Stereo Schroeder reverb ─────────────────────────────────────────────── +// +// Four parallel comb filters per channel + two series allpass filters per +// channel, with light L↔R cross-feedback for stereo width. Magic numbers are +// the classic Freeverb constants, scaled to actual sample rate. + +const COMB_DELAYS_44K: [usize; 4] = [1116, 1188, 1277, 1356]; +const ALLP_DELAYS_44K: [usize; 2] = [225, 556]; +const COMB_FEEDBACK: f32 = 0.84; +const COMB_DAMPING: f32 = 0.2; +const ALLP_FEEDBACK: f32 = 0.5; +const STEREO_SPREAD: usize = 23; + +struct Comb { + buf: Vec, + idx: usize, + last: f32, +} + +impl Comb { + fn new(delay: usize) -> Self { Self { buf: vec![0.0; delay], idx: 0, last: 0.0 } } + fn run(&mut self, x: f32) -> f32 { + let y = self.buf[self.idx]; + // One-pole lowpass in feedback path = damping. + self.last = y * (1.0 - COMB_DAMPING) + self.last * COMB_DAMPING; + self.buf[self.idx] = x + self.last * COMB_FEEDBACK; + self.idx = (self.idx + 1) % self.buf.len(); + y + } +} + +struct AllPass { + buf: Vec, + idx: usize, +} + +impl AllPass { + fn new(delay: usize) -> Self { Self { buf: vec![0.0; delay], idx: 0 } } + fn run(&mut self, x: f32) -> f32 { + let y = self.buf[self.idx]; + self.buf[self.idx] = x + y * ALLP_FEEDBACK; + self.idx = (self.idx + 1) % self.buf.len(); + y - x // standard Schroeder allpass output + } +} + +pub struct SchroederStereo { + comb_l: [Comb; 4], + comb_r: [Comb; 4], + allp_l: [AllPass; 2], + allp_r: [AllPass; 2], +} + +impl SchroederStereo { + pub fn new(sample_rate: u32) -> Self { + let scale = sample_rate as f32 / 44_100.0; + let s = |d: usize| ((d as f32 * scale) as usize).max(1); + + Self { + comb_l: [ + Comb::new(s(COMB_DELAYS_44K[0])), + Comb::new(s(COMB_DELAYS_44K[1])), + Comb::new(s(COMB_DELAYS_44K[2])), + Comb::new(s(COMB_DELAYS_44K[3])), + ], + comb_r: [ + Comb::new(s(COMB_DELAYS_44K[0] + STEREO_SPREAD)), + Comb::new(s(COMB_DELAYS_44K[1] + STEREO_SPREAD)), + Comb::new(s(COMB_DELAYS_44K[2] + STEREO_SPREAD)), + Comb::new(s(COMB_DELAYS_44K[3] + STEREO_SPREAD)), + ], + allp_l: [ + AllPass::new(s(ALLP_DELAYS_44K[0])), + AllPass::new(s(ALLP_DELAYS_44K[1])), + ], + allp_r: [ + AllPass::new(s(ALLP_DELAYS_44K[0] + STEREO_SPREAD)), + AllPass::new(s(ALLP_DELAYS_44K[1] + STEREO_SPREAD)), + ], + } + } + + fn process(&mut self, in_l: f32, in_r: f32) -> (f32, f32) { + // Cross-feed a tiny amount of each channel into the other's combs + // for an enveloping stereo image. + let xl = in_l + 0.15 * in_r; + let xr = in_r + 0.15 * in_l; + + let mut l = 0.0; + let mut r = 0.0; + for c in &mut self.comb_l { l += c.run(xl); } + for c in &mut self.comb_r { r += c.run(xr); } + l *= 0.25; + r *= 0.25; + + for a in &mut self.allp_l { l = a.run(l); } + for a in &mut self.allp_r { r = a.run(r); } + + (l, r) + } +} + +// ─── Tests ───────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use std::f32::consts::PI; + + const SR: u32 = 44_100; + + fn sine(freq: f32, n_frames: usize) -> Vec { + let mut pcm = vec![0.0f32; n_frames * 2]; + for i in 0..n_frames { + let s = (2.0 * PI * freq * i as f32 / SR as f32).sin() * 0.5; + pcm[i * 2] = s; + pcm[i * 2 + 1] = s; + } + pcm + } + + fn rms(samples: &[f32]) -> f32 { + let n = samples.len() as f32; + (samples.iter().map(|x| x * x).sum::() / n).sqrt() + } + + /// Lowpass at 1 kHz attenuates a 5 kHz sine more than -10 dB. + #[test] + fn lowpass_cuts_highs() { + let params = DeckParams::new(); + params.cutoff.store(-1000.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 + let mut out = vec![0.0f32; SR as usize * 2]; + // Run a few blocks to settle the biquad and the stretcher buffer. + 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 biquad/stretcher transient. + let settled = &out[(SR as usize / 5) * 2..]; + let out_rms = rms(settled); + let in_rms = rms(&pcm[(SR as usize / 5) * 2..]); + let ratio_db = 20.0 * (out_rms / in_rms).log10(); + 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. + #[test] + fn highpass_cuts_lows() { + let params = DeckParams::new(); + params.cutoff.store(1000.0, 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); + } + let settled = &out[(SR as usize / 5) * 2..]; + let out_rms = rms(settled); + let in_rms = rms(&pcm[(SR as usize / 5) * 2..]); + let ratio_db = 20.0 * (out_rms / in_rms).log10(); + assert!(ratio_db < -10.0, "100 Hz through 1 kHz HP only attenuated {:.1} dB", ratio_db); + } + + /// At tempo = 2.0, each output block consumes ~2× the input frames it would + /// at tempo = 1.0. + #[test] + fn tempo_doubles_input_consumption() { + let pcm = sine(440.0, SR as usize * 2); + let mut out = vec![0.0f32; 4096 * 2]; + + let p_normal = DeckParams::new(); + let mut dsp_normal = DeckDsp::new(SR); + let in_normal = dsp_normal.process(&p_normal, &pcm, 0, &mut out); + + let p_fast = DeckParams::new(); + p_fast.tempo.store(2.0, std::sync::atomic::Ordering::Relaxed); + let mut dsp_fast = DeckDsp::new(SR); + let in_fast = dsp_fast.process(&p_fast, &pcm, 0, &mut out); + + let ratio = in_fast as f32 / in_normal as f32; + assert!((ratio - 2.0).abs() < 0.05, + "tempo=2.0 consumed {} frames vs tempo=1.0 {} frames (ratio {:.3}, want 2.0)", + in_fast, in_normal, ratio); + } + + /// Reverb produces non-zero tail after the dry signal stops. + #[test] + fn reverb_has_tail() { + let params = DeckParams::new(); + params.reverb_wet.store(1.0, std::sync::atomic::Ordering::Relaxed); + let mut dsp = DeckDsp::new(SR); + + // 0.1 s of audio followed by 0.4 s of silent input — but we'll + // process the silent part and look for non-zero output. + let mut pcm = sine(440.0, SR as usize / 10); + pcm.extend(vec![0.0f32; (SR as usize / 10 * 4) * 2]); + + let mut out = vec![0.0f32; pcm.len()]; + let block = 1024; + let mut cursor = 0usize; + let total_frames = pcm.len() / 2; + for chunk_start in (0..total_frames).step_by(block) { + let end = (chunk_start + block).min(total_frames); + 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); + } + + // Last 0.1 s should still have energy from the reverb tail. + let tail_start = (total_frames - SR as usize / 10) * 2; + let tail_rms = rms(&out[tail_start..]); + assert!(tail_rms > 1e-4, "reverb tail RMS too small: {}", tail_rms); + } +} + diff --git a/src/effect_chain.rs b/src/effect_chain.rs deleted file mode 100644 index e1ae8e2..0000000 --- a/src/effect_chain.rs +++ /dev/null @@ -1,267 +0,0 @@ -/// Effect chain management -/// -/// Each deck gets a PipeWire filter-chain node loaded via: -/// pw-cli load-module libpipewire-module-filter-chain '{ ... }' -/// -/// The chain hosts the rubberband LADSPA pitch+tempo plugin. -/// Runtime parameter changes go through: -/// pw-cli set-param Props '{ params = ["pitch:Pitch scale" "1.0"] }' -/// -/// Fader (volume) is applied on the deck's own playback stream via: -/// wpctl set-volume -/// -/// Node IDs are discovered after load by parsing pw-dump JSON output. -use std::process::Command; - -use anyhow::{anyhow, Result}; - -use crate::deck::DeckId; - -/// Known rubberband LADSPA plugin labels and their control port names. -/// Run `analyseplugin ladspa-rubberband.so` to see the full list on your system. -/// We use the stereo pitch shifter variant. -const RUBBERBAND_PLUGIN: &str = "ladspa-rubberband"; -const RUBBERBAND_LABEL: &str = "rubberband_pitchshifter_stereo"; -const PITCH_PORT: &str = "Pitch scale"; // control port name -const TEMPO_PORT: &str = "Time ratio"; // control port name (some builds call it "Tempo ratio") - -pub struct EffectChain { - pub deck: DeckId, - /// Module ID returned by pw-cli load-module (used for unload) - pub module_id: Option, - /// PipeWire object ID of the filter-chain node (used for set-param) - pub node_id: Option, - /// Human-readable node name used to find the node ID in pw-dump - pub node_name: String, -} - -impl EffectChain { - /// Build the node name for a deck — must be unique and stable. - pub fn node_name_for(deck: DeckId) -> String { - format!("djdeck.chain.{}", deck.node_suffix()) - } - - /// Load the filter-chain module for this deck. - /// Returns an EffectChain with module_id set; node_id is resolved separately. - pub fn load(deck: DeckId) -> Result { - let node_name = Self::node_name_for(deck); - - // Build the SPA JSON argument for pw-cli load-module - // We expose the filter-chain as a sink that the deck stream auto-connects to, - // and whose output goes to the default audio sink. - let args = format!( - r#"{{ - node.name = "{node_name}" - node.description = "djdeck Deck {label} chain" - media.name = "{node_name}" - filter.graph = {{ - nodes = [ - {{ - type = ladspa - name = pitch - plugin = {plugin} - label = {label_plugin} - control = {{ - "{pitch_port}" = 1.0 - "{tempo_port}" = 1.0 - }} - }} - ] - }} - audio.channels = 2 - audio.position = [ FL FR ] - capture.props = {{ - node.name = "input.{node_name}" - media.class = Audio/Sink - node.passive = true - }} - playback.props = {{ - node.name = "output.{node_name}" - media.class = Audio/Source - }} -}}"#, - node_name = node_name, - label = deck.label(), - plugin = RUBBERBAND_PLUGIN, - label_plugin = RUBBERBAND_LABEL, - pitch_port = PITCH_PORT, - tempo_port = TEMPO_PORT, - ); - - tracing::info!("Loading filter-chain for deck {}: {}", deck.label(), node_name); - - let output = Command::new("pw-cli") - .args(["load-module", "libpipewire-module-filter-chain", &args]) - .output()?; - - if !output.status.success() { - let err = String::from_utf8_lossy(&output.stderr); - return Err(anyhow!("pw-cli load-module failed: {}", err)); - } - - // pw-cli prints something like "Module: id:42, ..." - let stdout = String::from_utf8_lossy(&output.stdout); - let module_id = parse_id_from_pw_cli_output(&stdout, "Module"); - - tracing::info!( - "Deck {} filter-chain loaded, module_id={:?}", - deck.label(), - module_id - ); - - Ok(Self { - deck, - module_id, - node_id: None, - node_name, - }) - } - - /// Resolve the node_id by scanning pw-dump JSON for our node name. - /// Call this once after load, ideally with a short delay to let PipeWire settle. - pub fn resolve_node_id(&mut self) -> Result<()> { - let output = Command::new("pw-dump").output()?; - if !output.status.success() { - return Err(anyhow!("pw-dump failed")); - } - let json: serde_json::Value = serde_json::from_slice(&output.stdout)?; - let objects = json.as_array().ok_or_else(|| anyhow!("pw-dump not array"))?; - - for obj in objects { - let type_str = obj.get("type").and_then(|t| t.as_str()).unwrap_or(""); - if type_str != "PipeWire:Interface:Node" { - continue; - } - let props = obj.get("info") - .and_then(|i| i.get("props")) - .unwrap_or(&serde_json::Value::Null); - - let name = props.get("node.name").and_then(|n| n.as_str()).unwrap_or(""); - // The filter-chain creates two sub-nodes (input. and output. prefixed). - // We want the output (playback) side for routing, but either gives us params. - // Match the capture/sink side: "input." - if name == format!("input.{}", self.node_name) { - let id = obj.get("id").and_then(|i| i.as_u64()).map(|i| i as u32); - self.node_id = id; - tracing::info!( - "Resolved filter node id {:?} for deck {}", - id, - self.deck.label() - ); - return Ok(()); - } - } - Err(anyhow!( - "Could not find filter-chain node '{}' in pw-dump", - self.node_name - )) - } - - /// Set pitch in semitones. Converted to a scale factor (2^(st/12)). - pub fn set_pitch(&self, semitones: f32) -> Result<()> { - let scale = 2.0_f32.powf(semitones / 12.0); - self.set_param("pitch", PITCH_PORT, scale) - } - - /// Set tempo multiplier (1.0 = normal, 1.1 = 10% faster). - pub fn set_tempo(&self, multiplier: f32) -> Result<()> { - // rubberband time ratio is 1/tempo_multiplier - let ratio = 1.0 / multiplier.clamp(0.5, 2.0); - self.set_param("pitch", TEMPO_PORT, ratio) - } - - /// Low-level: set a LADSPA control port value on the filter-chain node. - /// - /// Uses: - /// pw-cli set-param Props '{ params = [":" ""] }' - fn set_param(&self, plugin_node: &str, port: &str, value: f32) -> Result<()> { - let node_id = self - .node_id - .ok_or_else(|| anyhow!("filter node_id not resolved yet"))?; - - let param = format!( - "{{ params = [\"{plugin_node}:{port}\" \"{value:.6}\"] }}", - plugin_node = plugin_node, - port = port, - value = value, - ); - - tracing::info!( - "set-param deck {} node {} Props {}", - self.deck.label(), - node_id, - param - ); - - let status = Command::new("pw-cli") - .args(["set-param", &node_id.to_string(), "Props", ¶m]) - .status()?; - - if !status.success() { - return Err(anyhow!( - "pw-cli set-param failed for deck {}", - self.deck.label() - )); - } - Ok(()) - } - - /// Set the volume (fader) on the deck's PipeWire stream node via wpctl. - /// - /// wpctl set-volume - pub fn set_volume(stream_node_id: u32, value: f32) -> Result<()> { - let status = Command::new("wpctl") - .args([ - "set-volume", - &stream_node_id.to_string(), - &format!("{:.4}", value.clamp(0.0, 1.5)), - ]) - .status()?; - - if !status.success() { - return Err(anyhow!( - "wpctl set-volume failed for node {}", - stream_node_id - )); - } - Ok(()) - } - - /// Unload this module from PipeWire. - pub fn unload(&self) -> Result<()> { - if let Some(id) = self.module_id { - let status = Command::new("pw-cli") - .args(["unload-module", &id.to_string()]) - .status()?; - if !status.success() { - return Err(anyhow!("pw-cli unload-module {} failed", id)); - } - tracing::info!("Unloaded filter-chain module {} for deck {}", id, self.deck.label()); - } - Ok(()) - } -} - -impl Drop for EffectChain { - fn drop(&mut self) { - if let Err(e) = self.unload() { - tracing::warn!("EffectChain drop unload error: {e}"); - } - } -} - -/// Parse a numeric ID from pw-cli output lines like: -/// "Module: id:42, type:PipeWire:Interface:Module/3" -fn parse_id_from_pw_cli_output(text: &str, prefix: &str) -> Option { - for line in text.lines() { - if line.trim_start().starts_with(prefix) { - // look for "id:N" - if let Some(pos) = line.find("id:") { - let rest = &line[pos + 3..]; - let end = rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len()); - return rest[..end].parse().ok(); - } - } - } - None -} diff --git a/src/file_selector.rs b/src/file_selector.rs index 9990bd3..482e7fd 100644 --- a/src/file_selector.rs +++ b/src/file_selector.rs @@ -81,7 +81,7 @@ impl FileSelector { let dir = p.parent().and_then(|d| d.to_str()).unwrap_or(""); ListItem::new(Line::from(vec![ Span::styled(name, Style::default().fg(Color::White)), - Span::styled(format!(" {}", dir), Style::default().fg(Color::DarkGray)), + Span::styled(format!(" {}", dir), Style::default().fg(Color::Gray)), ])) }).collect(); diff --git a/src/main.rs b/src/main.rs index 49b2c85..c1da617 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,7 @@ mod app; mod audio; mod cache; mod deck; -mod effect_chain; +mod dsp; mod file_selector; mod osc; mod tui; @@ -19,9 +19,11 @@ async fn main() -> Result<()> { tracing_subscriber::EnvFilter::from_default_env() .add_directive(tracing::Level::INFO.into()), ) - .with_writer(log_file) + .with_writer(std::sync::Mutex::new(log_file)) + .with_ansi(false) .init(); + eprintln!("djdeck: logs → /tmp/djdeck.log"); tracing::info!("djdeck starting"); // Parse command line argument for audio directory diff --git a/src/osc.rs b/src/osc.rs index 393a0be..7650729 100644 --- a/src/osc.rs +++ b/src/osc.rs @@ -9,6 +9,8 @@ /// fader f (0.0–1.0) → wpctl set-volume on stream node /// pitch f (semitones) → pw-cli Props on filter node /// tempo f (multiplier)→ pw-cli Props on filter node +/// cutoff f (Hz, 100–20000) → pw-cli Props lpL/lpR on filter node +/// reverb f (0.0–1.0) → pw-cli Props reverb:Wet level /// cue/set, cue/goto /// /// Outgoing: @@ -33,9 +35,11 @@ pub const SEND_PORT: u16 = 9001; #[derive(Debug)] pub enum OscAppCmd { Audio(AudioCmd), - SetPitch { deck: DeckId, semitones: f32 }, - SetTempo { deck: DeckId, multiplier: f32 }, - SetFader { deck: DeckId, value: f32 }, + SetPitch { deck: DeckId, semitones: f32 }, + SetTempo { deck: DeckId, multiplier: f32 }, + SetFader { deck: DeckId, value: f32 }, + SetCutoff { deck: DeckId, hz: f32 }, + SetReverb { deck: DeckId, wet: f32 }, } pub fn start_osc_server(cmd_tx: Sender) -> Result<()> { @@ -79,9 +83,11 @@ fn handle_msg(msg: OscMessage, tx: &Sender) { ("pause", None) => Some(OscAppCmd::Audio(AudioCmd::Pause(deck))), ("stop", None) => Some(OscAppCmd::Audio(AudioCmd::Stop(deck))), ("seek", None) => f32_arg(&msg.args, 0).map(|v| OscAppCmd::Audio(AudioCmd::Seek { deck, position: v })), - ("fader", None) => f32_arg(&msg.args, 0).map(|v| OscAppCmd::SetFader { deck, value: v }), - ("pitch", None) => f32_arg(&msg.args, 0).map(|v| OscAppCmd::SetPitch { deck, semitones: v }), - ("tempo", None) => f32_arg(&msg.args, 0).map(|v| OscAppCmd::SetTempo { deck, multiplier: v }), + ("fader", None) => f32_arg(&msg.args, 0).map(|v| OscAppCmd::SetFader { deck, value: v }), + ("pitch", None) => f32_arg(&msg.args, 0).map(|v| OscAppCmd::SetPitch { deck, semitones: v }), + ("tempo", None) => f32_arg(&msg.args, 0).map(|v| OscAppCmd::SetTempo { deck, multiplier: v }), + ("cutoff", None) => f32_arg(&msg.args, 0).map(|v| OscAppCmd::SetCutoff { deck, hz: v }), + ("reverb", None) => f32_arg(&msg.args, 0).map(|v| OscAppCmd::SetReverb { deck, wet: v }), ("cue", Some("set")) => Some(OscAppCmd::Audio(AudioCmd::SetCue(deck))), ("cue", Some("goto")) => Some(OscAppCmd::Audio(AudioCmd::GotoCue(deck))), _ => None, diff --git a/src/tui.rs b/src/tui.rs index 7dbd27c..3fc44b4 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -20,9 +20,9 @@ fn draw_base(f: &mut Frame, app: &App, area: Rect) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(1), - Constraint::Min(1), - Constraint::Length(4), + Constraint::Length(1), // header + Constraint::Min(1), // 2×2 deck grid + Constraint::Length(3), // help ]) .split(area); @@ -31,31 +31,33 @@ fn draw_base(f: &mut Frame, app: &App, area: Rect) { Span::styled(" ♫ djdeck ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), Span::styled( format!("OSC :{} → :{} ", crate::osc::LISTEN_PORT, crate::osc::SEND_PORT), - Style::default().fg(Color::DarkGray), + Style::default().fg(Color::Gray), ), Span::styled( - "PipeWire graph: each deck → rubberband filter-chain → sink", - Style::default().fg(Color::DarkGray), + "in-proc DSP: signalsmith-stretch(pitch+tempo) → biquad(cutoff) → schroeder(reverb)", + Style::default().fg(Color::Gray), ), ])); f.render_widget(header, chunks[0]); - // 2×2 deck grid + // 2×2 deck grid. Keyboard layout: A S / Z X + // Index mapping: top-left=A(0), top-right=S(2), bottom-left=Z(1), bottom-right=X(3) + let render_order = [[0usize, 2usize], [1usize, 3usize]]; + let rows = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) .split(chunks[1]); - for row in 0..2usize { + for (row_i, row_idxs) in render_order.iter().enumerate() { let cols = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(rows[row]); + .split(rows[row_i]); - for col in 0..2usize { - let idx = row * 2 + col; - if idx < app.decks.len() { - draw_deck(f, &app.decks[idx], cols[col], app.focused_deck == idx); + 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); } } } @@ -81,65 +83,90 @@ fn draw_deck(f: &mut Frame, deck: &Deck, area: Rect, focused: bool) { Style::default().fg(Color::DarkGray) }; - // Truncate track name to fit - let max_name = area.width.saturating_sub(18) as usize; + // Title: deck label + play state + truncated track name + node IDs + 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 = if track_display.len() > max_name { format!("{}…", &track_display[..max_name.saturating_sub(1)]) } else { track_display.to_string() }; - - // Node ID status - let graph_status = match (deck.stream_node_id, deck.filter_node_id) { - (Some(s), Some(f)) => format!(" [pw:{}/{}]", s, f), - (Some(s), None) => format!(" [pw:{}/--]", s), - _ => " [pw:--]".to_string(), - }; - + let bpm = deck.bpm.map(|b| format!(" {:.1} BPM ", b)).unwrap_or_default(); let title = format!( - " Deck {} {} {} {} ", - deck.id.label(), - deck.play_state.icon(), - track_display, - graph_status, + " Deck {} {} {}{}", + deck.id.label(), deck.play_state.icon(), track_display, bpm ); let outer = Block::default() .borders(Borders::ALL) .title(title) - .border_style(border_style) - .style(Style::default()); + .border_style(border_style); f.render_widget(outer, area); let inner = area.inner(ratatui::layout::Margin { horizontal: 1, vertical: 1 }); + if inner.height < 4 { return; } + + // Layout inside the deck: + // + // [V][L][R] timecode + progress bar + // [V][L][R] info / effects + // [V][L][R] waveform (grows to fill space, min 4 rows) + // [V][L][R] timeline (1 row, at the very bottom) + // + // V = volume fader bar (3 chars: bar + space) + // L = left VU bar (1 char) + // R = right VU bar (1 char) + // Total left strip: 3 chars wide + + let vu_cols = 3u16; // V·LR + let (left_area, right_area) = if inner.width > vu_cols + 4 { + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Length(vu_cols), Constraint::Min(1)]) + .split(inner); + (Some(cols[0]), cols[1]) + } else { + (None, inner) + }; + + // Right side: timecode(1) + progress(1) + info(2+) + waveform(4+) + timeline(1) + let fixed = 3u16; // timecode + progress + timeline + let wave_h = ((right_area.height / 2).max(4)) + .min(right_area.height.saturating_sub(fixed + 2)); + let info_h = right_area.height.saturating_sub(fixed + wave_h).max(2); let rows = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(1), // timecode - Constraint::Length(1), // progress - Constraint::Length(1), // gap - Constraint::Length(1), // fader - Constraint::Length(1), // pitch + tempo - Constraint::Length(1), // gap - Constraint::Length(1), // VU + Constraint::Length(1), // timecode + Constraint::Length(1), // progress + Constraint::Length(info_h), // info + effects + Constraint::Length(wave_h), // waveform + Constraint::Length(1), // timeline label ]) - .split(inner); + .split(right_area); - // Timecode + // ── Left strip: V · L · R (full height) ───────────────────────────────── + if let Some(left) = left_area { + draw_vu_strip(f, deck, left, color); + } + + // ── Timecode ───────────────────────────────────────────────────────────── let (em, es) = deck.elapsed(); let (rm, rs) = deck.remaining(); f.render_widget( Paragraph::new(Line::from(vec![ - Span::styled(format!("{:02}:{:02}", em, es), Style::default().fg(color).add_modifier(Modifier::BOLD)), + Span::styled( + format!("{:02}:{:02}", em, es), + Style::default().fg(color).add_modifier(Modifier::BOLD), + ), Span::styled(" / ", Style::default().fg(Color::DarkGray)), - Span::styled(format!("-{:02}:{:02}", rm, rs), Style::default().fg(Color::DarkGray)), + Span::styled(format!("-{:02}:{:02}", rm, rs), Style::default().fg(Color::White)), ])), rows[0], ); - // Progress + // ── Progress bar ───────────────────────────────────────────────────────── f.render_widget( Gauge::default() .gauge_style(Style::default().fg(color)) @@ -148,83 +175,353 @@ fn draw_deck(f: &mut Frame, deck: &Deck, area: Rect, focused: bool) { rows[1], ); - // Fader + // ── Info + effects ──────────────────────────────────────────────────────── + draw_info_effects(f, deck, rows[2]); + + // ── Waveform ────────────────────────────────────────────────────────────── + draw_waveform(f, deck, rows[3], color); + + // ── Timeline label (bottom row) ─────────────────────────────────────────── let fader_pct = (deck.fader * 100.0) as u16; - f.render_widget( - Gauge::default() - .gauge_style(Style::default() - .fg(if fader_pct > 10 { Color::White } else { Color::DarkGray })) - .percent(fader_pct.min(100)) - .label(format!("VOL {:3}%", fader_pct)), - rows[3], - ); - - // Pitch + Tempo (both live in rubberband filter-chain node) f.render_widget( Paragraph::new(Line::from(vec![ - Span::styled("PITCH ", Style::default().fg(Color::DarkGray)), Span::styled( - format!("{:+.1}st", deck.pitch), - Style::default().fg(if deck.pitch.abs() > 0.05 { Color::Yellow } else { Color::White }), - ), - Span::styled(" TEMPO ", Style::default().fg(Color::DarkGray)), - Span::styled( - format!("{:.2}×", deck.tempo), - Style::default().fg(if (deck.tempo - 1.0).abs() > 0.01 { Color::Yellow } else { Color::White }), + format!("VOL {:3}%", fader_pct), + Style::default().fg(if fader_pct > 10 { Color::White } else { Color::Gray }), ), ])), rows[4], ); - - // VU - draw_vu(f, deck.vu, rows[6], color); } -fn draw_vu(f: &mut Frame, vu: (f32, f32), area: Rect, color: Color) { - let half = area.width / 2; - let la = Rect { x: area.x, y: area.y, width: half.saturating_sub(1), height: 1 }; - let ra = Rect { x: area.x + half, y: area.y, width: half, height: 1 }; +// ── Left VU/fader strip ─────────────────────────────────────────────────────── +// +// Three 1-char-wide columns spanning the full deck inner height: +// col 0: V — volume/fader bar (white/gray) +// col 1: L — left channel VU (deck colour → yellow → red) +// col 2: R — right channel VU +// Bottom row of each column shows the label character. - let vc = |p: u16| if p > 90 { Color::Red } else if p > 70 { Color::Yellow } else { color }; +fn draw_vu_strip(f: &mut Frame, deck: &Deck, area: Rect, color: Color) { + if area.width < 3 || area.height < 1 { return; } + let h = area.height as f32; - let lp = (vu.0 * 100.0) as u16; - let rp = (vu.1 * 100.0) as u16; + let bars: [(f32, Color, &str); 3] = [ + (deck.fader, Color::White, "V"), + (deck.vu.0, color, "L"), + (deck.vu.1, color, "R"), + ]; - f.render_widget(Gauge::default().gauge_style(Style::default().fg(vc(lp))).percent(lp.min(100)).label("L"), la); - f.render_widget(Gauge::default().gauge_style(Style::default().fg(vc(rp))).percent(rp.min(100)).label("R"), ra); + for (col, (level, base_col, label)) in bars.iter().enumerate() { + let x = area.x + col as u16; + let filled_rows = (*level * (h - 1.0)) as usize; // -1 for label row + let bar_color = if *level > 0.9 { + Color::Red + } else if *level > 0.7 { + Color::Yellow + } else { + *base_col + }; + + for row in 0..area.height { + let y = area.y + area.height - 1 - row; + let span = if row == 0 { + // Bottom row — label + Span::styled(label.to_string(), Style::default().fg(Color::Gray)) + } else { + let ch = if (row as usize) <= filled_rows { '█' } else { ' ' }; + Span::styled(ch.to_string(), Style::default().fg(bar_color)) + }; + f.render_widget( + Paragraph::new(Line::from(vec![span])), + Rect { x, y, width: 1, height: 1 }, + ); + } + } } +// ── Info + effects grid ─────────────────────────────────────────────────────── + +fn draw_info_effects(f: &mut Frame, deck: &Deck, area: Rect) { + if area.height < 1 { return; } + + // Artist/album (truncated) on first line, then effects in 2-column grid + let artist_str = deck.artist.as_deref().unwrap_or(""); + let album_str = deck.album.as_deref().unwrap_or(""); + + let max_w = area.width.saturating_sub(1) as usize; + let meta_line = if !artist_str.is_empty() || !album_str.is_empty() { + let full = if !album_str.is_empty() { + format!("{} — {}", artist_str, album_str) + } else { + artist_str.to_string() + }; + if full.len() > max_w { + format!("{}…", &full[..max_w.saturating_sub(1)]) + } else { + full + } + } else { + String::new() + }; + + // 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() + }; + 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 reverb_active = deck.reverb > 0.01; + + let eff = |s: &str, active: bool| -> Span { + Span::styled( + s.to_string(), + Style::default().fg(if active { Color::Yellow } else { Color::Gray }), + ) + }; + + let mut lines: Vec = Vec::new(); + + if !meta_line.is_empty() { + lines.push(Line::from(vec![ + Span::styled(meta_line, Style::default().fg(Color::White)), + ])); + } + + // Effects grid: two per row + let col_w = (area.width as usize / 2).max(12); + let e0 = format!("{: char { + // Fill both columns symmetrically from bottom to top. + // Braille dot layout (standard numbering): + // 1 4 (top) + // 2 5 + // 3 6 + // 7 8 (bottom) + // Bit positions: dot1=0, dot2=1, dot3=2, dot4=3, dot5=4, dot6=5, dot7=6, dot8=7 + // + // Fill order (bottom-first, both halves together): + // L-bot both-bot L-2nd both-2nd L-3rd both-3rd L-top both-top + // 7(6) 8(7) 3(2) 6(5) 2(1) 5(4) 1(0) 4(3) + // + // This makes partial cells look wide (not thin left sticks), so waveform + // peaks are visually clean at any height. + let level = (amp.clamp(0.0, 1.0) * 8.0) as u32; + const BITS: [u32; 9] = [ + 0x00, // 0 — empty + 0x40, // 1 — ⡀ dot7 + 0xC0, // 2 — ⣀ dot7+dot8 + 0xC4, // 3 — ⣄ + dot3 + 0xE4, // 4 — ⣤ + dot6 + 0xE6, // 5 — ⣦ + dot2 + 0xF6, // 6 — ⣶ + dot5 + 0xF7, // 7 — ⣷ + dot1 + 0xFF, // 8 — ⣿ + dot4 (full) + ]; + let code = BRAILLE_BASE + BITS[level.min(8) as usize]; + char::from_u32(code).unwrap_or('░') +} + +/// Stereo-band waveform. +/// +/// Each terminal column always represents an integer number of peak buckets, +/// and the viewport's left edge is *snapped* to a bucket boundary. That +/// guarantees a given audio sample always lands in the same column as long as +/// the window width is unchanged, so the bars hold still while the playhead +/// slides across them — no per-frame re-scaling, no dithering. +/// +/// The bar height in each column is the max of the three band peaks (overall +/// loudness). The column's color is `(R = bass, G = mid, B = high)`, scaled to +/// the band ratios — bass-heavy kicks come out red, mids green, hats blue, a +/// full mix near-white. +fn draw_waveform(f: &mut Frame, deck: &Deck, area: Rect, focus_color: Color) { + if area.width < 4 || area.height < 1 { return; } + + let n_rows = area.height as usize; + let width = area.width as usize; + + let sr = deck.sample_rate as f64; + let pos_secs = deck.position as f64 / sr; + let win_secs = 30.0_f64; + + // PEAK_BUCKET must match audio.rs's constant. + const PEAK_BUCKET: usize = 4096; + let secs_per_peak = PEAK_BUCKET as f64 / sr; + let bands = &deck.waveform_bands; + let total = bands.len(); + + // Buckets per column. Snap to integer so the bucket→column mapping is + // stable as `pos_secs` advances by less than one column's worth of audio. + let buckets_per_col = ((win_secs / secs_per_peak) / width as f64) + .round() + .max(1.0) as usize; + let secs_per_col = buckets_per_col as f64 * secs_per_peak; + + // Snap viewport left edge to a column-aligned bucket boundary. + let half_win = secs_per_col * width as f64 / 2.0; + let target_left_sec = (pos_secs - half_win).max(0.0); + let left_bucket = ((target_left_sec / secs_per_peak) as usize + / buckets_per_col) + * buckets_per_col; + let cursor_col = ((pos_secs - left_bucket as f64 * secs_per_peak) / secs_per_col) + .clamp(0.0, width as f64) as usize; + + // Per-column band amplitudes (max-of-bucket within column). + let col_bands: Vec<[f32; 3]> = (0..width).map(|col| { + let bs = left_bucket + col * buckets_per_col; + let be = (bs + buckets_per_col).min(total); + if bs >= total { return [0.0, 0.0, 0.0]; } + let mut acc = [0.0f32; 3]; + for b in &bands[bs..be] { + if b[0] > acc[0] { acc[0] = b[0]; } + if b[1] > acc[1] { acc[1] = b[1]; } + if b[2] > acc[2] { acc[2] = b[2]; } + } + acc + }).collect(); + + // Build per-column (height, color) once, reused across rows. + let col_render: Vec<(f32, Color)> = col_bands.iter().map(|b| { + let height = b[0].max(b[1]).max(b[2]); // total loudness + let color = blend_band_color(b[0], b[1], b[2], focus_color); + (height, color) + }).collect(); + + // Render row by row, top-down. Each row covers a fixed amplitude slice. + for row in 0..n_rows { + let band_top = 1.0 - row as f32 / n_rows as f32; + let band_bottom = 1.0 - (row + 1) as f32 / n_rows as f32; + let band_size = band_top - band_bottom; + + let mut spans: Vec = Vec::with_capacity(width); + for col in 0..width { + let (amp, color) = col_render[col]; + + // Past columns get the band color directly; future columns get a + // dimmer version so the playhead is visually obvious. + let style_color = if col < cursor_col { + color + } else if col == cursor_col { + Color::White + } else { + dim_color(color) + }; + + let ch = if col == cursor_col && row == n_rows - 1 { + '┃' + } else { + let local_amp = if amp >= band_top { + 1.0 + } else if amp <= band_bottom { + 0.0 + } else { + (amp - band_bottom) / band_size + }; + amplitude_to_braille_char(local_amp) + }; + + spans.push(Span::styled(ch.to_string(), Style::default().fg(style_color))); + } + + f.render_widget( + Paragraph::new(Line::from(spans)), + Rect { y: area.y + row as u16, height: 1, ..area }, + ); + } +} + +/// Map per-band peaks (bass, mid, high) ∈ [0,1] to an RGB color via additive +/// mixing: bass=red, mid=green, high=blue. Silence falls back to the deck's +/// focus color so empty regions still look like a deck strip, not garbage. +fn blend_band_color(bass: f32, mid: f32, high: f32, fallback: Color) -> Color { + let total = bass + mid + high; + if total < 0.01 { return fallback; } + // Normalize each band to 0..1 of the strongest band, then map to byte. + let m = bass.max(mid).max(high).max(1e-6); + let r = (bass / m * 255.0).clamp(0.0, 255.0) as u8; + let g = (mid / m * 255.0).clamp(0.0, 255.0) as u8; + let b = (high / m * 255.0).clamp(0.0, 255.0) as u8; + Color::Rgb(r, g, b) +} + +/// Darken an RGB color so the not-yet-played future portion of the waveform +/// is visually distinct from the past portion. +fn dim_color(c: Color) -> Color { + match c { + Color::Rgb(r, g, b) => Color::Rgb(r / 3, g / 3, b / 3), + _ => Color::DarkGray, + } +} + +// ── Help bar ───────────────────────────────────────────────────────────────── + fn draw_help(f: &mut Frame, area: Rect) { let help = Paragraph::new(vec![ Line::from(vec![ - Span::styled(" Focus: ", Style::default().fg(Color::DarkGray)), - k("a"), k("z"), k("s"), k("x"), - Span::styled(" Load: ", Style::default().fg(Color::DarkGray)), - k("Shift+A/Z/S/X"), - Span::styled(" Play/Pause: ", Style::default().fg(Color::DarkGray)), + Span::styled(" Focus: ", Style::default().fg(Color::Gray)), + k("a"), k("s"), k("z"), k("x"), + Span::styled(" Load: ", Style::default().fg(Color::Gray)), + k("Shift+A/S/Z/X"), + Span::styled(" Play/Pause: ", Style::default().fg(Color::Gray)), k("Space"), - Span::styled(" Stop: ", Style::default().fg(Color::DarkGray)), + Span::styled(" Stop: ", Style::default().fg(Color::Gray)), k("Enter"), + Span::styled(" Seek: ", Style::default().fg(Color::Gray)), + k("←/→"), + Span::styled(" Quit: ", Style::default().fg(Color::Gray)), + k("q"), ]), Line::from(vec![ - Span::styled(" Fader: ", Style::default().fg(Color::DarkGray)), - k("↑/↓"), - Span::styled(" Pitch: ", Style::default().fg(Color::DarkGray)), - k("[/]"), - Span::styled(" Tempo: ", Style::default().fg(Color::DarkGray)), - k(",/."), - Span::styled(" Cue: ", Style::default().fg(Color::DarkGray)), - k("c"), Span::raw(" set "), k("v"), Span::raw(" goto"), - Span::styled(" Seek: ", Style::default().fg(Color::DarkGray)), - k("←/→"), - Span::styled(" Quit: ", Style::default().fg(Color::DarkGray)), - k("q"), + Span::styled(" Fader: ", Style::default().fg(Color::Gray)), k("↑/↓"), + Span::styled(" Pitch: ", Style::default().fg(Color::Gray)), k("[/]"), + Span::styled(" Tempo: ", Style::default().fg(Color::Gray)), k(",/."), + Span::styled(" Cutoff: ",Style::default().fg(Color::Gray)), k("-/="), + Span::styled(" Reverb: ",Style::default().fg(Color::Gray)), k("o/p"), + Span::styled(" Cue: ", Style::default().fg(Color::Gray)), + k("c"), Span::raw("set "), k("v"), Span::raw("goto"), ]), ]) .block(Block::default() .borders(Borders::TOP) - .border_style(Style::default().fg(Color::DarkGray))) - .style(Style::default()); + .border_style(Style::default().fg(Color::DarkGray))); f.render_widget(help, area); }