initial code scetch

This commit is contained in:
2026-03-05 15:41:53 +01:00
commit 8a7acd6d5b
9 changed files with 1554 additions and 0 deletions

166
README.md Normal file
View File

@@ -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 <node-id> 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/<n>/play` | | Play |
| `/deck/<n>/pause` | | Pause |
| `/deck/<n>/stop` | | Stop |
| `/deck/<n>/seek` | `f` 0.01.0 | Seek to position |
| `/deck/<n>/fader` | `f` 0.01.0 | Set volume (wpctl) |
| `/deck/<n>/pitch` | `f` semitones | Set pitch (rubberband) |
| `/deck/<n>/tempo` | `f` multiplier | Set tempo (rubberband) |
| `/deck/<n>/cue/set` | | Mark cue point |
| `/deck/<n>/cue/goto` | | Jump to cue point |
`<n>` = 0 (A), 1 (Z), 2 (S), 3 (X)
### Outgoing (port 9001)
| Address | Args |
|---------|------|
| `/deck/<n>/loaded` | `s` track name |
| `/deck/<n>/position` | `f` 0.01.0 |
| `/deck/<n>/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 <filter-node-id> Props '{ params = ["pitch:Pitch scale" "1.0594"] }'
# Volume on stream node
wpctl set-volume <stream-node-id> 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()`.

422
src/app.rs Normal file
View File

@@ -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 <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};
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<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>,
}
impl App {
pub fn new() -> 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)?;
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<CrosstermBackend<io::Stdout>>,
) -> 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<bool> {
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<bool> {
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<bool> {
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<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
}

75
src/cache.rs Normal file
View File

@@ -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<String>,
}
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<Vec<PathBuf>> {
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::<CacheFile>(&data) {
if cache.root == root_str {
return Ok(cache.files.into_iter().map(PathBuf::from).collect());
}
}
}
// Walk
let mut files: Vec<PathBuf> = 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()
}

135
src/deck.rs Normal file
View File

@@ -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<Self> {
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<PathBuf>,
pub track_name: Option<String>,
// Transport
pub play_state: PlayState,
pub position: u64, // samples
pub duration: u64, // samples
pub sample_rate: u32,
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,
// 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<u32>,
/// Object ID of the filter-chain node for this deck
pub filter_node_id: Option<u32>,
}
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)
}
}

267
src/effect_chain.rs Normal file
View File

@@ -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 <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::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", &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
}

115
src/file_selector.rs Normal file
View File

@@ -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<PathBuf>,
pub filtered: Vec<PathBuf>,
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<PathBuf> {
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<ListItem> = 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]
}

28
src/main.rs Normal file
View File

@@ -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
}

108
src/osc.rs Normal file
View File

@@ -0,0 +1,108 @@
/// OSC server and sender
///
/// Listen port : 9000
/// Send port : 9001
///
/// Incoming address map (/deck/<n>/...):
/// play, pause, stop
/// seek f (0.01.0)
/// fader f (0.01.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/<n>/position f
/// /deck/<n>/vu f f
/// /deck/<n>/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<OscAppCmd>) -> 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<OscAppCmd>) {
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<OscAppCmd>) {
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<f32> {
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<OscType>) {
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));
}
}

238
src/tui.rs Normal file
View File

@@ -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),
)
}