# 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] │ │ 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 - **Volume (fader)** — `wpctl set-volume` on the stream node - **Tempo** — varispeed in audio engine; rubberband compensates pitch automatically ## Dependencies ### System **Required:** ```bash # Arch pacman -S pipewire wireplumber pipewire-audio ladspa rubberband # Ubuntu / Debian apt install pipewire wireplumber ladspa-sdk rubberband-ladspa # 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 ``` 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 ``` ### Rust ``` pipewire-rs (pipewire = "0.9.2") 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) 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 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) | | `q` | Quit (unloads filter-chain modules) | **File selector:** type to filter, `↑/↓` navigate, `Enter` load, `Esc` cancel. Files indexed from CWD, cached at `$XDG_CACHE_HOME/djdeck/file_index.json`. ## OSC Reference ### Incoming (port 9000) | Address | Args | Description | |---------|------|-------------| | `/deck//play` | | Play | | `/deck//pause` | | Pause | | `/deck//stop` | | Stop | | `/deck//seek` | `f` 0.0–1.0 | Seek to position | | `/deck//fader` | `f` 0.0–1.0 | Set volume (wpctl) | | `/deck//pitch` | `f` semitones | Set pitch (rubberband) | | `/deck//tempo` | `f` multiplier | Set tempo (rubberband) | | `/deck//cue/set` | | Mark cue point | | `/deck//cue/goto` | | Jump to cue point | `` = 0 (A), 1 (Z), 2 (S), 3 (X) ### Outgoing (port 9001) | Address | Args | |---------|------| | `/deck//loaded` | `s` track name | | `/deck//position` | `f` 0.0–1.0 | | `/deck//vu` | `f f` L R | ## Inspecting the graph ```bash # See all djdeck nodes pw-dump | jq '.[] | select(.info.props["node.name"] | startswith("djdeck"))' # Live graph view qpwgraph # or helvum # Check filter-chain params for deck A pw-cli set-param Props '{ params = ["pitch:Pitch scale" "1.0594"] }' # Volume on stream node wpctl set-volume 0.85 ``` ## Adding more effects The effect chain abstraction is in `src/effect_chain.rs`. The filter-chain module `filter.graph` in `EffectChain::load()` accepts multiple nodes wired in series: ``` filter.graph = { nodes = [ { type = ladspa name = pitch plugin = ladspa-rubberband label = rubberband_pitchshifter_stereo ... } { type = ladspa name = eq plugin = lsp-plugins-ladspa label = para_equalizer_x8_stereo ... } ] links = [ { output = "pitch:Output L" input = "eq:Input L" } { output = "pitch:Output R" input = "eq:Input R" } ] } ``` No Rust changes needed — just extend the `args` string in `EffectChain::load()`.