2026-03-05 15:41:53 +01:00
|
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
|
|
```
|
2026-03-05 23:02:13 +01:00
|
|
|
|
pipewire-rs (pipewire = "0.9.2")
|
2026-03-05 15:41:53 +01:00
|
|
|
|
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()`.
|