Files
djdeck/README.md
T

196 lines
7.1 KiB
Markdown
Raw Normal View History

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 │
│ [pitch → lpL/lpR → reverb] │
2026-03-05 15:41:53 +01:00
│ 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** — rubberband `Semitones` port (requires `ladspa-rubberband`)
- **Cutoff filter** — `lowpass_iir` + `highpass_iir` (swh-plugins), `Cutoff Frequency` port:
- Negative value → lowpass (kill highs), positive → highpass (kill lows), 0 → flat
- **Reverb** — `plate` `Dry/wet mix` port (swh-plugins): 0=dry → 1=wet
2026-03-05 15:41:53 +01:00
- **Volume (fader)** — `wpctl set-volume` on the stream node
- **Tempo** — varispeed in audio engine; rubberband compensates pitch automatically
2026-03-05 15:41:53 +01:00
## Dependencies
### System
**Required:**
2026-03-05 15:41:53 +01:00
```bash
# Arch
pacman -S pipewire wireplumber pipewire-audio ladspa rubberband
# Ubuntu / Debian
apt install pipewire wireplumber ladspa-sdk rubberband-ladspa
2026-03-05 15:41:53 +01:00
# Fedora
dnf install pipewire wireplumber ladspa rubberband-ladspa
```
**Required** (cutoff + reverb):
```bash
# Arch
pacman -S swh-plugins
# Ubuntu / Debian
apt install swh-plugins
# Fedora
dnf install swh-plugins
```
2026-03-05 15:41:53 +01:00
Verify the rubberband LADSPA plugin is present:
```bash
python3 -c "
import ctypes; lib = ctypes.CDLL('/usr/lib/ladspa/ladspa-rubberband.so')
lib.ladspa_descriptor.restype = ctypes.c_void_p
class D(ctypes.Structure):
_fields_ = [('uid',ctypes.c_ulong),('label',ctypes.c_char_p),('props',ctypes.c_int),('name',ctypes.c_char_p)]
i=0
while True:
d=lib.ladspa_descriptor(i)
if not d: break
print(ctypes.cast(d,ctypes.POINTER(D)).contents.label.decode()); i+=1
"
# should include: rubberband-pitchshifter-stereo
2026-03-05 15:41:53 +01:00
```
### 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. Start 4 PipeWire playback streams, each targeting its filter-chain sink
2. Load `libpipewire-module-filter-chain` for each deck via `pw-cli load-module` (pitch → lpL/lpR → reverb)
2026-03-05 15:41:53 +01:00
3. Wait 800ms for PipeWire to settle, then resolve node IDs via `pw-dump`
4. Explicitly link each chain's output to the default sink via `pw-link -L`
5. Start the TUI and OSC server
2026-03-05 15:41:53 +01:00
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 (rubberband `Semitones` port) |
| `, / .` | Tempo ±1% (varispeed; rubberband compensates pitch) |
| `- / =` | Cutoff ±500 (negative=LP kills highs, positive=HP kills lows, 0=flat) |
| `o / p` | Reverb wet ±5% (requires swh-plugins) |
2026-03-05 15:41:53 +01:00
| `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()`.