Skip to content
ibzPublic

About

Keyboard-driven DJ tools for the terminal

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

50 Commits

Folders and files

Repository files navigation

odj

Keyboard-driven DJ tools for the Linux terminal:

  • odj-player, a DJ player inspired by Pioneer CDJ decks.
  • odj-sampler, which plays the cues stored by odj-player from 16 pads.
cargo run --release -p odj-player                 # first run: pick & remember a music folder
cargo run --release -p odj-player -- ~/Music      # or browse this folder just this once
cargo run --release -p odj-player -- track.mp3    # load a track straight away
cargo run --release -p odj-player -- --deck 2     # forces deck 2 (defaults to whichever's free, usually 1)
cargo run --release -p odj-sampler                # 16 pads of stored cues

The first run (or whenever no music folder is set yet) asks you to pick one and remembers it; with more than one saved, it asks which to open instead of guessing. Passing a folder on the command line just browses there for that session, without changing what's remembered. Manage saved folders any time from Settings (`).

Needs librubberband (for Master Tempo), ALSA and PipeWire; building also needs libclang. Plays MP3, FLAC, WAV, OGG/Vorbis, AAC/M4A and AIFF.

If there is more than one stereo output (say, a USB audio interface next to the built-in card), odj-player and odj-sampler ask at start which one to play on (unless a mixer is running — see below): a stereo output, or one side of it in mono. HDMI outputs are not listed. Audio goes through PipeWire, so you can run one odj-player per output, e.g. two decks on the two line outs of an interface. Without PipeWire, odj-player opens the card directly and holds it while it runs.

Each running odj-player claims a deck number — shown as "DECK n" in the header — defaulting to the first one free (1, unless another instance is already running); pass --deck 1/--deck 2 to force a specific one, e.g. to start deck 2 before deck 1.

odj-player runs either standalone, playing straight to a real output with no Volume or EQ control of its own, or routed through odj-mixer: if one is already running when a deck starts, that deck routes its output through it instead of a real device, and odj-mixer discovers it live, as "Deck n", to apply Volume/EQ to (by hand or by MIDI knob) the same way it does for any sound-card input — see below. Which one happens is checked once, at startup, not re-checked if odj-mixer starts or quits mid-session.

odj-sampler works the same way: standalone with a real output, or, if odj-mixer is already running when it starts, routed through it instead (no output picker in that case), discovered live as "Sampler" and given the same Volume/EQ treatment.

Use a terminal with the kitty keyboard protocol (Ghostty, kitty, foot, Alacritty, WezTerm) and run it outside tmux. Hold-to-preview cue, jog nudges and search need key-release events; elsewhere those holds fall back to timers.

Playing a track, with a loop and a hot cue set

Browsing a folder of tracks

Beat grid and quantize

odj-player finds where each track's beats and bars fall, not just its tempo, and shows them on a ruler under the waveform, with bar numbers (in amber where a 16-bar phrase starts), and a bar.beat counter. When the grid is off, fix it: D makes the playhead beat 1 of a bar, A taps the tempo (while playing the beats also move onto your taps), and Y enters grid adjust, where , / . shift the beats by 1 ms and ↑ / ↓ change the BPM by 0.01. Shift+A goes back to the detected grid.

With quantize on (Q), cues, hot cues, loop in and out and auto loops go on the nearest beat, the paused beat jog steps along the grid, and a hot cue or reloop pressed while playing waits for the next beat, so the music stays in phase. Quantize is set per track. It starts on for tracks whose beats keep to the grid, such as most electronic music, and off for loosely played ones.

On the grid you can also beatjump (- / =, Shift to change the size from 1 to 64 beats; while looping, the loop moves along), make beat loops of 1/32 to 64 beats (L, with [ / ] setting the size), and hold ; for a loop roll of that size; tapping [ / ] while holding it halves or doubles the roll for a stutter build-up. A roll stops when you let go, and the track goes on from where it would have been had you never rolled. Slip mode (Z) does the same for loops, reverse, held hot cues and pause: the track keeps going underneath, shown as a purple line on the waveform, and comes back in time.

Key

odj-player finds each track's musical key and shows it after the BPM, as its Camelot code and name (KEY 8A Am). Without Master Tempo the pitch moves with the tempo, so the key shown is the one you hear, with how many semitones it has moved (+1). With other decks running, the sync row shows their keys too, in green where they mix well with this one: the same number on the Camelot wheel, or the same letter a step either way.

