See CHANGELOG.md for version history.
Point it at your collection. Get a set worth playing.
AI-powered DJ crate assistant. Point it at your Rekordbox collection, pick a genre, and get a set of ready-to-use mix concepts with Camelot-ordered track listings — delivered to Discord.
For background on why this was built and how it works in practice, read the MixLab case study.
This project explores AI-assisted development workflows. My focus here was system design and delivery rather than idiomatic Python, which is not my primary stack.
git clone https://github.com/christophechang/MixLab.git && cd MixLab
./setup.sh
cp .env.example .env # fill in ANTHROPIC_API_KEY + at least one Stage 1 key
./mixlab # crate availability table — no LLM cost
./mixlab --genre house # full mix report, delivered to Discord- Parses your exported Rekordbox XML collection
- If
CATALOG_API_URLis set, fetches your play history and applies--modefiltering (unplayedby default, orplayedto restrict to battle-tested tracks); without the catalog API the full collection is used - Prints a crate availability table (no LLM cost)
- If
--genreis specified, scopes the collection to that genre (or custom cross-genre pool), runs Stage 1 shortlisting, wraps each shortlist into a Mix Canvas (BPM tiers, role candidates, contrast assets, anchors, era/label coherence, risk notes), then writes a full Stage 2 mix planning report — optionally steered with--intent "..." - If
--playlistis specified, uses that Rekordbox playlist as the seed, infers the set's intent, builds natural BPM-zone shortlists around the seed tracks, generates three completion variants, then writes the best playlist-completion report - Optionally exports a Rekordbox-compatible merged XML file
- Sends the report and any XML attachment to a Discord channel
- Python 3.12+
- A Rekordbox XML export (see Setup)
ANTHROPIC_API_KEY— required for default Stage 2 report generation- At least one Stage 1 LLM key (Groq, Gemini, or Mistral)
- A catalog API URL + key (optional — for filtering already-played tracks)
- A Discord bot token (optional — report prints to stdout without it)
git clone https://github.com/christophechang/MixLab.git
cd MixLab
./setup.shsetup.sh creates the virtual environment, installs all dependencies, and copies .env.example to .env if it doesn't already exist.
Copy .env.example to .env and fill in your keys:
cp .env.example .env| Variable | Required | Description |
|---|---|---|
ANTHROPIC_API_KEY |
Yes | Default Stage 2 report generation (Claude Sonnet) |
CATALOG_API_URL |
No | Base URL of your catalog/play-history API |
CHANGSTA_API_KEY |
No | Bearer token for CATALOG_API_URL (if your API requires auth) |
GROQ_API_KEY |
No | Stage 1 provider (tried first) |
GEMINI_API_KEY |
No | Stage 1 provider (fallback 1) |
MISTRAL_API_KEY |
No | Stage 1 provider (fallback 2) |
DISCORD_BOT_TOKEN |
No | Discord delivery |
DISCORD_GUILD_ID |
No | Discord server ID |
MIXLAB_DISCORD_CHANNEL_ID |
No | Target channel ID (preferred over name) |
MIXLAB_DISCORD_CHANNEL |
No | Target channel name (default: mix-lab) |
ANTHROPIC_API_KEY is required for Stage 2 report generation. Without a catalog API URL, played-track exclusion is skipped and the full collection is used. Without a Discord token the report is printed to stdout only.
The catalog API is used to fetch your play history and exclude already-played tracks from recommendations. Without it, every track in your collection is treated as unplayed — you still get full mix concepts, but the tool cannot distinguish tracks you've played before from ones you haven't. The unplayed count in the crate table will equal the total collection count.
The catalog API MixLab integrates with is also open source: soundcloud-ai-mix-recommender-api. Set CATALOG_API_URL to your deployed instance to enable played-track exclusion.
MixLab works from your Rekordbox XML export. The core fields — BPM and Camelot key — are required and must be set for every track. The following enrichment layers are optional but significantly improve the quality of the AI-generated concepts.
Every track must have a Camelot key set in Rekordbox (Tonality field). The easiest way to populate these at scale is Mixed In Key — it analyses your files and writes Camelot keys directly into Rekordbox.
Mixed In Key writes two additional pieces of data into the Rekordbox Comments field that MixLab reads:
| Data | Format in Comments | What MixLab does with it |
|---|---|---|
| Energy score | Energy 7 (1–8 scale) |
Passed to the AI to inform the energy arc and peak placement |
| Genre/mood tags | /* Deep House / Soulful / Melodic */ |
Passed to the AI as track character descriptors |
To populate these, run Mixed In Key on your collection, enable Write to Rekordbox, and let it analyse. After analysis completes, re-export your Rekordbox XML.
Tracks without energy scores or tags still work — MixLab reasons from BPM, key, genre, and artist knowledge when supplementary data is absent.
MixLab reads Rekordbox track colours and maps them to energy tiers passed to the AI:
| Colour | Tier | Meaning |
|---|---|---|
| Red | High energy | Peak / floor-filling track |
| Orange | Mid energy | Builder or transition track |
| Green | Chill | Opener, palette cleanser, or warm-up |
Colour-code your tracks in Rekordbox manually to give the AI an additional signal beyond the Mixed In Key energy score. Mixed In Key can also set colours automatically based on energy level if you configure it to do so.
MixLab reads whatever is inside the /* ... */ block in the Comments field and passes it verbatim to the AI as track descriptors. There is no fixed schema — it is plain text and you can structure it however you like. The AI will reason from whatever you put there.
The following is the tagging structure used in this project, shared as a concrete example rather than a prescription.
Tagging layers
Tracks are described across four independent layers:
| Layer | Where | What it captures |
|---|---|---|
| Genre | Rekordbox Genre field | Primary genre (House, Drum & Bass, etc.) |
| Playlists | Rekordbox Playlists | Tracks used in a SoundCloud mix — "battle-tested" markers |
| Energy | MIK energy score in Comments | Intensity level on a 1–8 scale |
| Mood | /* */ block in Comments |
Vibe, character, and feel of the track |
Mood tag vocabulary
The mood tags in this collection are a vibe-based layer that sits on top of genre and energy — describing not what a track is, but what feeling it brings to a mix:
acid · aggressive · big · brooding · builder · carnival · dark · dirty · driving · druggy · dreamy · emotional · energetic · funky · grimy · happy · heavy · in the groove · Latino · melancholic · old skool · raga · rave · soulful · street · trippy · vocal
A typical Comments field with this setup looks like:
8A - Energy 7 /* Drum & Bass / dark / driving */
MIK writes the key and energy score; the /* */ block is added manually in Rekordbox. You can use as many or as few tags as feel useful — the AI treats them as hints, not hard rules.
The Label field in Rekordbox is passed to the AI as context. Useful if your collection is tagged by label and you want the AI to reason about label character (e.g. Defected, Nervous, Peacefrog).
- Analyse your collection with Mixed In Key and write Camelot keys, energy scores, and tags back to Rekordbox
- Optionally colour-code tracks in Rekordbox (or let Mixed In Key do it by energy level)
- Export your collection: File → Export Collection in xml format
- Move the file to
import/rekordbox.xml - Re-export after any library changes (new tracks, updated tags)
Rekordbox 6: XML export must be enabled before it appears in the menu. Go to Preferences → Advanced → rekordbox xml and tick Export rekordbox xml. Restart Rekordbox if needed.
In Rekordbox:
- Go to File → Export Collection in xml format
- Choose a save location and export
- Move or copy the exported file to
import/rekordbox.xmlin the project root
Note: Rekordbox exports your full COLLECTION — all tracks, not just a playlist. Tracks not tagged to any playlist will still appear in the XML (MixLab uses genre tags, not playlist membership). SoundCloud cloud tracks are excluded automatically. Re-export whenever you add new tracks.
./mixlabPrints unplayed vs total counts per genre, sorted by availability. Only the catalog API is called (if configured). Without a catalog API, shows total collection counts.
./mixlab --genre house
./mixlab --genre house --mode played
./mixlab --genre 4x4 --mode allRuns the full genre pipeline: parse → fetch played history (based on --mode) → scope to the requested genre → Stage 1 shortlist generation → Stage 2 report → Discord/stdout.
--mode controls which tracks are eligible for concepts. Each mode also tunes the canvas scoring weights (boost novelty for unplayed, anchor strength for played, cross-canvas distinctiveness for all) and adds mode-specific creative direction to the Stage 2 prompt:
| Mode | Behaviour |
|---|---|
unplayed (default) |
Only tracks never played live. Stage 2 framed as discovery — surface debuts worth introducing. Requires CATALOG_API_URL. |
played |
Only tracks that have appeared in your play history — battle-tested and SoundCloud-proven. Stage 2 framed as reassembly — bolder Camelot jumps and chapter pivots since familiarity is an asset. Requires CATALOG_API_URL. |
all |
Full collection, ignoring play history entirely. Stage 2 framed as interleave — concepts that combine played and unplayed material in deliberate ways; notes the lean (played-anchored / unplayed-anchored / balanced) in the thesis. |
The report starts with a context header so you can see exactly what kind of run produced it, for example:
Report context: House (unplayed tracks)
Report context: House (played tracks)
Report context: 140 (custom genre, All Tracks)
For standard and custom genre runs, a Rekordbox-compatible merged XML file can be attached to the Discord message or written to disk. It contains one playlist per concept plus an All Unplayed Tunes playlist with the full scoped unplayed pool when played-track history was used.
./mixlab --genre house --intent "warmup set for an outdoor afternoon, low pressure, melodic"
./mixlab --genre 4x4 --intent "peak time main-room, no warmup, hit hard fast"
./mixlab --genre techno --mode played --intent "tools-only set, no melody, sustained pressure"--intent accepts a free-text creative direction that is injected verbatim into the Stage 2 prompt for genre mode. There is no parsing or LLM extraction — the model reads it as guidance and fills in everything you did not specify. If the intent conflicts with the candidate pool, Stage 2 picks the closest viable interpretation and notes the gap.
--intent is ignored in playlist mode (--playlist), which already runs its own Stage 0 intent-extraction pass over the seed playlist.
./mixlab --playlist "Monday Night"
./mixlab --playlist "Monday Night" --genre electronica
./mixlab --playlist "Sets/Monday Night"
./mixlab --playlist "Monday Night" --mode all
./mixlab --playlist "Monday Night" --mode playedPlaylist mode is a different workflow from genre mode:
- The source Rekordbox playlist is treated as the seed and MixLab aims to complete or extend it, not replace it
- MixLab first runs an intent-analysis pass over the seed playlist to infer the overall vibe, energy shape, anchor tracks, and any missing set roles
- Seed tracks are clustered into natural BPM zones
- Each zone becomes a shortlist containing the seed tracks for that zone plus nearby library tracks
- Stage 2 generates exactly three completion variants (
practical,balanced, andadventurous) and MixLab auto-selects the strongest one - The final report explains which seed tracks were retained, which were dropped, which library tracks were added, and which alternative strategy was rejected
Important playlist-mode rules:
--genrein playlist mode constrains added library tracks to that genre scope; it does not filter the seed playlist itself--modecontrols which library tracks are candidates:unplayed(default) biases towards unplayed tracks,playedrestricts the pool to battle-tested tracks only,alluses the full collection with no weighting- Playlist names are matched case-insensitively
- If the same playlist name exists in multiple folders, pass the full path such as
Sets/Monday Night - Playlist mode requires at least 4 valid seed tracks with BPM and Camelot key after parsing
Playlist runs use the same report context header as genre runs, for example:
Report context: Monday Night playlist (Electronica, unplayed tracks)
Report context: Monday Night playlist (played tracks)
Report context: Monday Night playlist (Electronica, All Tracks)
Playlist runs also print a compact intent summary before the final report, for example:
Intent brief: Deep, rolling warm-up with a late lift | energy: single_arc | risk: medium | anchors: 3 | missing roles: peak
If you export playlist mode, the merged XML contains the single completed concept only; it does not add an All Unplayed Tunes playlist.
To also write the XML to disk:
./mixlab --genre house --export-playlists
# writes to output/playlists/rekordbox_export.xml
./mixlab --genre house --export /path/to/dir
# writes to /path/to/dir/rekordbox_export.xml./mixlab --genre house --min-bpm 122 --max-bpm 128
./mixlab --genre drum_and_bass --min-year 2020
./mixlab --genre 4x4 --max-year 2019
./mixlab --playlist "Monday Night" --min-bpm 130 --max-bpm 138BPM and year filters apply after ingestion and BPM correction. In playlist mode they apply only to library additions — seed tracks are never filtered out. Tracks with no release year set are excluded when either year flag is active. Active filters appear in the Discord crate snapshot label.
./mixlab --export-unplayedCompares your full Rekordbox collection against your play history and exports every track you haven't played yet as a Rekordbox-compatible merged XML file. Use this when you want a complete picture of what's in your crates that you haven't touched — import the file into Rekordbox to browse, filter, or prepare for a session.
- Writes to
output/playlists/rekordbox_export.xmlwith a dated folder name (Mix Lab - All Unplayed - YYYY-MM-DD) - Posts a summary and the XML attachment to Discord
- Requires
CATALOG_API_URL— without play history there is nothing to compare against - Respects the
DO NOT RECOMMENDexclusion list - No LLM calls — fast and cheap
./mixlab --genre house --debug
# or: MIXLAB_DEBUG_SCORE=1 ./mixlab --genre houseEmits per-canvas scoring diagnostics to stderr: every weighted component, weakness penalty, floor multiplier, overlap penalty against already-picked canvases, novelty breakdown (track-overlap component + shape-similarity component + closest history match), era/label coherence values, and risk notes. Normal stdout output and Discord delivery are unchanged.
./mixlab --genresPass the label (left column) to --genre. The right column shows the Rekordbox genre tags that map to it.
| Label | Rekordbox genre tags |
|---|---|
house |
House, Deep House, Tech House, Classic House, Afro House, Minimal / Deep Tech |
drum_and_bass |
Drum & Bass, DnB, Liquid DnB, Jungle/Drum'n'bass |
breakbeat |
Breakbeat, Breaks, Nu Skool Breaks, Hardcore |
electronica |
Electronica, Electronic, Downtempo, Trip Hop |
hip_hop |
Hip Hop, Funk, Hip Hop/Rap, Soul/Funk/Jazz |
jungle |
Jungle, Ragga Jungle, Rave |
uk_bass |
UK Bass |
progressive |
Progressive |
disco |
Disco |
techno |
Techno, Dark Techno, Industrial Techno, Dub Techno, Melodic House & Techno |
uk_garage |
UK Garage, UKG, 2-Step, UK Garage / Bassline |
You can also pass a Rekordbox genre tag directly (case-insensitive), e.g. --genre "Deep House".
Custom genres merge multiple standard genres into a single pool. They are designed for cross-genre sets where the interesting DJ work happens at the boundaries — moving from one sound to another and making it feel intentional.
./mixlab --genre 4x4
./mixlab --genre 170
./mixlab --genre 140| Label | Sub-genres | BPM range | Rationale |
|---|---|---|---|
170 |
drum_and_bass + jungle | 165–175 BPM | Hardcore continuum genres that live at the same tempo and share rhythmic DNA — the richest cross-genre territory in the collection |
140 |
breakbeat + uk_bass + uk_garage | 130–140 BPM | UK underground genres that occupy the same tempo bracket; blends can range from technical to percussive to bass-heavy |
4x4 |
house + electronica + disco + progressive + techno | none | The full 4/4 spectrum from deep house to techno, with a wide BPM range (~90–140); the creative challenge is the journey across that arc |
Custom genres behave differently from standard genres in two key ways:
1. BPM filtering. 170 and 140 apply a hard BPM range filter — tracks outside those bounds are excluded before Stage 1. The range is part of what defines the genre. 4x4 has no hard BPM filter: the sub-genres span a wide range (~90–140 BPM) and Stage 1 is responsible for finding BPM-coherent groupings within the pool.
2. Stage 2 cross-genre guidance. The Stage 2 prompt is given the list of sub-genres and instructed to justify any move across genre boundaries — naming the specific mechanism that makes the transition work (BPM alignment, rhythmic character, harmonic relationship, or the energy state of the room). Cross-genre moves are not avoided; they are the point of using a custom genre. But every such move must be defensible.
Custom genre pools are large — 4x4 alone is ~800 tracks. Sending the whole pool to Stage 1 would mean 10–15 sequential API calls to a free-tier LLM provider, which hits rate limits on every run and produces the same output every time.
Instead, MixLab picks a random 120-track window from the BPM-sorted pool on each run:
- The pool is sorted by BPM, so adjacent tracks in the sorted list are in the same tempo zone. The window always lands on a BPM-coherent slice — house tracks at 120–125 one run, progressive/techno at 128–133 the next.
- Only 2 Stage 1 API calls are made per run (120 tracks / 60 per call), keeping the LLM load light and the rate limits safe.
- Each run explores a different section of the collection, so you get different concepts each time without manually curating which tracks to send.
Run the same custom genre multiple times. Each run will focus on a different corner of the pool and produce different concepts.
- Source:
import/rekordbox.xml— read fresh on every run, never cached - Tracks missing BPM or Camelot key are excluded with a warning printed to stderr
- SoundCloud tracks (Location starting with
file://localhostsoundcloud) are excluded silently - Tracks in a Rekordbox playlist named
DO NOT RECOMMENDare excluded from every run; the crate snapshot shows how many were excluded, and a warning fires if the playlist is missing from the XML - If
CATALOG_API_URLis set, tracks in your catalog play history are excluded — fuzzy-matched on artist + title with unicode normalisation, dash normalisation, and feat. stripping; otherwise all tracks are treated as unplayed
Drum & Bass tracks (genre tag Drum & Bass or DnB) with a recorded BPM below 100 are automatically doubled. Rekordbox sometimes stores DnB at half-time tempo.
- Tracks are grouped by Rekordbox genre tag, then aggregated under a canonical label via
GENRE_MAPinconfig.py - Tracks whose genre tag is not in
GENRE_MAPand not inIGNORED_GENRESappear as Outliers in the Discord report - Outlier tracks matching the requested genre name (case-insensitive) are passed to Stage 1 as a
Misccluster if there are 4 or more of them - Custom genres (
170,140,4x4) merge multiple standard genres into a single pool before Stage 1; see Custom genres
- Standard genres: tracks are partitioned into three pools relative to the cluster median — core (±6 BPM), bridge (±12 BPM), and wildcard (>12 BPM). Core tracks are sent to Stage 1; bridge and wildcard tracks are retained as canvas metadata and are available to Stage 2 for structural roles such as opener, closer, or pivot where BPM deviation is intentional.
- Custom genres with a defined BPM range (
170,140): a hard range filter is applied instead — tracks outside the defined range are excluded - Custom genres without a BPM range (
4x4): no BPM filter is applied; Stage 1 finds BPM-coherent groupings within the pool itself
Tracks within each concept are sorted for harmonic compatibility. The algorithm walks the Camelot wheel preferring adjacent moves (±1 same mode, or same number opposite mode), falling back to lowest BPM when no harmonically compatible track is available.
- Provider cascade tried in order, falling through on error or missing key: Groq → Gemini → Mistral
- Standard genres: clusters larger than 40 tracks are chunked; each chunk is called independently and concepts merged
- Custom genres: a random 120-track window is selected from the BPM-sorted pool each run (see Why random selection?); 60 tracks per call, 2 calls maximum; shortlist target is 20–25 tracks per concept (vs 15–25 for standard)
- Track IDs are aliased to short positional keys (
T001,T002, …) in the prompt; hallucinated IDs are structurally impossible and concepts with fewer than 4 resolvable aliases are discarded - Stage 1 concepts are wrapped into Mix Canvases — structured objects that add role candidates (opener, groove-locker, builder, pivot, peak, closer), contrast assets (vocal moments, texture changes, darker/brighter turns), deterministic risk notes (weak opener/closer pool, BPM spread, artist repetition), an era window and dominant label when the core pool supports them, identity-defining
Anchorsfrom the core pool (provenance + library rarity + pool centrality), andConcept anchorstagging bridge/wildcard tracks aspeak,identity, orstructural-exception. Up to 6 canvases are forwarded to Stage 2, selected by a weighted scoring model covering technical viability, role coverage, anchor strength, contrast potential, cross-canvas distinctiveness, era coherence, label coherence, and novelty against recent run history. Weights are mode-aware —unplayedmode prioritises novelty,playedmode prioritises anchor strength,allmode prioritises cross-canvas distinctiveness. Selection is deterministic given the same input — no random sampling.
- Uses Claude Sonnet 4.6 (Anthropic-only, no fallback provider)
- Before sequencing, chooses an explicit energy path (Slow Climb, Wave, Plateau With Detail, Double Peak, Front-Loaded Hook, Dark to Light, Light to Dark) and assigns every track to one of five sections: Invitation, Groove Lock, Development, Peak/Payoff, Resolution
- Assigns each track a role from a focused 10-role vocabulary: opener, groove, hook, pivot, lift, vocal-moment, texture-change, peak, resolution, closer (a track may carry more than one)
- Each report includes: named energy path, structured
arc_typefield, section breakdown with track numbers, per-track role and transition risk, dedicated opener and closer rationale, excluded tracks with reasons, aBold moves:summary of bridge/wildcard usage with the mechanism that justified each pick, and a one-linePracticality:score (bpm_smoothness, harmonic_ratio, risk_justified, overall) for triage - If the catalog API returns existing mix names, Stage 2 is instructed to avoid reusing any words, tropes, or phrasing from them; each concept also includes a
name_reasontying the name to the set's thesis - Playlist mode generates three variants (
practical,balanced,adventurous) and auto-selects the strongest; seed retention is enforced with a floor of 75% of anchor tracks and 40% of supporting tracks - Appends shortfall warnings for concepts significantly below the recommended track count for their genre
- Appends the active report context and elapsed generation time to the final output
- After each successful run, concept history is written to
.mixlab/concept-history.json. On subsequent runs, canvases are penalised on a combined novelty score: 65% track-overlap Jaccard plus 35% concept-shape similarity (BPM band, dominant Camelot zone, role pattern,arc_type). Both components decay at 0.8^age across a 10-run recency window. Catches "different tracks, same shape" repetition the old track-only signal missed. Stage 2 also sees aRECENT CONCEPTSblock listing recent titles/arcs/moods so it can deliberately diverge. - Post-Stage-2 validation is warn-only. Strong-tier checks (always fire): track IDs not found in the library, denylist or played-track violations, Camelot jumps greater than 4, BPM jumps greater than 15, artist repeats of 3 or more, opener/closer absent in expected positions, bridge/wildcard tracks used without a justified
risk_type, and wildcard tracks used outside the canvas's concept-anchor list. Soft-tier checks (softened by genre family andarc_type): no peak in sequence, no wind-down before closer, three-or-more consecutive same-role-family tracks, all tracks high-energy, cross-concept track overlap above 50%, generic[Adjective][Noun]concept titles. Warnings appear under ⚠ Validation Notes and never abort the run.
| Genre | Minimum | Target |
|---|---|---|
| Drum & Bass / DnB | 10 | 14 |
| UK Garage | 10 | 13 |
| Jungle | 12 | 16 |
| House / Deep House / Techno | 8 | 12 |
| All others | 8 | 12 |
A shortfall warning fires when a concept has more than 4 tracks below the minimum.
pytest
pytest --tb=short -q # terse outputruff format .
ruff check .mypy .All three must pass clean before committing. See CLAUDE.md for full coding conventions.
MixLab/
├── src/mixlab/
│ ├── __main__.py # CLI entry point and pipeline orchestration
│ ├── reader.py # Rekordbox XML parsing and BPM correction
│ ├── client.py # Catalog API client (played track history, optional)
│ ├── matcher.py # Fuzzy played-track exclusion
│ ├── clustering.py # Genre grouping, BPM pool partitioning, Mix Canvas builder and scoring
│ ├── history.py # Concept history read/write (.mixlab/concept-history.json)
│ ├── llm.py # Stage 1 provider cascade + Stage 2 Anthropic report + post-run validation
│ ├── playlist_exporter.py # Rekordbox playlist XML export
│ ├── discord_client.py # Discord delivery and report formatting
│ ├── cache.py # Genre availability cache (.mixlab_genres.json)
│ ├── config.py # GENRE_MAP, IGNORED_GENRES, TRACK_COUNT_TARGETS
│ └── models.py # Pydantic models: Track, MixConcept, MixCanvas, BpmPools, CanvasScore
├── tests/ # pytest suite mirroring src layout
├── import/
│ └── rekordbox.xml # Your Rekordbox export (gitignored)
├── pyproject.toml
└── .env.example
MIT — see LICENSE.