A Claude-powered playlist generator and curator for TIDAL — with a local, tunable recommendation engine.
Tsunami pairs Anthropic's Claude with a local model of your TIDAL listening to build playlists that expand your taste rather than rehashing the songs you already love. It syncs your library into SQLite, ranks it locally (recency + play-frecency + novelty, no LLM in the hot path), DJ-sequences the result for smooth flow, and only then hands a curated pool to Claude. Tell it a mood, set a running pace, or point it at an existing playlist.
The ranking is not hard-coded magic — it's a transparent weighted scorer you can A/B-test and retune to your own taste from a built-in tuning page.
Tsunami is three modes behind one tab bar. They share the same engine — your locally-ranked TIDAL library, Claude's curation, and DJ-style sequencing — but each is tuned for a different moment.
Spin the mood wheel — romance, energetic, chill, melancholy, focus, party — and Claude assembles a 15–20 track playlist from your locally-ranked favourites, deliberately leaning into deeper cuts over the obvious hits. Tracks stream in live, each with a one-line reason for the pick, a smart editable name, and one-tap accept / skip before you save straight back to TIDAL.
| Spin the mood wheel | Curated, sequenced & named |
|---|---|
![]() |
![]() |
Point Tsunami at any playlist in your TIDAL account and it reads that playlist's "musical DNA," then suggests new tracks that match its style without duplicating what's already there. Keep the ones that land and append them in place — your existing order stays intact.
| Pick a playlist | Additions that match its style |
|---|---|
![]() |
![]() |
Dial in a distance and pace (or a direct BPM) and the DJ-desk turns it into a running cadence with tempo-matched windows (half-time / two-thirds / full). The sliders stretch all the way — a 1K sprint to a 100K ultra, world-record marathon pace down to a 10:00/km plod, and runs up to 10 hours — and Tsunami fills a playlist long enough to cover the whole distance, BPM-matched and beat-sequenced so the tempo carries you.
Don't like a pick? In every mode, skipping a track surfaces 3–5 alternatives that fit that slot in the flow — Run matches them locally by BPM, Create/Enhance use TIDAL radio seeded from the track and its neighbours. Swap one in-place, or drop it entirely.
- Create mode — Pick a mood (romance, energetic, chill, melancholy, focus, party) and Claude generates a 15–20 track playlist seeded from your locally-ranked favourites.
- Enhance mode — Point Tsunami at an existing TIDAL playlist. It analyses the playlist's "musical DNA" and suggests new tracks that fit without duplicating what's already there.
- Run mode — Enter a distance and pace (or a direct BPM target). Tsunami computes your running cadence, picks tempo-compatible tracks (half-time / two-thirds / full-cadence windows), and builds a playlist long enough to cover the whole run.
- Local recommendation engine — A synced SQLite library is ranked in milliseconds by a weighted scorer biased toward what you've added recently and play often — see How recommendation ranking works.
- DJ-style sequencing — The chosen tracks are ordered for smooth tempo/key transitions, with the same artist spaced apart and style clusters kept short.
- Tunable to your taste — A built-in
/tuner.htmlharness lets you A/B weight configs against your real library, flag tracks, and bake the winners into the defaults. - Discovery-first curation — A deliberate prompt mandate ensures the majority of Claude's suggestions are new discoveries and deeper cuts, not just the obvious hits.
- Interactive feedback loop — Accept or skip individual tracks; free-form feedback ("more upbeat", "less mainstream") steers the next round.
- Smart track swap — Skipping a track first offers 3–5 alternatives that suit its slot in the flow (Run matches them locally by BPM; Create/Enhance use TIDAL radio seeded from the track and its neighbours). Swap one in-place, or remove it entirely.
- Real-time streaming — Curation progress and tracks stream into the UI live via Server-Sent Events.
- Name & save back to TIDAL — Every playlist gets a smart default name (mood + month, or the run cadence) you can edit inline before saving; create a new playlist from accepted tracks, or append to the playlist you're enhancing.
┌──────────────┐ SSE ┌─────────────────────┐ HTTP ┌──────────────────┐
│ Browser │ ◀──────────▶ │ Next.js API routes │ ◀────────▶ │ TIDAL MCP server │
│ (React UI) │ │ /api/generate │ │ (tidal-mcp, │
│ /tuner.html │ │ /api/run /enhance │ │ local, :5100) │
└──────────────┘ │ /api/recommend ... │ └──────────────────┘
└─────────┬───────────┘
┌─────────────┴──────────────┐
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ SQLite library │ │ Claude │
│ (data/library) │ │ (Anthropic) │
│ local ranking │ │ curation + tools│
└──────────────────┘ └──────────────────┘
- A sync pulls your favourites, listening-history mixes, and (optionally) playlists from TIDAL into
data/library.db. - The local recommender (
lib/recommender.ts) scores and ranks that library — no LLM, no network — producing a candidate pool biased toward recent adds and heavy rotation. - The sequencer (
lib/sequencer.ts) orders the chosen tracks for smooth flow. - For Create/Run/Enhance, the ranked pool is handed to Claude (with TIDAL tools) to curate and fill out the playlist; the result streams back to the UI.
- Accepted tracks are written back to TIDAL through the MCP server.
The local recommender is also exposed standalone at
GET /api/recommend— pure SQLite scoring with no LLM call. This is the fast path used by the tuning page.
All ranking happens locally over the synced SQLite library. The pipeline is:
candidate pool → merge same-recording duplicates → score each track → sort → cap per artist → sequence
(db.ts) (recommender.ts) (recommender.ts) (recommender.ts) (sequencer.ts)
getRecommenderPool() joins every scoring signal in one SQL pass (lib/db.ts):
| Signal | Source | Meaning |
|---|---|---|
added_at |
favorites |
Real timestamp you saved the track |
added_rank |
favorites |
Position in your favourites (0 = most recently added) — a fallback when added_at is missing |
is_favorite |
favorites |
Whether it's a saved favourite at all |
in_alltime / in_yearly |
track_history |
Membership in your all-time / yearly TIDAL history mixes |
monthly_months |
track_history |
Which monthly history mixes it appears in (month_index list; 0 = this month) |
best_mix_rank |
track_history |
Its best (lowest) position across any history mix |
Optional filters: BPM windows (for Run mode) and "favourites only".
Why history mixes? TIDAL doesn't expose raw play counts, but its
HISTORY_*mixes are a play-frequency/recency proxy: a track recurring across many monthly mixes (and ranking high in them) is one you play a lot, lately.
The same recording can exist under several TIDAL IDs (single vs. album vs. remaster). mergeByRecording() groups them by ISRC (falling back to artist + title) and combines their signals, so "recently added" (your favourite's ID) and "played a lot" (the history mix's ID) compound onto one row instead of splitting into near-duplicates. The favourite instance is kept as canonical.
scoreTrack() is a transparent linear sum of five terms:
total = recency + favorite + play + popularity + novelty
| Term | Formula | What it rewards |
|---|---|---|
| recency | recencyAdd × 2^(−ageDays / recencyHalfLifeDays) (or rank-based fallback 2^(−added_rank / recencyRankHalfLife)) |
Tracks you saved recently |
| favorite | favoriteBoost × is_favorite |
Being a saved favourite at all (the "spine") |
| play | playAlltime·in_alltime + playYearly·in_yearly + Σₘ playMonthly·e^(−m / monthlyDecayTau) + playRankBonus·1/(1+best_mix_rank) |
Heavy, recent rotation (frecency). Multiplicity across monthly mixes accumulates; recent months decay slower |
| popularity | popularity × (global_popularity / 100) |
A weak global prior (tuned near-zero) |
| novelty | novelty × random() |
Exploration / tie-breaking — re-running reshuffles ties by design |
capPerArtist()caps how many tracks per artist make the list (maxPerArtist, default 2) while preserving score order.- The top
limittracks are then passed to the sequencer (lib/sequencer.ts), which orders them to minimise adjacent-transition cost across tempo (BPM), harmonic key (Camelot wheel derived from the synced musical key), and style (genre / audio-feature distance from enrichment), while keeping the same artistartistGappositions apart (default 3) and capping consecutive same-style tracks (maxStyleRun, default 3). The style term activates once genre enrichment has run.
DEFAULT_WEIGHTS in lib/recommender.ts, arrived at over two rounds of live tuning:
recencyAdd: 3.0, favoriteBoost: 2.5,
playAlltime: 0.8, playYearly: 0.8, playMonthly: 0.7, playRankBonus: 0.6,
popularity: 0.1, novelty: 1.5,
recencyHalfLifeDays: 120, recencyRankHalfLife: 150, monthlyDecayTau: 3,Character: your saved favourites are the spine, tilted toward recent adds, with listening-history play as reinforcement and a healthy novelty term for variety. Popularity is near-zero — popularity-driven results tested as off-taste.
Every weight is overridable per request as a query param on /api/recommend, so you can A/B without touching code:
# Inspect the local ranking with a custom weighting (no LLM, no TIDAL round-trip):
curl -s 'http://localhost:3000/api/recommend?explain=1&limit=25&recencyAdd=3&playMonthly=1.2&novelty=1.5&sequence=1'Supported params: every weight key above, plus limit, maxPerArtist, favoritesOnly=1, bpm & tol (Run-style windows), sequence=0 (skip DJ ordering), artistGap, maxStyleRun, and explain=1 (include the per-term score breakdown).
Tsunami ships a standalone Recommender Tuner at http://localhost:3000/tuner.html (public/tuner.html). This is the exact page used to train the current DEFAULT_WEIGHTS — it runs the local recommender against your library so you can see, flag, and retune the ranking to your own ear.
- Presents a battery of weight presets (A–F), each with a hypothesis and a "look for" prompt, plus an editable params box so you can tweak any weight on the fly.
- Run test calls
GET /api/recommend?explain=1&limit=…&<your params>and renders the ranked tracks in a table with a per-signal breakdown (recency / play / popularity / favourite / date-added) so you can see why each track ranked where it did. - For each track you can cycle a flag: 👍 love · 🕰 too old · 🚫 not played · ❓ unexpected.
- For each preset you record a verdict (✓ Good / ~ Mixed / ✗ Bad) and free-form commentary.
- Everything auto-saves to
nimbalyst-local/tuning/round-<N>.jsonviaPOST /api/tuning(and reloads restore it). Download JSON exports a round for your records.
The committed nimbalyst-local/tuning/round-1.json and round-2.json are the real feedback that shaped the defaults (e.g. "popularity… doesn't match my taste", "monthly is my strongest play signal", "all-time lost my metal") — useful as worked examples.
- Sync first. The tuner needs favourites + history present, or
/api/recommendreturns409:curl -N -X POST http://localhost:3000/api/library/sync \ -H 'Content-Type: application/json' -d '{"mode":"full"}'
- Open http://localhost:3000/tuner.html.
- Work through the battery. For each preset: click ▶ Run test, read the results, flag tracks that feel wrong (too old / not actually played / surprising) and ones you love, set a verdict, and jot why in the comment. Hit Save & next →.
- Experiment freely. Edit the params box on any preset (or use preset F · Your call) to push or cut a weight, change a half-life, or tighten
maxPerArtist, then re-run. Note what you changed in the comment so the round is self-documenting. - Read the signal. Compare verdicts and flags across presets. The breakdown columns tell you which term is driving a bad pick (e.g. lots of
popon tracks you don't recognise → cutpopularity; stale tracks ranking high → shortenrecencyHalfLifeDaysor raiserecencyAdd). - Iterate in a new round. To start a fresh round, bump the
ROUNDconstant near the top ofpublic/tuner.htmland adjust theBATTERYarray of presets to probe what the last round raised. Each round persists to its ownround-<N>.json. - Lock in the winner. Once a configuration consistently earns ✓ Good, either:
- edit
DEFAULT_WEIGHTSinlib/recommender.tsto make it the app-wide default (what Create/Run/Enhance use), or - pass it per call as query params on
/api/recommendwhen you want a one-off weighting without changing the default.
- edit
Tip: because
noveltyinjects randomness, re-running the same preset reshuffles ties on purpose. Judge the overall character across a couple of runs, not the exact order.
Tsunami's TIDAL connectivity is built on top of ibeal/tidal-mcp — a fork of the excellent original tidal-mcp by yuhuacheng — which handles TIDAL authentication, favourites, recommendations, and playlist management over a local HTTP API. Please go star both repos. 🌟
Tsunami talks to tidal-mcp's HTTP REST API (
tidal_api/app.py), which it runs as a local sidecar process. BPM analysis runs in a second local sidecar (bpm-service/) — ffmpeg + librosa kept out of the lean TIDAL shim — that fills gaps where TIDAL has no native BPM. Both start automatically withnpm run dev.
- Node.js 20+ (Next.js 16 requires Node 20.9+)
- Python with uv — runs both the tidal-mcp server and the BPM sidecar
ffmpegon yourPATH— used by the BPM sidecar to decode audio for analysis- A local clone of ibeal/tidal-mcp
- An Anthropic API key
- A TIDAL account
git clone https://github.com/ibeal/tidal-mcp.gitFollow its README to install dependencies (it uses uv).
npm installCreate a .env.local in the project root:
# Required — your Anthropic API key
ANTHROPIC_API_KEY=sk-ant-...
# Optional — where the tidal-mcp HTTP server is reachable (default: http://127.0.0.1:5100)
TIDAL_API_URL=http://127.0.0.1:5100
# Optional — where the BPM sidecar is reachable (default: http://127.0.0.1:5101)
BPM_SERVICE_URL=http://127.0.0.1:5101The tidal script in package.json launches the MCP server. It defaults to a sibling ../tidal-mcp clone — if yours lives elsewhere, set TIDAL_MCP_DIR instead of editing package.json:
# Clone is somewhere other than ../tidal-mcp? Point at it (and make sure uv is on PATH):
TIDAL_MCP_DIR=/path/to/tidal-mcp npm run devnpm run devconcurrently starts all three:
- the tidal-mcp server on port
5100(labeltidal, cyan) - the BPM sidecar (
bpm-service/) on port5101(labelbpm, yellow) - the Next.js dev server on port
3000(labelnext, magenta)
Wait for the next line, then open http://localhost:3000:
[tidal] Starting Flask app on port 5100
[bpm] Tsunami BPM sidecar starting on port 5101
[next] ✓ Ready ... Local: http://localhost:3000
Workspace root:
next.config.tspinsturbopack.rootto this project. Without that pin, a straypackage-lock.jsonanywhere up the directory tree (e.g. in your home folder) can make Next infer the wrong workspace root and fail withCan't resolve 'tailwindcss'. If you move the project, the pin keeps resolution local.If Connect TIDAL fails with
fetch failed, the tidal-mcp backend isn't on:5100— check the[tidal]logs.
On first launch you'll be prompted to connect TIDAL. A browser window opens for you to log in; the session is saved locally by tidal-mcp, so you only do this once.
Tsunami needs a synced data/library.db before the local recommender (or any LLM mode) can run.
# Quick — favourites + listening-history mixes only (seconds). Enough for ranking.
curl -N -X POST http://localhost:3000/api/library/sync \
-H 'Content-Type: application/json' -d '{"mode":"quick"}'
# Full — also crawls all your playlists AND runs local BPM analysis (slower, minutes).
curl -N -X POST http://localhost:3000/api/library/sync \
-H 'Content-Type: application/json' -d '{"mode":"full"}'
# Incremental — refresh only changed playlists + favourites/history, then BPM (the default).
curl -N -X POST http://localhost:3000/api/library/sync \
-H 'Content-Type: application/json' -d '{"mode":"incremental"}'Which mode? Musical key is captured on every track a sync touches. BPM, however, is filled by local audio analysis that runs only in
fullandincrementalmodes —quickskips it. For harmonic and tempo sequencing (and Run mode), run afullsync at least once.quickis ideal for fast recommendation/tuning iterations where BPM isn't needed.
Check coverage anytime:
curl -s http://localhost:3000/api/library/status
# { "trackCount":…, "favoriteCount":…, "bpmTracksCount":…, "lastSync":… }curl -N -X POST http://localhost:3000/api/enrich # Claude labels genre/mood/energy/… (batched, SSE)
curl -s http://localhost:3000/api/enrich # coverage: {"enriched":N,"total":M}This unlocks style-aware sequencing (small genre clusters, gradual transitions). It calls the Anthropic API, so it costs credits.
- In the app — Create / Run / Enhance modes (recency/frecency-biased and DJ-sequenced).
- Locally, no LLM (fast, for inspection/tuning):
curl -s 'http://localhost:3000/api/recommend?explain=1&limit=25' - Weight-tuning harness — open http://localhost:3000/tuner.html (see above).
- Smart track swap + named saves + UI pass — Skipping a track now suggests fitting alternatives to swap in-place (Run: instant local BPM match; Create/Enhance: TIDAL radio); playlists get an editable smart-default name before saving; and a Linear-style visual refresh (design tokens, focus rings, motion). See Three ways to build a playlist.
- Local recommendation engine — TIDAL library is synced to SQLite and ranked locally by recency-of-add + play-frecency + novelty, with same-recording (ISRC) de-duplication and per-artist diversity caps. See How recommendation ranking works.
- DJ sequencer — final tracklists are ordered for smooth tempo/key/style transitions with artist spacing (
lib/sequencer.ts). - Tuning harness —
public/tuner.html+/api/tuningpersistence let you A/B weight configs against your own library and bake in the winners. The shipped defaults were trained here over two rounds (nimbalyst-local/tuning/). - BPM enrichment — local audio analysis backfills BPM during
full/incrementalsyncs for tempo-matched Run mode and harmonic sequencing. - Stability fixes —
- Pinned
turbopack.rootinnext.config.tsso a stray~/package-lock.jsonno longer hijacks module resolution (theCan't resolve 'tailwindcss'dev-server crash). - Fixed the SQLite schema-init ordering so the
favorites.added_atindex is created after its column migration — olderlibrary.dbfiles now migrate cleanly on launch instead of throwingno such column: added_at.
- Pinned
| Script | Description |
|---|---|
npm run dev |
Run the tidal-mcp server, the BPM sidecar, and the Next.js dev server together |
npm run tidal |
Run only the tidal-mcp server (set TIDAL_MCP_DIR if the clone isn't at ../tidal-mcp) |
npm run bpm |
Run only the BPM sidecar (bpm-service/, port 5101) |
npm run build |
Production build |
npm run start |
Start the production server |
npm run lint |
Run ESLint |
app/
page.tsx # Main client UI: mode switching, streaming, feedback loop
api/
generate/route.ts # Create-mode: ranked pool → Claude curation → sequenced (SSE)
enhance/route.ts # Enhance-mode: suggest additions for a playlist (SSE)
run/route.ts # Run-mode: BPM-matched, recommender-ranked, sequenced (SSE)
recommend/route.ts # Local, LLM-free generation + per-weight A/B overrides (GET)
library/sync/route.ts # Sync TIDAL → SQLite (quick | full | incremental) (SSE)
library/status/route.ts# Library coverage (tracks, favourites, BPM, last sync)
enrich/route.ts # LLM genre/mood enrichment over the library (SSE)
tuning/route.ts # Persistence for the weight-tuning harness
save/route.ts # Create a new TIDAL playlist
tidal/ # Auth, login, playlists, playlist-track proxies
components/
RunnerConfig.tsx # Distance/pace/BPM input UI for Run mode
... # Mood selector, playlist views, track cards, feedback bar
lib/
db.ts # SQLite layer: tracks, favourites, history, features, feedback + the candidate-pool query
sync.ts # TIDAL → SQLite sync (favourites recency + history frecency + BPM)
recommender.ts # Local weighted scorer (recency + frecency + ISRC merge + DEFAULT_WEIGHTS)
sequencer.ts # DJ sequencing: Camelot key + tempo + artist spacing
similarity.ts # Content-based audio-feature distance
enrich.ts # Batched Claude classification → track_features
claude.ts # Anthropic client, tool defs, system prompts, parsing
tidal.ts # Thin client over the tidal-mcp HTTP API
reddit.ts # Music-subreddit context fetching
public/tuner.html # Standalone recommender weight-tuning harness
bpm-service/ # Local BPM-analysis sidecar (ffmpeg + librosa, port 5101)
nimbalyst-local/tuning/ # Saved tuning rounds (round-<N>.json) that trained the defaults
types/index.ts # Shared TypeScript types (incl. RunConfig)
- Next.js 16 (App Router, Turbopack) + React 19
- Tailwind CSS 4
- TypeScript
- better-sqlite3 — local library + ranking store
- Anthropic SDK — Claude with tool use (model:
claude-sonnet-4-6) - ibeal/tidal-mcp — TIDAL integration (fork of yuhuacheng/tidal-mcp)
- This is a personal/local project: the tidal-mcp server runs on your own machine and stores your TIDAL session locally.
data/(includinglibrary.db) is git-ignored — your synced library never leaves your machine.- The
tidalnpm script defaults to a sibling../tidal-mcpclone; setTIDAL_MCP_DIRif yours lives elsewhere (see step 4).




