internal process dsp, skipping ladspa pipewire attempt, and reworked waveforms

This commit is contained in:
Bendik Aagaard Lynghaug
2026-05-27 22:04:08 +02:00
parent f81608734a
commit 39095f3a4a
11 changed files with 1606 additions and 838 deletions
+6
View File
@@ -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
+40 -11
View File
@@ -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 <node-id> 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.
+173 -255
View File
@@ -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 <stream-node-id> <value>
/// Pitch → pw-cli set-param <filter-node-id> Props { params = ["pitch:Pitch scale" "<v>"] }
/// Tempo → pw-cli set-param <filter-node-id> Props { params = ["pitch:Time ratio" "<v>"] }
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<Deck>,
pub decks: Vec<Deck>,
pub focused_deck: usize,
pub file_selector: Option<FileSelector>,
audio: AudioEngine,
chains: Vec<EffectChain>, // one per deck, in order
osc_rx: crossbeam_channel::Receiver<OscAppCmd>,
audio: Option<AudioEngine>,
/// Per-deck atomic params, shared with the audio engine's realtime callback.
/// Populated when the audio engine starts.
params: Vec<Arc<DeckParams>>,
osc_rx: crossbeam_channel::Receiver<OscAppCmd>,
audio_dir: PathBuf,
/// Track if we need to redraw the UI
dirty: bool,
dirty: bool,
}
impl App {
pub fn new(audio_dir: Option<PathBuf>) -> Result<Self> {
// 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<CrosstermBackend<io::Stdout>>,
) -> 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<bool> {
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<u32> {
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
}
+443 -171
View File
@@ -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.<x> 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<DeckParams>` 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<f32> },
/// 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<String>,
album: Option<String>,
/// 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<f32>,
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<f32>, // 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<f32>,
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<DeckParams>,
last_vu_update: std::time::Instant,
}
impl DeckState {
fn new(id: DeckId) -> Self {
fn new(id: DeckId, sample_rate: u32, params: Arc<DeckParams>) -> 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<AudioCmd>,
pub cmd_tx: Sender<AudioCmd>,
pub status_rx: Receiver<AudioStatus>,
/// One `Arc<DeckParams>` per deck — the app writes effect parameters into
/// these atomics directly. The realtime callback snapshots them per block.
pub params: Vec<Arc<DeckParams>>,
}
impl AudioEngine {
pub fn new() -> Result<Self> {
let (cmd_tx, cmd_rx) = bounded::<AudioCmd>(64);
let (status_tx, status_rx) = bounded::<AudioStatus>(512);
let (cmd_tx, cmd_rx) = bounded::<AudioCmd>(64);
let (status_tx, status_rx) = bounded::<AudioStatus>(1024);
let params: Vec<Arc<DeckParams>> = (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<AudioCmd>, status_tx: Sender<AudioStatus>) -> Result<()> {
const STREAM_SR: u32 = 44_100;
fn engine_thread(
cmd_rx: Receiver<AudioCmd>,
status_tx: Sender<AudioStatus>,
params: Vec<Arc<DeckParams>>,
) -> 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<Arc<Mutex<DeckState>>> = (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<StreamRc> = Vec::new();
let mut _listeners = Vec::new();
let mut _streams: Vec<StreamRc> = 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<AudioCmd>, status_tx: Sender<AudioStatus>) ->
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<AudioCmd>, status_tx: Sender<AudioStatus>) ->
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<AudioCmd>, status_tx: Sender<AudioStatus>) ->
_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<AudioCmd>, status_tx: Sender<AudioStatus>) ->
Ok(())
}
// ─── Command handler ──────────────────────────────────────────────────────────
fn handle_cmd(cmd: AudioCmd, states: &[Arc<Mutex<DeckState>>], status_tx: &Sender<AudioStatus>) {
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<Mutex<DeckState>>], 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<AudioStatus>,
) -> 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<f32>, 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<f32>, u32)> {
)?;
let mut format = probed.format;
let track = format
.tracks()
.iter()
@@ -363,33 +428,218 @@ fn decode_file(path: &PathBuf) -> Result<(Vec<f32>, 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<f32> = Vec::new();
let mut pcm_batch: Vec<f32> = 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<f32> = 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<f32>, 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<f32>,
mid_bp: biquad::DirectForm1<f32>,
high_hp: biquad::DirectForm1<f32>,
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::<f32>::from_hz(sample_rate as f32).unwrap();
let lp_co = Coefficients::<f32>::from_params(
Type::LowPass, fs, Hertz::<f32>::from_hz(250.0).unwrap(), Q_BUTTERWORTH_F32
).unwrap();
let bp_co = Coefficients::<f32>::from_params(
Type::BandPass, fs, Hertz::<f32>::from_hz(1500.0).unwrap(), 0.7
).unwrap();
let hp_co = Coefficients::<f32>::from_params(
Type::HighPass, fs, Hertz::<f32>::from_hz(4000.0).unwrap(), Q_BUTTERWORTH_F32
).unwrap();
Self {
bass_lp: DirectForm1::<f32>::new(lp_co),
mid_bp: DirectForm1::<f32>::new(bp_co),
high_hp: DirectForm1::<f32>::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<dyn symphonia::core::formats::FormatReader>) -> (Option<String>, Option<String>) {
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<f32>,
total_frames: &mut u64,
buf: AudioBufferRef,
channels: usize,
wf: &mut WaveformAccum,
mono_for_bpm: &mut Vec<f32>,
bpm_cap: usize,
deck: DeckId,
load_gen: u64,
sample_rate: u32,
status_tx: &Sender<AudioStatus>,
) {
macro_rules! push {
($buf:expr, $scale:expr) => {{
let frames = $buf.frames();
@@ -397,11 +647,35 @@ fn push_samples(out: &mut Vec<f32>, 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<f32>, 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<Vec<u8>> {
use pipewire::spa::param::ParamType;
@@ -431,15 +705,13 @@ fn make_f32_stereo_pod() -> Result<Vec<u8>> {
&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)
}
+48 -32
View File
@@ -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<PathBuf>,
pub track: Option<PathBuf>,
pub track_name: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
// 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<u64>,
pub cue_point: Option<u64>,
// 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<u32>,
/// Object ID of the filter-chain node for this deck
pub filter_node_id: Option<u32>,
// 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<f32>,
}
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)
+489
View File
@@ -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<Self> {
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<f32>,
lp_r: DirectForm1<f32>,
hp_l: DirectForm1<f32>,
hp_r: DirectForm1<f32>,
reverb: SchroederStereo,
cached: Cached,
// Scratch — sized lazily, kept across calls.
in_l: Vec<f32>,
in_r: Vec<f32>,
out_l: Vec<f32>,
out_r: Vec<f32>,
}
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::<f32>::new(lp),
lp_r: DirectForm1::<f32>::new(lp),
hp_l: DirectForm1::<f32>::new(hp),
hp_r: DirectForm1::<f32>::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<f32> {
let fs = Hertz::<f32>::from_hz(sample_rate as f32).unwrap();
let f0 = Hertz::<f32>::from_hz(cutoff_hz.clamp(HP_OPEN_HZ, LP_OPEN_HZ)).unwrap();
Coefficients::<f32>::from_params(Type::LowPass, fs, f0, Q_BUTTERWORTH_F32).unwrap()
}
fn make_hp(sample_rate: u32, cutoff_hz: f32) -> Coefficients<f32> {
let fs = Hertz::<f32>::from_hz(sample_rate as f32).unwrap();
let f0 = Hertz::<f32>::from_hz(cutoff_hz.clamp(HP_OPEN_HZ, LP_OPEN_HZ)).unwrap();
Coefficients::<f32>::from_params(Type::HighPass, fs, f0, Q_BUTTERWORTH_F32).unwrap()
}
// ─── 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<f32>,
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<f32>,
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<f32> {
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::<f32>() / 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(&params, &pcm, cursor, &mut block_out);
cursor += consumed;
out[chunk_start * 2..end * 2].copy_from_slice(&block_out);
}
// Skip first 0.2 s for 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(&params, &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(&params, &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);
}
}
-267
View File
@@ -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 <node-id> Props '{ params = ["pitch:Pitch scale" "1.0"] }'
///
/// Fader (volume) is applied on the deck's own playback stream via:
/// wpctl set-volume <stream-node-id> <value>
///
/// 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<u32>,
/// PipeWire object ID of the filter-chain node (used for set-param)
pub node_id: Option<u32>,
/// 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<Self> {
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.<node_name>"
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 <node-id> Props '{ params = ["<plugin_node_name>:<port>" "<value>"] }'
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", &param])
.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 <node-id> <value>
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<u32> {
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
}
+1 -1
View File
@@ -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();
+4 -2
View File
@@ -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
+12 -6
View File
@@ -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<OscAppCmd>) -> Result<()> {
@@ -79,9 +83,11 @@ fn handle_msg(msg: OscMessage, tx: &Sender<OscAppCmd>) {
("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,
+390 -93
View File
@@ -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<Line> = 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!("{:<width$}", pitch_str, width = col_w);
let e1 = format!("{:<width$}", tempo_str, width = col_w);
let e2 = format!("{:<width$}", cutoff_str, width = col_w);
let e3 = reverb_str.clone();
lines.push(Line::from(vec![eff(&e0, pitch_active), eff(&e1, tempo_active)]));
lines.push(Line::from(vec![eff(&e2, cutoff_active), eff(&e3, reverb_active)]));
f.render_widget(
Paragraph::new(lines),
area,
);
}
// ── Waveform timeline ─────────────────────────────────────────────────────────
//
// Shows a 30-second window centred on the current playback position.
// Each terminal column represents (30s / width) seconds of audio.
// Amplitude encoded using braille dot-matrix: each char = 2 cols × 4 rows,
// giving 8 amplitude levels per 2 horizontal samples.
//
// We use 2 terminal rows → each row covers 4 braille dot rows → amplitude
// is encoded across both rows for 8 distinct levels per column.
const BRAILLE_BASE: u32 = 0x2800;
/// Map an amplitude 0..1 into braille dots for two terminal rows (top, bottom).
/// The braille char encodes a 2-wide × 4-tall grid; we use the left column only
/// (dots 1,2,3,7 = bottom row; dots 4,5,6,8 = hmm… let's use block rows).
/// Simpler: use the 8 vertical block elements for a single row.
fn amplitude_to_braille_char(amp: f32) -> 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<Span> = 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);
}