initial code scetch
This commit is contained in:
166
README.md
Normal file
166
README.md
Normal 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.0–1.0 | Seek to position |
|
||||||
|
| `/deck/<n>/fader` | `f` 0.0–1.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.0–1.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
422
src/app.rs
Normal 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
75
src/cache.rs
Normal 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
135
src/deck.rs
Normal 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
267
src/effect_chain.rs
Normal 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", ¶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 <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
115
src/file_selector.rs
Normal 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
28
src/main.rs
Normal 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
108
src/osc.rs
Normal 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.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/<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
238
src/tui.rs
Normal 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),
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user