Sync

Run one odj-player per deck and they find each other: each takes a deck number (DECK 1 to 4, in the header) and shows the others' tempo. F syncs a deck to the master: it takes the master's tempo, or half or double it, whichever is nearer, and keeps its beats on the master's, correcting drift by up to 0.3% of speed and jumping back in if it's far out. Play on a synced deck waits for the master's next beat, and quantize stays on while synced. Decks whose beats don't keep to the grid sync their tempo only (SYNC BPM).

The first synced deck to play becomes the master; when it stops, a synced deck that's playing takes over, so loading your next track never moves the tempo of what's playing. Shift+F makes a deck the master. The phase meter shows how far a deck's beats are from the master's, or another playing deck's, in ms, so it helps when beatmatching by ear too. Under the ruler, each other deck gets a row showing its beats against this one's (the master's in yellow): in phase, the rows line up. Sync allows for each sound card's latency and Master Tempo's, so decks on different cards meet at the mixer.

Press ? in the app for all keys, or see Keys. Memory points, hot cues, loops, beat grid corrections and quantize are remembered per track, one JSON file each under ~/.local/share/odj/tracks/. Files are named by a fingerprint of the audio, so cues survive renaming, moving and retagging. Settings such as Auto Cue live in ~/.config/odj/settings.json.

odj-sampler

Sixteen pads loaded with a kit, one playing

Sixteen pads on the keys 1234 / QWER / ASDF / ZXCV. Press Enter to browse samples for the selected pad: the active kit's own samples first, Up to browse any other kit's (the embedded "Acoustic (Embedded)" kit is always there too, even before you've ever picked it — see Kits below), or jump straight to a shortcut that browses your stored odj-player cues (hot cues, memory points, last loops) instead. A loop from a cue goes on as it is; a cue without an end opens the sample editor, which starts at 4 bars and lets you move the start and the end along the beat grid by beats or bars, or by milliseconds, and preview it; T brings a pad back into it. Each pad can be one-shot, gate (plays while held) or toggle, loop or not, and has its own gain.

A pad holds its own copy of the audio, cut out and converted to the output rate, so the track file isn't read again while you play. The first time a cue is put on a pad, it's cut from the original track and saved as its own .wav inside the kit — from then on that kit owns the sample outright, even if you later clear the pad; picking it again (or giving it to another kit via the sample browser's Up) just reuses that file. The header and the memory panel show how much memory the pads take.

Switching kits, or creating a new one

Kits. Everything above belongs to one kit at a time — pad assignments, properties, and the samples themselves — each its own self-contained folder under ~/.local/share/odj/kits/<name>/ (a kit.json plus its samples/). Press K any time to switch kits or create a new, empty one; odj-sampler remembers the last one used (in the settings file shared with odj-player, ~/.config/odj/settings.json) and loads it directly next time. On first run, with no kit yet, the same browser offers "Acoustic (Embedded)", a small kit built into the binary (works offline, no setup) — picking it doesn't create a kit folder either, until it's actually changed, same as above — fuller kits downloaded from the companion odj-kits repo (currently Acoustic and Latin percussion, both CC0), or creating a new, empty kit of your own. Deleting the kit you're on falls back to another one if you have one, or to a fresh, unnamed kit otherwise — nothing's written to disk for that until you actually change something, at which point you're asked to name it, which creates its folder.

Pads can also be triggered over MIDI — Note On/Off from an external sequencer (hardware or software) works exactly like a key press, including one-shot/gate/toggle behaviour. At startup, if any MIDI input is available, you're asked which one to use (or none). The first time you pick a given device, you're walked through pressing each of the 16 pads in order so its notes are learned rather than assumed (controllers don't agree on how they number pads); after that it's remembered. The chosen port and each device's learned notes are kept in the same shared settings file.

odj-downloader

Searching, with a few downloads queued

Searches YouTube via yt-dlp and downloads whatever you queue, one at a time, into ~/Downloads — handy for getting a track straight into a folder odj-player already watches. Tab moves focus between the search box, results and queue; Enter runs a search (or, on a result, queues it); ↑/↓ navigate the focused list; x/Delete removes a queued item; Esc clears the query or cancels a search in progress; Ctrl+C quits.

odj-mixer

Continuously detects running odj-player decks and a running odj-sampler, and connects every one of them to the main output automatically, live — each is already part of the session, so it just plays, no extra step needed (none of them send their audio anywhere special for this: each plays into its own virtual sink instead of a real device, and odj-mixer captures it from there). A sound-card input (a turntable's line-in, a mixer channel, a mic) is different: there's usually several, not all of them meant to be mixed in, so each one instead waits until it's added from Settings (`, see below) — once added, every Volume/EQ control works identically whether a strip is a turntable, a deck or the sampler. Needs PipeWire running (it refuses to start without it): both the virtual sinks and routing between two different real cards rely on PipeWire's shared graph clock, which plain ALSA has no way to provide.

The live screen looks like a real mixer: one channel strip per running deck (or the sampler) plus every input that's been added, side by side, each with High, Mid and Low and a vertical Volume fader. A hardware input's title is cut down to whatever's actually different about it compared to every other one currently shown — just "2" among several inputs on the same interface, "UMC404HD 192k" if what's different is which interface, and so on — so two inputs never read the same just because a long shared name pushed the part that differs off the edge. ←/→ picks the channel and ↑/↓ picks which control is active (High, Mid, Low, Volume, wrapping top to bottom and back) — that's all either does, never changing a value, so they're always safe to tap. +/- adjusts the selected control instead (Shift: ×4 steps, a no-op until the channel's actually connected). Nothing on the live screen changes a MIDI mapping any more — that only happens from Settings. As on odj-player's own live screen, the footer's contextual keys sit on the left and the globals (` settings, ? help, Ctrl+C quit) stay pinned to the right.

Picking a MIDI controller at startup needs no picker either, the same way the main output doesn't: every controller odj-mixer's ever learned knob mappings for is remembered (Settings.mixer_midi_controllers, by its own stable name — not its connection-order port id, which can change across a replug), so if exactly one known one is plugged in, it's used automatically; if more than one is, the last-used one is (Settings.mixer_midi_last), or whichever sorts first by name if that one isn't among them. Only when every currently plugged-in controller is unknown does it fall back to asking (Esc/N skips it) — picking one there makes it known from then on, with an empty map to start. Either way, it then briefly looks for decks (and the sampler) already running, then walks you through turning a knob for each one's Volume, Low, Mid and High that isn't mapped yet on that controller (so 8 knobs total for two decks, 12 with the sampler too) — Esc at any point stops the walkthrough, leaving the rest for later. Inputs are left out of this walkthrough (most already have a physical knob on their own hardware); map one on demand from Settings instead. Any knob mapped this way, for an input, a deck or the sampler, is remembered under that controller's own entry in the settings file shared with the other apps — switching controllers (see MIDI below) never mixes up what's mapped on one with another.

` ("settings", on the right of the live screen's hint line) opens Settings, 3 tabs — Tab/Shift+Tab cycles through them either way, the current one highlighted the same way the help overlay's own tabs are (black on amber). Its own border bottom matches the help overlay's too, exactly: Esc ("Back" while drilled into a row's own settings, "Close" otherwise) on the left in amber, Tab ("Next tab") on the right in the help overlay's own distinct light blue, both in the same highlighted-badge style — with whatever's specific to the current tab in plain text between them — Tab is left off entirely while drilled in, since it doesn't do anything there.

Inputs (the tab it opens to) is one list, one row per detected input, deck and the sampler — plus one more for any device that's no longer detected but still has a MIDI mapping, so that mapping stays visible (and clearable) instead of quietly surviving, unseen, in the settings file — with a right-aligned indicator for whether it's currently mixed in: ●/○ for an input (Space toggles it — adding one connects it to the main output right away, removing one disconnects it; this is also remembered in the shared settings file, so an added input is still there, reconnected automatically, next time odj-mixer runs — same as a deck or the sampler already is), ◆/◇ for a deck or the sampler — a different shape, not just a different state, since either is always connected automatically and Space can't change that — or ✕ for one of those mapped-but-undetected rows, just as non-interactive as a deck's for the opposite reason: there's nothing there to connect. Each row is 2 lines tall, the 2nd showing its own MIDI mapping summary — MIDI H:n M:n L:n V:n, "—" for whichever of the 4 isn't mapped — so a mapping can be seen without drilling in to check one at a time. ↑/↓ picks a row; Enter, on any of the 3 kinds, drills into its own settings — for now just its Volume, Low, Mid and High, each showing whatever CC it's bound to, or "—" if none. ↑/↓ picks one there, Enter maps (or remaps) a knob to it, X/Delete clears it, Esc goes back to the channel list. Esc/` from there closes Settings back to the live screen.

Outputs is one list, one row per detected output, ●/○ marking which one is currently the main output — ↑/↓ picks a row, Enter makes it the main output instead (a no-op on one that isn't currently available, shown in red). This is what used to be a mandatory pick before the live screen could even start; now it's remembered the same way an added input is (Settings.mixer_output), restored automatically next run — or, the very first time, defaulted to the internal speakers (whichever output looks like fixed, onboard audio rather than something plugged in) rather than nothing at all — and can be changed again any time from here — switching tears down every channel's route and lets the same auto-connect that handles a newly detected one put them all back on the new output. Esc/` closes Settings back to the live screen here too.

MIDI is one list, one row per controller odj-mixer has at least one saved knob mapping for, plus one more for each currently plugged in that has none yet — so a controller with mappings still shows up even unplugged, and a brand new one can be picked the moment it's seen, not only once it's been used before. Each row names how many mappings it has, right-aligned ahead of the marker (0 for one that's merely plugged in), right there instead of drilling in to check. The marker is the same 3-icon convention Inputs uses for its own not-currently-detected rows: ✕ for a controller that's unplugged (known only from its saved mappings — not something Enter can do anything with), ● for whichever one is actually connected, ○ for any other one that's plugged in but not the active one. ↑/↓ picks a row, Enter switches to it live if it's plugged in (tearing down whatever was connected before and loading that controller's own saved mappings, or an empty one if it has none), X/Delete forgets its mappings instead — a no-op if it has none to forget, same as Enter is on one that isn't plugged in. Esc/` closes Settings back to the live screen here too.

?// opens the full key list from the live screen (Settings has no help of its own — every key's already spelled out in its own hint line), taking over until Esc/?// closes it again. Ctrl+C quits (at the top level) — q/Esc don't; Esc is reserved for backing out of whatever's open instead.

Every error odj-mixer shows on screen (as a red "Status" popup) is also appended, with when it happened, to ~/.local/share/odj/mixer.log — the popup itself clears on the next keypress, so this is the place to look for one that's already gone.

Keys

Press ? in either app for these keys, on the same pages as here: Tab and Shift+Tab (or → and ←) turn the pages, ↑ and ↓ scroll, and Esc closes the help. The last page, About, shows the version, the author and the license. Ctrl+C quits either app; the player saves its cue memory first, and the sampler saves its kit as you go. The file browser and the cue picker show their keys along their bottom edge, as does the output picker at start.

odj-player

Basics

Key Does
Space Play / Pause
C Cue
1 2 3 Hot cue A/B/C
L Beat loop / exit the loop
← / → Search (Shift: super fast)
↑ / ↓ Tempo (Shift: ×10)
, / . Jog: hold to slow down / speed up 3% (Shift: 12%)
R Reverse
F Sync to the master deck
B / N Previous / next track in folder
Tab File browser
Ctrl+U in the browser Add this folder to your music folders
` Settings: auto-cue, music folders

Cues

Key Does
C Cue — playing: back to cue & pause · paused: set cue · hold on cue: preview
Space while holding C Keep playing after releasing Cue
1 2 3 Hot cue A/B/C: jump & play (on the next beat when quantizing) · REC mode: store the cue, or the loop while looping
1 2 3 held in slip Play the hot cue while held, then back to where the track would be
E Hot cue REC / PLAY mode
Shift+1 2 3 Clear hot cue (REC mode only)
W / J / K / X Memory: store cue or loop / jump to the nearest one left / right / delete
X X on a loop memory A loop needs a second X within 2s to confirm the delete
T Elapsed / remaining time
Shift+T Auto Cue on/off

Loops

Key Does
I / O / P Loop in / Loop out / Reloop-Exit (back into whatever loop you're at or just left)
O while looping Loop out adjust: , / . move the out point (Shift: ×10), O to finish
R while looping Play the loop backwards, R again for forwards
L Beat loop of the loop size / exit the loop
Shift+P Jump to the draft loop (last one set by I/O/L), even after visiting another memory
Shift+L Promote the draft loop to a real memory
[ / ] Halve / double the roll or loop, else the next beat loop's size
Shift+[ / ] Same, but from the in point: extends or shrinks the loop to the left
; (hold) Loop roll of the loop size, [ / ] change it; back to where the track would be
- / = Beatjump back / forward (moves the loop while looping)
Shift+- / = Beatjump size 1 … 64 beats
Z Slip: loops, reverse, held hot cues and pause carry on underneath

Tempo

Key Does
↑ / ↓ Tempo fader (Shift: ×10)
0 / G Reset tempo / cycle range ±6 ±10 ±16 WIDE
H Master Tempo (key lock)
F Sync to the master deck's tempo and beats
Shift+F Make this deck the master
, / . Jog: hold to slow down / speed up 3% (Shift: 12%) · paused: 1 frame (Shift: 1 beat, 1 s without a BPM)
S / V Cycle start / brake time

Beat grid

Key Does
Q Quantize this track: cues, loops and jumps go on the beat (always on while synced)
A Tap BPM (playing: the beats go on the taps)
Shift+A Back to the detected grid
D Make the playhead beat 1 of a bar
Y Grid adjust: , / . shift the beats 1 ms (Shift: 10 ms) · ↑/↓ BPM ±0.01 (Shift: ±0.1) · Y or Esc to finish

odj-sampler

Basics

Key Does
1 2 3 4 / Q W E R Pads 1–8 (press to play, also selects)
A S D F / Z X C V Pads 9–16
Shift+pad Stop that pad
Space Stop all pads
← → ↑ ↓ Select a pad
Enter Browse samples to assign to the selected pad
K Browse kits: switch, or create a new one

Pads

Key Does
Enter Browse samples to assign to the selected pad
K Browse kits: switch, or create a new one
T Trim: open the selected pad in the sample editor
M Mode: one-shot · gate (plays while held) · toggle
L Loop on/off
G Retrigger: a one-shot press while playing restarts or is ignored
- / = / 0 Gain down / up / 0 dB
Delete / Backspace Clear the selected pad

Sample browser

Key Does
↑ ↓ Select a row
Enter Assign the sample, open the kit, or browse odj-player's cues
Esc Close without assigning

Kit browser

Key Does
↑ ↓ Select a row
Enter Switch to that kit, or start naming a new one
Delete / Backspace Delete the selected kit (asks to confirm first, in red)
(typing) Name a new kit; Enter creates it, Esc cancels
Esc Close without switching

Name this kit

Key Does
(typing) A kit changed for the first time has no name yet
Enter Save it under this name, creating its folder
Esc Leave it unsaved for now; asked again next change

Sample editor

Key Does
Tab Switch between moving the start and the end
← / → Move it a beat along the grid (Shift: a bar); without a grid 0.1 s (Shift: 1 s)
, / . Move it ±10 ms (Shift: ±1 ms)
[ / ] Halve / double the length
Space Preview (loops)
Enter / Esc Put it on the pad / cancel

Layout

A Cargo workspace: crates/odj-core holds what the apps share (decoding and fingerprinting, the cue library, audio input/output, the file browser, the link between decks); crates/odj-player, crates/odj-sampler, crates/odj-downloader and crates/odj-mixer are the four binaries.

Install on Omarchy / Arch

git clone https://github.com/ibz/odj && cd odj/packaging/arch
makepkg -si

This builds the latest master, pulls in rubberband, alsa-lib, libpipewire and yt-dlp, and installs all four binaries (odj-player, odj-sampler, odj-downloader, odj-mixer), an odj alias for odj-player, and a launcher entry (Super+Space → "odj player"), which opens in your default terminal. Run makepkg -si again to update; sudo pacman -R odj-git removes it. Once it's on the AUR: yay -S odj-git.

Install on Debian / Ubuntu

git clone https://github.com/ibz/odj && cd odj
packaging/debian/build.sh              # or: packaging/debian/build.sh ubuntu:24.04
sudo apt install ./packaging/debian/dist/debian-trixie/odj_*.deb

This builds the package in a Docker container of the release you name (Debian trixie by default), so it matches that release's libraries. It installs all four binaries, an odj alias for odj-player and the launcher entry, and pulls in rubberband, ALSA, PipeWire and yt-dlp. sudo apt remove odj removes it.

License

MIT, see LICENSE.

About

Keyboard-driven DJ tools for the terminal

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages