commit 8a7acd6d5b93d8acd1585ec6494b3fb44f26b2d9 Author: Bendik Aagaard Lynghaug Date: Thu Mar 5 15:41:53 2026 +0100 initial code scetch diff --git a/README.md b/README.md new file mode 100644 index 0000000..f5e94ef --- /dev/null +++ b/README.md @@ -0,0 +1,166 @@ +# djdeck + +Terminal DJ suite — Rust + Ratatui + PipeWire graph DSP. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Ratatui TUI (16ms loop, crossterm events) │ +│ OSC server (UDP :9000 in, :9001 out) │ +└──────────┬──────────────────────────────┬───────────────────┘ + │ │ + AudioCmd channel OscAppCmd channel + │ │ +┌──────────▼──────────┐ ┌──────────▼────────────────────┐ +│ Audio Engine │ │ App.rs effect dispatch │ +│ (PipeWire streams) │ │ fader → wpctl set-volume │ +│ Symphonia decode │ │ pitch → pw-cli set-param Props│ +│ Raw PCM only │ │ tempo → pw-cli set-param Props│ +└──────────┬──────────┘ └───────────────────────────────┘ + │ auto-connects to filter-chain sink + ▼ +┌──────────────────────────────────────────────────────────────┐ +│ PipeWire Graph │ +│ │ +│ djdeck.deck.a ──► input.djdeck.chain.a │ +│ [rubberband LADSPA] │ +│ output.djdeck.chain.a ──► default sink │ +│ │ +│ (same for z, s, x) │ +└──────────────────────────────────────────────────────────────┘ +``` + +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` +- **Volume (fader)** — `wpctl set-volume` on the stream node +- **Pitch/Tempo params** — `pw-cli set-param Props '{ params = [...] }'` + +## Dependencies + +### System + +```bash +# Arch +pacman -S pipewire wireplumber pipewire-audio ladspa rubberband + +# Ubuntu / Debian +apt install pipewire wireplumber pipewire-audio ladspa-sdk rubberband-ladspa + +# Fedora +dnf install pipewire wireplumber ladspa rubberband-ladspa +``` + +Verify the rubberband LADSPA plugin is present: +```bash +analyseplugin ladspa-rubberband.so +# should list labels including: rubberband_pitchshifter_stereo +``` + +### Rust + +``` +pipewire-rs (pipewire = "0.8") +symphonia (audio decoding) +ratatui + crossterm +rosc (OSC) +tokio, crossbeam-channel, serde_json +``` + +## Build & Run + +```bash +cargo build --release +./target/release/djdeck +``` + +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 +3. Wait 800ms for PipeWire to settle, then resolve node IDs via `pw-dump` +4. Start the TUI and OSC server + +Node IDs for each deck are shown in the deck panel title: `[pw:stream/filter]`. + +## Keyboard Reference + +| Key | Action | +|-----|--------| +| `a / z / s / x` | Focus deck A / Z / S / X | +| `Shift + A/Z/S/X` | Open file selector for deck | +| `Space` | Play / Pause | +| `Enter` | Stop (return to start) | +| `← / →` | Seek ±1% | +| `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) | +| `q` | Quit (unloads filter-chain modules) | + +**File selector:** type to filter, `↑/↓` navigate, `Enter` load, `Esc` cancel. +Files indexed from CWD, cached at `$XDG_CACHE_HOME/djdeck/file_index.json`. + +## OSC Reference + +### Incoming (port 9000) + +| Address | Args | Description | +|---------|------|-------------| +| `/deck//play` | | Play | +| `/deck//pause` | | Pause | +| `/deck//stop` | | Stop | +| `/deck//seek` | `f` 0.0–1.0 | Seek to position | +| `/deck//fader` | `f` 0.0–1.0 | Set volume (wpctl) | +| `/deck//pitch` | `f` semitones | Set pitch (rubberband) | +| `/deck//tempo` | `f` multiplier | Set tempo (rubberband) | +| `/deck//cue/set` | | Mark cue point | +| `/deck//cue/goto` | | Jump to cue point | + +`` = 0 (A), 1 (Z), 2 (S), 3 (X) + +### Outgoing (port 9001) + +| Address | Args | +|---------|------| +| `/deck//loaded` | `s` track name | +| `/deck//position` | `f` 0.0–1.0 | +| `/deck//vu` | `f f` L R | + +## Inspecting the graph + +```bash +# See all djdeck nodes +pw-dump | jq '.[] | select(.info.props["node.name"] | startswith("djdeck"))' + +# Live graph view +qpwgraph # or helvum + +# Check filter-chain params for deck A +pw-cli set-param Props '{ params = ["pitch:Pitch scale" "1.0594"] }' + +# Volume on stream node +wpctl set-volume 0.85 +``` + +## Adding more effects + +The effect chain abstraction is in `src/effect_chain.rs`. The filter-chain module +`filter.graph` in `EffectChain::load()` accepts multiple nodes wired in series: + +``` +filter.graph = { + nodes = [ + { type = ladspa name = pitch plugin = ladspa-rubberband label = rubberband_pitchshifter_stereo ... } + { type = ladspa name = eq plugin = lsp-plugins-ladspa label = para_equalizer_x8_stereo ... } + ] + links = [ + { output = "pitch:Output L" input = "eq:Input L" } + { output = "pitch:Output R" input = "eq:Input R" } + ] +} +``` + +No Rust changes needed — just extend the `args` string in `EffectChain::load()`. diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..b660720 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,422 @@ +/// 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 +/// +/// Runtime param changes: +/// Fader → wpctl set-volume +/// Pitch → pw-cli set-param Props { params = ["pitch:Pitch scale" ""] } +/// Tempo → pw-cli set-param Props { params = ["pitch:Time ratio" ""] } +use std::{io, time::Duration}; + +use anyhow::Result; +use crossterm::{ + event::{self, Event, KeyCode, KeyEvent, KeyModifiers}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{backend::CrosstermBackend, Terminal}; + +use crate::{ + audio::{AudioCmd, AudioEngine, AudioStatus}, + deck::{Deck, DeckId, PlayState}, + effect_chain::EffectChain, + file_selector::FileSelector, + osc::{self, OscAppCmd}, + tui, +}; + +pub struct App { + pub decks: Vec, + pub focused_deck: usize, + pub file_selector: Option, + + audio: AudioEngine, + chains: Vec, // one per deck, in order + osc_rx: crossbeam_channel::Receiver, +} + +impl App { + pub fn new() -> Result { + // 1. Load filter-chain modules for all 4 decks + tracing::info!("Loading PipeWire filter-chain modules…"); + let mut chains = Vec::new(); + for i in 0..4 { + let deck_id = DeckId::from_index(i).unwrap(); + match EffectChain::load(deck_id) { + Ok(chain) => { + tracing::info!("Filter-chain loaded for deck {}", deck_id.label()); + chains.push(chain); + } + Err(e) => { + tracing::warn!( + "Filter-chain load failed for deck {} (rubberband LADSPA not installed?): {e}", + deck_id.label() + ); + // Push a stub with no module_id so the rest of the app still works + chains.push(EffectChain { + deck: deck_id, + module_id: None, + node_id: None, + node_name: EffectChain::node_name_for(deck_id), + }); + } + } + } + + // 2. Start audio engine (PipeWire streams) + let audio = AudioEngine::new()?; + + // 3. OSC channel + let (osc_tx, osc_rx) = crossbeam_channel::bounded(128); + osc::start_osc_server(osc_tx)?; + + let decks = (0..4) + .map(|i| Deck::new(DeckId::from_index(i).unwrap())) + .collect(); + + Ok(Self { + decks, + focused_deck: 0, + file_selector: None, + audio, + chains, + osc_rx, + }) + } + + pub async fn run(&mut self) -> Result<()> { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + 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 result = self.event_loop(&mut terminal).await; + + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + result + } + + /// Query pw-dump and fill in filter_node_id for each deck. + fn resolve_all_node_ids(&mut self) { + for (i, chain) in self.chains.iter_mut().enumerate() { + if chain.module_id.is_none() { continue; } + match chain.resolve_node_id() { + Ok(()) => { + self.decks[i].filter_node_id = chain.node_id; + tracing::info!( + "Deck {} filter node id = {:?}", + chain.deck.label(), + chain.node_id + ); + } + Err(e) => tracing::warn!("Could not resolve filter node for deck {}: {e}", chain.deck.label()), + } + } + } + + async fn event_loop( + &mut self, + terminal: &mut Terminal>, + ) -> Result<()> { + loop { + terminal.draw(|f| tui::draw(f, self))?; + + // Drain audio status + while let Ok(status) = self.audio.status_rx.try_recv() { + self.handle_audio_status(status); + } + + // Drain OSC commands + while let Ok(cmd) = self.osc_rx.try_recv() { + if self.handle_osc_cmd(cmd)? { return Ok(()); } + } + + // Keyboard (16ms poll → ~60fps) + if event::poll(Duration::from_millis(16))? { + if let Event::Key(key) = event::read()? { + if self.handle_key(key)? { return Ok(()); } + } + } + } + } + + // ─── Audio status ───────────────────────────────────────────────────────── + + fn handle_audio_status(&mut self, status: AudioStatus) { + match status { + AudioStatus::Loaded { deck, duration_samples, sample_rate, name, stream_node_id } => { + 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); + } + } + osc::send_osc( + &format!("/deck/{}/loaded", deck as usize), + vec![rosc::OscType::String(name)], + ); + } + AudioStatus::Position { deck, position } => { + self.decks[deck as usize].position = position; + osc::send_osc( + &format!("/deck/{}/position", deck as usize), + 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); + d.vu.1 = (d.vu.1 * 0.65).max(r); + osc::send_osc( + &format!("/deck/{}/vu", deck as usize), + vec![rosc::OscType::Float(l), rosc::OscType::Float(r)], + ); + } + AudioStatus::TrackEnded(deck) => { + self.decks[deck as usize].play_state = PlayState::Stopped; + } + AudioStatus::Error { deck, msg } => { + tracing::error!("Deck {:?}: {}", deck, msg); + self.decks[deck as usize].track_name = Some(format!("ERR: {}", msg)); + } + } + } + + // ─── OSC commands ───────────────────────────────────────────────────────── + + fn handle_osc_cmd(&mut self, cmd: OscAppCmd) -> Result { + match cmd { + OscAppCmd::Audio(audio_cmd) => { + self.sync_play_state_from_audio_cmd(&audio_cmd); + let _ = self.audio.cmd_tx.send(audio_cmd); + } + 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), + } + Ok(false) + } + + fn sync_play_state_from_audio_cmd(&mut self, cmd: &AudioCmd) { + match cmd { + AudioCmd::Play(d) => self.decks[*d as usize].play_state = PlayState::Playing, + AudioCmd::Pause(d) => self.decks[*d as usize].play_state = PlayState::Paused, + AudioCmd::Stop(d) => self.decks[*d as usize].play_state = PlayState::Stopped, + _ => {} + } + } + + // ─── Keyboard ───────────────────────────────────────────────────────────── + + fn handle_key(&mut self, key: KeyEvent) -> Result { + if self.file_selector.is_some() { + return self.handle_key_selector(key); + } + + match (key.modifiers, key.code) { + (_, KeyCode::Char('q')) => return Ok(true), + (KeyModifiers::CONTROL, KeyCode::Char('c')) => return Ok(true), + + // Deck focus + (KeyModifiers::NONE, KeyCode::Char('a')) => self.focused_deck = 0, + (KeyModifiers::NONE, KeyCode::Char('z')) => self.focused_deck = 1, + (KeyModifiers::NONE, KeyCode::Char('s')) => self.focused_deck = 2, + (KeyModifiers::NONE, KeyCode::Char('x')) => self.focused_deck = 3, + + // Open file selector + (KeyModifiers::SHIFT, KeyCode::Char('A')) => self.open_selector(0), + (KeyModifiers::SHIFT, KeyCode::Char('Z')) => self.open_selector(1), + (KeyModifiers::SHIFT, KeyCode::Char('S')) => self.open_selector(2), + (KeyModifiers::SHIFT, KeyCode::Char('X')) => self.open_selector(3), + + // Transport + (KeyModifiers::NONE, KeyCode::Char(' ')) => self.toggle_play(), + (KeyModifiers::NONE, KeyCode::Enter) => self.stop_focused(), + (KeyModifiers::NONE, KeyCode::Left) => self.seek_relative(-0.01), + (KeyModifiers::NONE, KeyCode::Right) => self.seek_relative(0.01), + + // Cue + (KeyModifiers::NONE, KeyCode::Char('c')) => self.set_cue(), + (KeyModifiers::NONE, KeyCode::Char('v')) => self.goto_cue(), + + // Fader → wpctl + (KeyModifiers::NONE, KeyCode::Up) => self.nudge_fader(0.05), + (KeyModifiers::NONE, KeyCode::Down) => self.nudge_fader(-0.05), + + // Pitch → pw-cli filter-chain Props + (KeyModifiers::NONE, KeyCode::Char(']')) => self.nudge_pitch(0.5), + (KeyModifiers::NONE, KeyCode::Char('[')) => self.nudge_pitch(-0.5), + + // Tempo → pw-cli filter-chain Props + (KeyModifiers::NONE, KeyCode::Char('.')) => self.nudge_tempo(0.01), + (KeyModifiers::NONE, KeyCode::Char(',')) => self.nudge_tempo(-0.01), + + _ => {} + } + Ok(false) + } + + fn handle_key_selector(&mut self, key: KeyEvent) -> Result { + match key.code { + KeyCode::Esc => { self.file_selector = None; } + KeyCode::Up => { self.file_selector.as_mut().unwrap().move_up(); } + KeyCode::Down => { self.file_selector.as_mut().unwrap().move_down(); } + KeyCode::Backspace => { self.file_selector.as_mut().unwrap().pop_char(); } + KeyCode::Enter => { + if let Some(sel) = &self.file_selector { + if let Some(path) = sel.confirm() { + 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 }); + } + } + self.file_selector = None; + } + KeyCode::Char(c) => { self.file_selector.as_mut().unwrap().push_char(c); } + _ => {} + } + Ok(false) + } + + // ─── Helpers ────────────────────────────────────────────────────────────── + + 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) { + self.file_selector = Some(FileSelector::new(id)); + } + } + + fn toggle_play(&mut self) { + let deck = self.focused_id(); + let d = &mut self.decks[self.focused_deck]; + if !d.is_loaded() { return; } + match d.play_state { + PlayState::Playing => { + d.play_state = PlayState::Paused; + let _ = self.audio.cmd_tx.send(AudioCmd::Pause(deck)); + } + _ => { + d.play_state = PlayState::Playing; + let _ = self.audio.cmd_tx.send(AudioCmd::Play(deck)); + } + } + } + + 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)); + } + + 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 }); + } + + fn set_cue(&mut self) { + let deck = self.focused_id(); + 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)); + } + + fn goto_cue(&mut self) { + let deck = self.focused_id(); + 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)); + } + + 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); + self.apply_fader(deck, new_val); + } + + fn apply_fader(&mut self, deck: DeckId, value: f32) { + let value = value.clamp(0.0, 1.0); + 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()); + } + } + } + + fn nudge_pitch(&mut self, delta: f32) { + 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); + } + + 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()); + } + } + + fn nudge_tempo(&mut self, delta: f32) { + 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); + } + + 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()); + } + } +} + +// ─── pw-dump node ID lookup ──────────────────────────────────────────────────── + +/// Find a PipeWire node's object ID by its node.name property, via pw-dump. +fn find_pw_node_id(node_name: &str) -> Option { + let out = std::process::Command::new("pw-dump").output().ok()?; + let json: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?; + let arr = json.as_array()?; + for obj in arr { + if obj.get("type")?.as_str()? != "PipeWire:Interface:Node" { continue; } + let props = obj.get("info")?.get("props")?; + if props.get("node.name")?.as_str()? == node_name { + return obj.get("id")?.as_u64().map(|i| i as u32); + } + } + None +} diff --git a/src/cache.rs b/src/cache.rs new file mode 100644 index 0000000..abd2492 --- /dev/null +++ b/src/cache.rs @@ -0,0 +1,75 @@ +use std::{fs, path::{Path, PathBuf}}; +use anyhow::Result; +use serde::{Deserialize, Serialize}; + +const AUDIO_EXTS: &[&str] = &[ + "mp3", "flac", "ogg", "wav", "aac", "m4a", "opus", "wma", +]; + +#[derive(Serialize, Deserialize)] +struct CacheFile { + root: String, + files: Vec, +} + +fn cache_path() -> PathBuf { + dirs::cache_dir() + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join("djdeck") + .join("file_index.json") +} + +pub fn get_audio_files(root: &Path) -> Result> { + let cp = cache_path(); + let root_str = root.to_string_lossy().into_owned(); + + // Try cache + if let Ok(data) = fs::read_to_string(&cp) { + if let Ok(cache) = serde_json::from_str::(&data) { + if cache.root == root_str { + return Ok(cache.files.into_iter().map(PathBuf::from).collect()); + } + } + } + + // Walk + let mut files: Vec = walkdir::WalkDir::new(root) + .follow_links(true) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_file()) + .filter(|e| { + e.path() + .extension() + .and_then(|x| x.to_str()) + .map(|x| AUDIO_EXTS.contains(&x.to_lowercase().as_str())) + .unwrap_or(false) + }) + .map(|e| e.into_path()) + .collect(); + + files.sort(); + + // Write cache + if let Some(p) = cp.parent() { let _ = fs::create_dir_all(p); } + let cache = CacheFile { + root: root_str, + files: files.iter().map(|p| p.to_string_lossy().into_owned()).collect(), + }; + if let Ok(json) = serde_json::to_string(&cache) { + let _ = fs::write(&cp, json); + } + + Ok(files) +} + +pub fn filter_files<'a>(files: &'a [PathBuf], query: &str) -> Vec<&'a PathBuf> { + let q = query.to_lowercase(); + if q.is_empty() { + return files.iter().collect(); + } + files + .iter() + .filter(|p| p.to_string_lossy().to_lowercase().contains(&q)) + .collect() +} diff --git a/src/deck.rs b/src/deck.rs new file mode 100644 index 0000000..9811c7b --- /dev/null +++ b/src/deck.rs @@ -0,0 +1,135 @@ +use std::path::PathBuf; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum DeckId { + A = 0, + Z = 1, + S = 2, + X = 3, +} + +impl DeckId { + pub fn from_index(i: usize) -> Option { + match i { + 0 => Some(Self::A), + 1 => Some(Self::Z), + 2 => Some(Self::S), + 3 => Some(Self::X), + _ => None, + } + } + + pub fn label(self) -> &'static str { + match self { + Self::A => "A", + Self::Z => "Z", + Self::S => "S", + Self::X => "X", + } + } + + /// Lowercase node name suffix used in PipeWire node names + pub fn node_suffix(self) -> &'static str { + match self { + Self::A => "a", + Self::Z => "z", + Self::S => "s", + Self::X => "x", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PlayState { + Stopped, + Playing, + Paused, + Cued, +} + +impl PlayState { + pub fn icon(&self) -> &'static str { + match self { + Self::Playing => "▶", + Self::Paused => "⏸", + Self::Stopped => "⏹", + Self::Cued => "⏮", + } + } +} + +#[derive(Debug, Clone)] +pub struct Deck { + pub id: DeckId, + + // Track + pub track: Option, + pub track_name: Option, + + // Transport + pub play_state: PlayState, + pub position: u64, // samples + pub duration: u64, // samples + pub sample_rate: u32, + pub cue_point: Option, + + // Per-deck mix controls + /// 0.0 – 1.0, sent to wpctl on the deck's PipeWire stream node + pub fader: f32, + + // 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) + pub vu: (f32, f32), + + // PipeWire object IDs (populated after graph setup) + /// Object ID of the deck's playback stream node + pub stream_node_id: Option, + /// Object ID of the filter-chain node for this deck + pub filter_node_id: Option, +} + +impl Deck { + pub fn new(id: DeckId) -> Self { + Self { + id, + track: None, + track_name: 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, + } + } + + pub fn is_loaded(&self) -> bool { + self.track.is_some() + } + + 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) + } + } + + pub fn elapsed(&self) -> (u64, u64) { + let s = self.position / self.sample_rate as u64; + (s / 60, s % 60) + } + + pub fn remaining(&self) -> (u64, u64) { + let s = self.duration.saturating_sub(self.position) / self.sample_rate as u64; + (s / 60, s % 60) + } +} diff --git a/src/effect_chain.rs b/src/effect_chain.rs new file mode 100644 index 0000000..7cfbb54 --- /dev/null +++ b/src/effect_chain.rs @@ -0,0 +1,267 @@ +/// Effect chain management +/// +/// Each deck gets a PipeWire filter-chain node loaded via: +/// pw-cli load-module libpipewire-module-filter-chain '{ ... }' +/// +/// The chain hosts the rubberband LADSPA pitch+tempo plugin. +/// Runtime parameter changes go through: +/// pw-cli set-param Props '{ params = ["pitch:Pitch scale" "1.0"] }' +/// +/// Fader (volume) is applied on the deck's own playback stream via: +/// wpctl set-volume +/// +/// Node IDs are discovered after load by parsing pw-dump JSON output. +use std::process::Command; + +use anyhow::{anyhow, Result}; + +use crate::deck::DeckId; + +/// Known rubberband LADSPA plugin labels and their control port names. +/// Run `analyseplugin ladspa-rubberband.so` to see the full list on your system. +/// We use the stereo pitch shifter variant. +const RUBBERBAND_PLUGIN: &str = "ladspa-rubberband"; +const RUBBERBAND_LABEL: &str = "rubberband_pitchshifter_stereo"; +const PITCH_PORT: &str = "Pitch scale"; // control port name +const TEMPO_PORT: &str = "Time ratio"; // control port name (some builds call it "Tempo ratio") + +pub struct EffectChain { + pub deck: DeckId, + /// Module ID returned by pw-cli load-module (used for unload) + pub module_id: Option, + /// PipeWire object ID of the filter-chain node (used for set-param) + pub node_id: Option, + /// Human-readable node name used to find the node ID in pw-dump + pub node_name: String, +} + +impl EffectChain { + /// Build the node name for a deck — must be unique and stable. + pub fn node_name_for(deck: DeckId) -> String { + format!("djdeck.chain.{}", deck.node_suffix()) + } + + /// Load the filter-chain module for this deck. + /// Returns an EffectChain with module_id set; node_id is resolved separately. + pub fn load(deck: DeckId) -> Result { + let node_name = Self::node_name_for(deck); + + // Build the SPA JSON argument for pw-cli load-module + // We expose the filter-chain as a sink that the deck stream auto-connects to, + // and whose output goes to the default audio sink. + let args = format!( + r#"{{ + node.name = "{node_name}" + node.description = "djdeck Deck {label} chain" + media.name = "{node_name}" + filter.graph = {{ + nodes = [ + {{ + type = ladspa + name = pitch + plugin = {plugin} + label = {label_plugin} + control = {{ + "{pitch_port}" = 1.0 + "{tempo_port}" = 1.0 + }} + }} + ] + }} + audio.channels = 2 + audio.position = [ FL FR ] + capture.props = {{ + node.name = "input.{node_name}" + media.class = Audio/Sink + node.passive = true + }} + playback.props = {{ + node.name = "output.{node_name}" + media.class = Audio/Source + }} +}}"#, + node_name = node_name, + label = deck.label(), + plugin = RUBBERBAND_PLUGIN, + label_plugin = RUBBERBAND_LABEL, + pitch_port = PITCH_PORT, + tempo_port = TEMPO_PORT, + ); + + tracing::info!("Loading filter-chain for deck {}: {}", deck.label(), node_name); + + let output = Command::new("pw-cli") + .args(["load-module", "libpipewire-module-filter-chain", &args]) + .output()?; + + if !output.status.success() { + let err = String::from_utf8_lossy(&output.stderr); + return Err(anyhow!("pw-cli load-module failed: {}", err)); + } + + // pw-cli prints something like "Module: id:42, ..." + let stdout = String::from_utf8_lossy(&output.stdout); + let module_id = parse_id_from_pw_cli_output(&stdout, "Module"); + + tracing::info!( + "Deck {} filter-chain loaded, module_id={:?}", + deck.label(), + module_id + ); + + Ok(Self { + deck, + module_id, + node_id: None, + node_name, + }) + } + + /// Resolve the node_id by scanning pw-dump JSON for our node name. + /// Call this once after load, ideally with a short delay to let PipeWire settle. + pub fn resolve_node_id(&mut self) -> Result<()> { + let output = Command::new("pw-dump").output()?; + if !output.status.success() { + return Err(anyhow!("pw-dump failed")); + } + let json: serde_json::Value = serde_json::from_slice(&output.stdout)?; + let objects = json.as_array().ok_or_else(|| anyhow!("pw-dump not array"))?; + + for obj in objects { + let type_str = obj.get("type").and_then(|t| t.as_str()).unwrap_or(""); + if type_str != "PipeWire:Interface:Node" { + continue; + } + let props = obj.get("info") + .and_then(|i| i.get("props")) + .unwrap_or(&serde_json::Value::Null); + + let name = props.get("node.name").and_then(|n| n.as_str()).unwrap_or(""); + // The filter-chain creates two sub-nodes (input. and output. prefixed). + // We want the output (playback) side for routing, but either gives us params. + // Match the capture/sink side: "input." + if name == format!("input.{}", self.node_name) { + let id = obj.get("id").and_then(|i| i.as_u64()).map(|i| i as u32); + self.node_id = id; + tracing::info!( + "Resolved filter node id {:?} for deck {}", + id, + self.deck.label() + ); + return Ok(()); + } + } + Err(anyhow!( + "Could not find filter-chain node '{}' in pw-dump", + self.node_name + )) + } + + /// Set pitch in semitones. Converted to a scale factor (2^(st/12)). + pub fn set_pitch(&self, semitones: f32) -> Result<()> { + let scale = 2.0_f32.powf(semitones / 12.0); + self.set_param("pitch", PITCH_PORT, scale) + } + + /// Set tempo multiplier (1.0 = normal, 1.1 = 10% faster). + pub fn set_tempo(&self, multiplier: f32) -> Result<()> { + // rubberband time ratio is 1/tempo_multiplier + let ratio = 1.0 / multiplier.clamp(0.5, 2.0); + self.set_param("pitch", TEMPO_PORT, ratio) + } + + /// Low-level: set a LADSPA control port value on the filter-chain node. + /// + /// Uses: + /// pw-cli set-param Props '{ params = [":" ""] }' + fn set_param(&self, plugin_node: &str, port: &str, value: f32) -> Result<()> { + let node_id = self + .node_id + .ok_or_else(|| anyhow!("filter node_id not resolved yet"))?; + + let param = format!( + "{{ params = [\"{plugin_node}:{port}\" \"{value:.6}\"] }}", + plugin_node = plugin_node, + port = port, + value = value, + ); + + tracing::debug!( + "set-param deck {} node {} Props {}", + self.deck.label(), + node_id, + param + ); + + let status = Command::new("pw-cli") + .args(["set-param", &node_id.to_string(), "Props", ¶m]) + .status()?; + + if !status.success() { + return Err(anyhow!( + "pw-cli set-param failed for deck {}", + self.deck.label() + )); + } + Ok(()) + } + + /// Set the volume (fader) on the deck's PipeWire stream node via wpctl. + /// + /// wpctl set-volume + pub fn set_volume(stream_node_id: u32, value: f32) -> Result<()> { + let status = Command::new("wpctl") + .args([ + "set-volume", + &stream_node_id.to_string(), + &format!("{:.4}", value.clamp(0.0, 1.5)), + ]) + .status()?; + + if !status.success() { + return Err(anyhow!( + "wpctl set-volume failed for node {}", + stream_node_id + )); + } + Ok(()) + } + + /// Unload this module from PipeWire. + pub fn unload(&self) -> Result<()> { + if let Some(id) = self.module_id { + let status = Command::new("pw-cli") + .args(["unload-module", &id.to_string()]) + .status()?; + if !status.success() { + return Err(anyhow!("pw-cli unload-module {} failed", id)); + } + tracing::info!("Unloaded filter-chain module {} for deck {}", id, self.deck.label()); + } + Ok(()) + } +} + +impl Drop for EffectChain { + fn drop(&mut self) { + if let Err(e) = self.unload() { + tracing::warn!("EffectChain drop unload error: {e}"); + } + } +} + +/// Parse a numeric ID from pw-cli output lines like: +/// "Module: id:42, type:PipeWire:Interface:Module/3" +fn parse_id_from_pw_cli_output(text: &str, prefix: &str) -> Option { + for line in text.lines() { + if line.trim_start().starts_with(prefix) { + // look for "id:N" + if let Some(pos) = line.find("id:") { + let rest = &line[pos + 3..]; + let end = rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len()); + return rest[..end].parse().ok(); + } + } + } + None +} diff --git a/src/file_selector.rs b/src/file_selector.rs new file mode 100644 index 0000000..7f52294 --- /dev/null +++ b/src/file_selector.rs @@ -0,0 +1,115 @@ +use std::path::PathBuf; + +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}, + Frame, +}; + +use crate::{cache, deck::DeckId}; + +pub struct FileSelector { + pub deck: DeckId, + pub query: String, + all_files: Vec, + pub filtered: Vec, + pub list_state: ListState, +} + +impl FileSelector { + pub fn new(deck: DeckId) -> Self { + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let all_files = cache::get_audio_files(&cwd).unwrap_or_default(); + let filtered = all_files.clone(); + let mut ls = ListState::default(); + if !filtered.is_empty() { ls.select(Some(0)); } + Self { deck, query: String::new(), all_files, filtered, list_state: ls } + } + + fn refresh(&mut self) { + self.filtered = cache::filter_files(&self.all_files, &self.query) + .into_iter() + .cloned() + .collect(); + self.list_state.select(if self.filtered.is_empty() { None } else { Some(0) }); + } + + pub fn push_char(&mut self, c: char) { self.query.push(c); self.refresh(); } + pub fn pop_char(&mut self) { self.query.pop(); self.refresh(); } + + pub fn move_up(&mut self) { + if self.filtered.is_empty() { return; } + let i = self.list_state.selected().unwrap_or(0); + self.list_state.select(Some(if i == 0 { self.filtered.len() - 1 } else { i - 1 })); + } + + pub fn move_down(&mut self) { + if self.filtered.is_empty() { return; } + let i = self.list_state.selected().unwrap_or(0); + self.list_state.select(Some((i + 1) % self.filtered.len())); + } + + pub fn confirm(&self) -> Option { + self.filtered.get(self.list_state.selected()?).cloned() + } + + pub fn render(&mut self, f: &mut Frame, area: Rect) { + let popup = centered_rect(70, 70, area); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(1)]) + .split(popup); + + f.render_widget(Clear, popup); + + let outer = Block::default() + .borders(Borders::ALL) + .title(format!(" Load track → Deck {} ", self.deck.label())) + .style(Style::default().fg(Color::Cyan)); + f.render_widget(outer, popup); + + let search = Paragraph::new(format!(" {}▌", self.query)) + .block(Block::default() + .borders(Borders::ALL) + .title(" Search ") + .border_style(Style::default().fg(Color::Yellow))); + f.render_widget(search, chunks[0]); + + let items: Vec = self.filtered.iter().map(|p| { + let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("?"); + 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)), + ])) + }).collect(); + + let list = List::new(items) + .block(Block::default().borders(Borders::LEFT | Borders::RIGHT | Borders::BOTTOM)) + .highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD)) + .highlight_symbol("▶ "); + + f.render_stateful_widget(list, chunks[1], &mut self.list_state); + } +} + +fn centered_rect(px: u16, py: u16, r: Rect) -> Rect { + let vert = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - py) / 2), + Constraint::Percentage(py), + Constraint::Percentage((100 - py) / 2), + ]) + .split(r); + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - px) / 2), + Constraint::Percentage(px), + Constraint::Percentage((100 - px) / 2), + ]) + .split(vert[1])[1] +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..509436a --- /dev/null +++ b/src/main.rs @@ -0,0 +1,28 @@ +mod app; +mod audio; +mod cache; +mod deck; +mod effect_chain; +mod file_selector; +mod osc; +mod tui; + +use anyhow::Result; + +#[tokio::main] +async fn main() -> Result<()> { + // Log to file so we don't pollute the TUI + let log_file = std::fs::File::create("/tmp/djdeck.log")?; + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::from_default_env() + .add_directive(tracing::Level::INFO.into()), + ) + .with_writer(log_file) + .init(); + + tracing::info!("djdeck starting"); + + let mut app = app::App::new()?; + app.run().await +} diff --git a/src/osc.rs b/src/osc.rs new file mode 100644 index 0000000..393a0be --- /dev/null +++ b/src/osc.rs @@ -0,0 +1,108 @@ +/// OSC server and sender +/// +/// Listen port : 9000 +/// Send port : 9001 +/// +/// Incoming address map (/deck//...): +/// play, pause, stop +/// seek f (0.0–1.0) +/// 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 +/// cue/set, cue/goto +/// +/// Outgoing: +/// /deck//position f +/// /deck//vu f f +/// /deck//loaded s +use std::{net::UdpSocket, thread}; + +use anyhow::Result; +use crossbeam_channel::Sender; +use rosc::{encoder, OscMessage, OscPacket, OscType}; + +use crate::audio::AudioCmd; +use crate::deck::DeckId; + +pub const LISTEN_PORT: u16 = 9000; +pub const SEND_PORT: u16 = 9001; + +/// Messages that require effect chain interaction (pitch/tempo/fader) +/// are sent back through AppCmd so the app can call the appropriate +/// EffectChain / wpctl method. +#[derive(Debug)] +pub enum OscAppCmd { + Audio(AudioCmd), + SetPitch { deck: DeckId, semitones: f32 }, + SetTempo { deck: DeckId, multiplier: f32 }, + SetFader { deck: DeckId, value: f32 }, +} + +pub fn start_osc_server(cmd_tx: Sender) -> Result<()> { + let socket = UdpSocket::bind(format!("0.0.0.0:{}", LISTEN_PORT))?; + tracing::info!("OSC listening on :{}", LISTEN_PORT); + + thread::Builder::new() + .name("osc-server".into()) + .spawn(move || { + let mut buf = [0u8; 4096]; + loop { + match socket.recv_from(&mut buf) { + Ok((size, _)) => { + if let Ok((_, pkt)) = rosc::decoder::decode_udp(&buf[..size]) { + handle_packet(pkt, &cmd_tx); + } + } + Err(e) => tracing::error!("OSC recv: {e}"), + } + } + })?; + + Ok(()) +} + +fn handle_packet(pkt: OscPacket, tx: &Sender) { + match pkt { + OscPacket::Message(msg) => handle_msg(msg, tx), + OscPacket::Bundle(b) => b.content.into_iter().for_each(|p| handle_packet(p, tx)), + } +} + +fn handle_msg(msg: OscMessage, tx: &Sender) { + let parts: Vec<&str> = msg.addr.trim_start_matches('/').split('/').collect(); + if parts.len() < 3 || parts[0] != "deck" { return; } + let n: usize = parts[1].parse().unwrap_or(99); + let deck = match DeckId::from_index(n) { Some(d) => d, None => return }; + + let cmd = match (parts[2], parts.get(3).copied()) { + ("play", None) => Some(OscAppCmd::Audio(AudioCmd::Play(deck))), + ("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 }), + ("cue", Some("set")) => Some(OscAppCmd::Audio(AudioCmd::SetCue(deck))), + ("cue", Some("goto")) => Some(OscAppCmd::Audio(AudioCmd::GotoCue(deck))), + _ => None, + }; + + if let Some(c) = cmd { let _ = tx.send(c); } +} + +fn f32_arg(args: &[OscType], i: usize) -> Option { + match args.get(i)? { + OscType::Float(f) => Some(*f), + OscType::Double(d) => Some(*d as f32), + OscType::Int(i) => Some(*i as f32), + _ => None, + } +} + +pub fn send_osc(addr: &str, args: Vec) { + let Ok(sock) = UdpSocket::bind("0.0.0.0:0") else { return }; + let msg = OscPacket::Message(OscMessage { addr: addr.to_string(), args }); + if let Ok(buf) = encoder::encode(&msg) { + let _ = sock.send_to(&buf, format!("127.0.0.1:{}", SEND_PORT)); + } +} diff --git a/src/tui.rs b/src/tui.rs new file mode 100644 index 0000000..95f3666 --- /dev/null +++ b/src/tui.rs @@ -0,0 +1,238 @@ +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Gauge, Paragraph}, + Frame, +}; + +use crate::{app::App, deck::{Deck, DeckId}}; + +pub fn draw(f: &mut Frame, app: &mut App) { + let area = f.area(); + draw_base(f, app, area); + if let Some(sel) = &mut app.file_selector { + sel.render(f, area); + } +} + +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), + ]) + .split(area); + + // Header + let header = Paragraph::new(Line::from(vec![ + 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), + ), + Span::styled( + "PipeWire graph: each deck → rubberband filter-chain → sink", + Style::default().fg(Color::DarkGray), + ), + ])); + f.render_widget(header, chunks[0]); + + // 2×2 deck grid + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(chunks[1]); + + for row in 0..2usize { + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(rows[row]); + + 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); + } + } + } + + draw_help(f, chunks[2]); +} + +fn deck_color(id: DeckId) -> Color { + match id { + DeckId::A => Color::Cyan, + DeckId::Z => Color::Magenta, + DeckId::S => Color::Green, + DeckId::X => Color::Yellow, + } +} + +fn draw_deck(f: &mut Frame, deck: &Deck, area: Rect, focused: bool) { + let color = deck_color(deck.id); + + let border_style = if focused { + Style::default().fg(color).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; + + // Truncate track name to fit + let max_name = area.width.saturating_sub(18) 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 title = format!( + " Deck {} {} {} {} ", + deck.id.label(), + deck.play_state.icon(), + track_display, + graph_status, + ); + + let outer = Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(border_style) + .style(Style::default().bg(Color::Black)); + f.render_widget(outer, area); + + let inner = area.inner(ratatui::layout::Margin { horizontal: 1, vertical: 1 }); + + 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 + ]) + .split(inner); + + // 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(" / ", Style::default().fg(Color::DarkGray)), + Span::styled(format!("-{:02}:{:02}", rm, rs), Style::default().fg(Color::DarkGray)), + ])), + rows[0], + ); + + // Progress + f.render_widget( + Gauge::default() + .gauge_style(Style::default().fg(color).bg(Color::Black)) + .percent((deck.progress() * 100.0) as u16) + .label(""), + rows[1], + ); + + // Fader + 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 }) + .bg(Color::Black)) + .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 }), + ), + ])), + 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 }; + + let vc = |p: u16| if p > 90 { Color::Red } else if p > 70 { Color::Yellow } else { color }; + + let lp = (vu.0 * 100.0) as u16; + let rp = (vu.1 * 100.0) as u16; + + f.render_widget(Gauge::default().gauge_style(Style::default().fg(vc(lp)).bg(Color::Black)).percent(lp.min(100)).label("L"), la); + f.render_widget(Gauge::default().gauge_style(Style::default().fg(vc(rp)).bg(Color::Black)).percent(rp.min(100)).label("R"), ra); +} + +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)), + k("Space"), + Span::styled(" Stop: ", Style::default().fg(Color::DarkGray)), + k("Enter"), + ]), + 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"), + ]), + ]) + .block(Block::default() + .borders(Borders::TOP) + .border_style(Style::default().fg(Color::DarkGray))) + .style(Style::default().bg(Color::Black)); + + f.render_widget(help, area); +} + +fn k(s: &str) -> Span<'_> { + Span::styled( + format!("[{}] ", s), + Style::default().fg(Color::White).add_modifier(Modifier::BOLD), + ) +}