diff --git a/.cursor/skills/ozan-radio/SKILL.md b/.cursor/skills/ozan-radio/SKILL.md index 079a898..207d475 100644 --- a/.cursor/skills/ozan-radio/SKILL.md +++ b/.cursor/skills/ozan-radio/SKILL.md @@ -1,6 +1,6 @@ --- name: ozan-radio -description: Operate Live Ozan Radio — DeepSeek DJ plans tracks, Google Lyria 3 generates them, Spotify supplies taste. Use when the user wants to start the radio, generate a track, skip, or tune the station. +description: Operate Live Ozan Radio — DeepSeek DJ, Lyria 3, taste from settings.json and taste_seeds.json. Use for start/generate/skip, or updating taste from Spotify screenshots (no API). --- # Live Ozan Radio @@ -8,12 +8,17 @@ description: Operate Live Ozan Radio — DeepSeek DJ plans tracks, Google Lyria ## When to use - User says "ozan radio", "live radio", "generate a track", "what's playing" +- User sends **Spotify screenshots** — update `settings.json` + `taste_seeds.json` (see `docs/TASTE-FROM-SCREENSHOTS.md`) - Operating or debugging `tinqs/live-radio` +## Taste from screenshots + +**No Spotify API.** Read `docs/TASTE-FROM-SCREENSHOTS.md` for the copy-paste Cursor prompt. Infer genres/mood/instruments from Home, Library, Daily Mixes; write both JSON files; never suggest playing Spotify URLs. + ## Prerequisites - `GEMINI_API_KEY` and `DEEPSEEK_API_KEY` in `.env` -- Server running: `python -m ozan_radio serve` (port 8787) +- Server: `python -m ozan_radio serve` (port 8787) ## Operations @@ -22,18 +27,14 @@ description: Operate Live Ozan Radio — DeepSeek DJ plans tracks, Google Lyria | Start station | `python -m ozan_radio serve` | | Generate track | `POST http://127.0.0.1:8787/api/generate` | | Now playing | `GET http://127.0.0.1:8787/api/now` | -| Skip | `POST http://127.0.0.1:8787/api/skip` | +| Skip / shuffle | `POST http://127.0.0.1:8787/api/skip` | | One-shot CLI | `python -m ozan_radio generate` | ## DJ behavior -DeepSeek reads Spotify taste (if configured), avoids recent titles, outputs a Lyria prompt. Do not suggest playing Spotify URLs — generation only. +DeepSeek reads `settings.json` + `taste_seeds.json`, avoids recent titles, outputs Lyria prompts. Generation only — no catalog playback. ## Models - Lyria: `lyria-3-pro-preview` (songs) or `lyria-3-clip-preview` (30s) -- DeepSeek: `deepseek-chat` via Tinqs inference proxy by default - -## Mac live layer - -Magenta RealTime 2 (`pip install "magenta-rt[mlx]"`) for real-time beds — see README. +- DeepSeek: `deepseek-chat` via Tinqs inference or `https://api.deepseek.com/v1` diff --git a/.env.example b/.env.example index 7a341f3..17c9c06 100644 --- a/.env.example +++ b/.env.example @@ -8,10 +8,8 @@ DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 DEEPSEEK_API_KEY= DEEPSEEK_MODEL=deepseek-chat -# Spotify taste profile (optional — DJ works without it) -SPOTIFY_CLIENT_ID= -SPOTIFY_CLIENT_SECRET= -SPOTIFY_REFRESH_TOKEN= +# Taste: settings.json + taste_seeds.json (see docs/TASTE-FROM-SCREENSHOTS.md) +# No Spotify API — use Cursor + library screenshots instead. # Radio server RADIO_HOST=127.0.0.1 diff --git a/AGENTS.md b/AGENTS.md index 6d20b59..db3be48 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,13 +4,16 @@ Public repo under `tinqs/live-radio`. AI agents run the station — humans liste ## Identity -- **No catalog playback.** Spotify is taste input only. Every track is generated. -- **DeepSeek** plans mood + Lyria prompts. **Google Lyria 3** renders audio. +- **No catalog playback.** Every track is generated by Lyria 3. +- **DeepSeek** plans mood + Lyria prompts. +- **Taste** from `settings.json` + `taste_seeds.json` — typically built from **Spotify screenshots in Cursor** (no Spotify API). - Respond in English. -## Taste +## Taste setup -Edit `settings.json` — DJ + chat read it every request. Default: ethnic world dubtronica. +1. Read **`docs/TASTE-FROM-SCREENSHOTS.md`** +2. User attaches Spotify screenshots → update `settings.json` and `taste_seeds.json` +3. DJ reloads both on every generate and chat ## Session start @@ -32,7 +35,6 @@ curl -X POST http://127.0.0.1:8787/api/generate |-----|---------| | `GEMINI_API_KEY` | Lyria 3 | | `DEEPSEEK_API_KEY` | DJ brain | -| `SPOTIFY_*` | Optional taste | ## Siblings diff --git a/CLAUDE.md b/CLAUDE.md index bd5830d..4fd7d40 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,10 +16,14 @@ python -m ozan_radio serve Player: `http://127.0.0.1:8787/player` +## Taste + +Screenshot Spotify → Cursor → `settings.json` + `taste_seeds.json`. See `docs/TASTE-FROM-SCREENSHOTS.md`. No Spotify API. + ## Architecture ``` -Spotify taste ──► DeepSeek DJ ──► Lyria 3 ──► radio_cache/*.mp3 ──► FastAPI stream +settings.json + taste_seeds.json ──► DeepSeek DJ ──► Lyria 3 ──► songs/*.mp3 ──► FastAPI stream ``` Optional: Magenta RealTime 2 on Mac for live MIDI/text steering (~200ms latency). diff --git a/README.md b/README.md index 364d141..d1e049d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Live Ozan Radio -Personal AI radio — **no catalog music, ever**. DeepSeek is the DJ. Google **Lyria 3** composes every track. Taste comes from `settings.json` (and optionally Spotify). +Personal AI radio — **no catalog music, ever**. DeepSeek is the DJ. Google **Lyria 3** composes every track. Taste comes from `settings.json` + `taste_seeds.json` — usually built from **Spotify screenshots in Cursor** (no Spotify API). Inspired by [Magenta RealTime 2](https://magenta.withgoogle.com/magenta-realtime-2) (live, ~200ms) and [Lyria 3](https://deepmind.google/models/lyria/) (full songs via Gemini API). On Mac you can layer MRT2 for true live improvisation; this repo ships the cross-platform Lyria + DeepSeek stack first. @@ -10,8 +10,7 @@ Inspired by [Magenta RealTime 2](https://magenta.withgoogle.com/magenta-realtime |-------|---------|------| | DJ brain | DeepSeek (Tinqs proxy or BYOK) | Mood, prompts, chat, variety | | Music engine | Google Lyria 3 Pro / Clip | Generate MP3 tracks | -| Taste | `settings.json` + `taste_seeds.json` | Genres, mood, instruments — DJ reads every request | -| Taste (optional) | Spotify Web API | Top artists, genres — never plays Spotify | +| Taste | `settings.json` + `taste_seeds.json` | Profile from screenshots or manual edit — see below | | Player | FastAPI + `gateway/player.html` | Stream queue, library, chat, dashboard | | Live (optional) | Magenta RealTime 2 | Apple Silicon only — see below | @@ -37,9 +36,15 @@ When shuffle is on (default): Daily generation stats live in `songs/stats.json` (gitignored, local runtime only). +## Taste from Spotify screenshots (recommended) + +**No Spotify API keys.** Screenshot your library (Home, Daily Mixes, playlists), open this repo in **Cursor**, attach images, and paste the prompt from **[docs/TASTE-FROM-SCREENSHOTS.md](docs/TASTE-FROM-SCREENSHOTS.md)**. The agent updates `settings.json` and `taste_seeds.json`. + +Share that doc with friends — same flow for group taste (WhatsApp links + screenshots). + ## Taste (`settings.json`) -Edit `settings.json` at the repo root. The DJ reloads it on every generate and chat. +Edit `settings.json` at the repo root, or let Cursor fill it from screenshots. The DJ reloads it on every generate and chat. ```json { @@ -58,7 +63,7 @@ Edit `settings.json` at the repo root. The DJ reloads it on every generate and c } ``` -Default taste profile: **ethnic world dubtronica** (global roots + dub space + electronic groove). +**Example profile (Ozan):** checked-in [`settings.json`](settings.json) + [`taste_seeds.json`](taste_seeds.json) — Anadolu psych + ethnic dubtronica from Spotify screenshots. Copy the structure for your own taste. The player settings panel PATCHes `playback` and `limits` via `/api/settings` and writes back to this file. @@ -82,7 +87,8 @@ python -m venv .venv .venv\Scripts\activate pip install -e . copy .env.example .env -# Fill GEMINI_API_KEY + DEEPSEEK_API_KEY (and Spotify if you have them) +# Fill GEMINI_API_KEY + DEEPSEEK_API_KEY +# Taste: docs/TASTE-FROM-SCREENSHOTS.md (Cursor + Spotify screenshots) $env:DEEPSEEK_BASE_URL = "https://api.deepseek.com/v1" python -m ozan_radio serve @@ -102,7 +108,6 @@ python -m ozan_radio generate | `GEMINI_API_KEY` | Yes | [Google AI Studio](https://aistudio.google.com/apikey) — Lyria 3 | | `DEEPSEEK_API_KEY` | Yes | Tinqs proxy token or DeepSeek direct | | `DEEPSEEK_BASE_URL` | No | Default `https://api.deepseek.com/v1` | -| `SPOTIFY_*` | No | Refresh token flow — taste only | | `LYRIA_MODEL` | No | `lyria-3-pro-preview` (default) or `lyria-3-clip-preview` | | `RADIO_OUTPUT_DIR` | No | Default `./songs` | @@ -115,15 +120,6 @@ python -m ozan_radio generate At the default cap of 10 new songs/day with Lyria Pro, projected max spend is **~$0.82/day**. Adjust `costs` and `limits` in `settings.json` or the player settings panel. -### Spotify setup (optional taste) - -1. Create an app at [Spotify Developer Dashboard](https://developer.spotify.com/dashboard). -2. Add redirect URI `http://127.0.0.1:8888/callback`. -3. Complete OAuth once to obtain a refresh token (scope: `user-top-read`). -4. Paste `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET`, `SPOTIFY_REFRESH_TOKEN` into `.env`. - -If Spotify is not configured, the DJ uses `settings.json` + `taste_seeds.json`. - ## API | Method | Path | Description | diff --git a/docs/TASTE-FROM-SCREENSHOTS.md b/docs/TASTE-FROM-SCREENSHOTS.md new file mode 100644 index 0000000..c331dc2 --- /dev/null +++ b/docs/TASTE-FROM-SCREENSHOTS.md @@ -0,0 +1,96 @@ +# Taste from Spotify screenshots (no API keys) + +Live Ozan Radio does **not** connect to Spotify. Every track is generated fresh — we never play catalog music. + +Your taste lives in two files the DJ reads on every generate: + +| File | Purpose | +|------|---------| +| `settings.json` | Broad profile — genres, mood, instruments, tempo, references, avoid | +| `taste_seeds.json` | Concrete anchors — artists, playlists, albums, track vibes | + +**Easiest setup:** open this repo in **Cursor** (or Claude Code), paste Spotify screenshots, and ask the agent to update those two files. + +--- + +## What to screenshot + +Capture anything that shows your real listening — the more context, the better: + +1. **Spotify Home** — Daily Mixes, Jump back in, Made For you +2. **Your Library** — playlists, artists, albums (scroll if needed) +3. **Liked Songs** or a playlist you actually use +4. **Now Playing** — optional, shows current mood + +Two or three screenshots usually enough. PNG from desktop app or phone both work. + +--- + +## Cursor prompt (copy-paste) + +Attach your screenshots to the chat, then paste: + +``` +I'm setting up Live Ozan Radio (this repo). No Spotify API — taste only. + +Look at my Spotify screenshots and update: +1. settings.json → taste section (summary, genres, mood, instruments, tempo_bpm, references, avoid) +2. taste_seeds.json → genres, artists, playlists, and 5–15 track/album seeds with short "vibe" lines + +Rules: +- Infer crossover taste the DJ can compose toward — not a copy of any one artist +- settings.json = creative direction for Lyria; taste_seeds.json = concrete reference anchors +- Never suggest playing Spotify URLs — generation only +- Keep listener name as me unless I say otherwise +- Show me a short summary of what you inferred before writing files +``` + +The agent edits the JSON files. Restart the server (or just generate — settings reload every request). + +--- + +## Claude Code / pi / other agents + +Same flow: clone repo, add screenshots, use the prompt above. Entry point: `AGENTS.md` and `.cursor/skills/ozan-radio/SKILL.md`. + +--- + +## Manual edit (no AI) + +Edit `settings.json` and `taste_seeds.json` by hand. See `taste_seeds.json` for the track object shape: + +```json +{ + "title": "Garip", + "artists": ["Altın Gün"], + "album": "Garip", + "vibe": "Anadolu psych-folk, bağlama, fuzzy warmth, hypnotic groove" +} +``` + +--- + +## Friends / group taste + +For a shared WhatsApp vibe (everyone's links and references), paste the **chat excerpt + screenshots** into Cursor: + +``` +Our friend group shares this taste (chat + screenshots attached). +Update settings.json for a crossover station everyone would enjoy on repeat. +Bias: [e.g. Anadolu psych, Soap&Skin ether, Mercan Dede, dubtronica] +``` + +Commit `settings.json` + `taste_seeds.json` if you want the profile in git. Keep `.env` private (API keys only). + +--- + +## Example profile (checked in) + +The repo ships **Ozan's** profile as a reference — do not need to match it; replace with your own. + +| File | What it shows | +|------|----------------| +| [`settings.json`](../settings.json) | Full DJ direction — genres, mood, instruments, tempo, references, avoid | +| [`taste_seeds.json`](../taste_seeds.json) | Concrete anchors — artists, playlists, albums, track vibes | + +Built from Spotify screenshots: Altın Gün, Baaba Maal, Thievery Corporation, islandman, Blanco White, ethnic chill, dubtronica, Anadolu psych. diff --git a/gateway/player.html b/gateway/player.html index 1985f0c..2be6def 100644 --- a/gateway/player.html +++ b/gateway/player.html @@ -390,7 +390,7 @@

Ozan Radio

-

No Spotify playback. No catalog tracks. DeepSeek picks the vibe — Google Lyria 3 composes it fresh.

+

No catalog tracks. Taste from settings.json — set yours via Cursor + Spotify screenshots.

@@ -484,7 +484,7 @@
- DJ: DeepSeek · Music: Google Lyria 3 · Taste: settings.json
+ DJ: DeepSeek · Music: Lyria 3 · Taste: screenshots → Cursor → settings.json
Optional live layer: Magenta RealTime 2 on Apple Silicon
diff --git a/pyproject.toml b/pyproject.toml index fbcf89d..5c2ed26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ where = ["src"] [project] name = "live-ozan-radio" version = "0.1.0" -description = "AI radio — DeepSeek DJ + Google Lyria 3, tuned to your Spotify taste" +description = "AI radio — DeepSeek DJ + Google Lyria 3, taste from settings and screenshot seeds" requires-python = ">=3.11" dependencies = [ "google-genai>=1.0", diff --git a/settings.json b/settings.json index d94e02b..05f6123 100644 --- a/settings.json +++ b/settings.json @@ -1,18 +1,20 @@ { "listener": "ozan", + "profile_note": "Example taste profile (Ozan / bozanbozkurt Spotify library, built from screenshots in Cursor). Copy this file structure for your own station — see docs/TASTE-FROM-SCREENSHOTS.md.", "station": "Live Ozan Radio", "taste": { - "summary": "Ethnic world dubtronica — global roots, dub space, and electronic groove.", + "summary": "Anadolu psychedelic rock meets ethnic dubtronica — Turkish psych, dark ether, Sufi electronic, Sahel warmth.", "genres": [ + "anadolu psychedelic rock", + "turkish psychedelic rock", "ethnic world", "world dub", "dubtronica", - "dub", - "global electronica", + "dark ethereal indie", + "sufi electronic", "desert blues", "afro-dub", "middle eastern dub", - "balkan dub", "trip-hop dub" ], "mood": [ @@ -20,22 +22,26 @@ "warm", "spacious", "late-night", - "sunset caravan", - "meditative but danceable" + "cinematic gothic", + "only lovers left alive", + "meditative but danceable", + "magnum opus slow burn" ], "instruments": [ + "bağlama or saz", + "ney flute", "sub bass", "dub delay and spring reverb", + "fuzz guitar with middle eastern scales", "hand percussion", - "kora or oud", - "nose flute or melodica", - "tabla", - "djembe", - "muted guitar skank", + "darbuka", + "oud", + "melancholic piano", + "whispered vocal texture", "analog warmth" ], - "tempo_bpm": [82, 108], - "references": "Thievery Corporation meets Sahel dub; Baaba Maal warmth over a stepper bassline; Khruangbin haze with Lee Scratch Perry space; ethnic samples woven into dubtronica, not EDM.", + "tempo_bpm": [78, 102], + "references": "Altın Gün and Cem Karaca Anadolu psych; islandman and Kaya Project ethnic electronic; Thievery Corporation dub lounge; Baaba Maal Sahel; Blanco White melancholic ether; Shye Ben Tzur / Anoushka Shankar fusion; Jon Hopkins texture; Buddha Bar ethnic chill; Mungo's Hi Fi reggae-dub. Profile built from Spotify screenshots via Cursor — see docs/TASTE-FROM-SCREENSHOTS.md.", "avoid": [ "big-room EDM drops", "four-on-the-floor house", diff --git a/songs/80206d7a.meta.json b/songs/80206d7a.meta.json new file mode 100644 index 0000000..b90a2cf --- /dev/null +++ b/songs/80206d7a.meta.json @@ -0,0 +1,10 @@ +{ + "id": "80206d7a", + "title": "Whisper of the Dervish", + "mood": "hypnotic, late-night, slow-burn cinematic", + "dj_line": "This one's for the hours when the world falls away \u2014 a desert prayer for the dancing soul.", + "lyria_prompt": "Instrumental. Anadolu psychedelic rock meets Sufi electronic and dark cinematic ether. Tempo 85 BPM. Build slowly: start with a single, melancholic piano phrase and a distant, whispered vocal texture (wordless, breathy, processed with spring reverb). Layer in a melodic ney flute line in a minor maqam, doubled by a slightly distorted saz/ba\u011flama playing a hypnotic riff. Add a warm, deep sub bass pulse (dub style) and a slow, sparse darbuka pattern with hand percussion. Introduce a fuzz guitar with heavy spring reverb playing a slow, soaring solo in a Turkish psych scale (H\u00fcseyni or U\u015f\u015fak) around 1:00. Keep the mix spacious, with dub delay and analog warmth. The arrangement should feel like a slow-motion journey through a candlelit desert ruin. No drums until 0:45 \u2014 just a soft kick on the downbeat. End with the ney fading over a sustained bass note and piano chord.", + "lyrics": "[[A0]]\n[0.0:] (Haaaaaa...)\n[:] (Ooooooh...)\n[:] (Mmmmmmm...)\n[:] (Haaa-ooo-waaa...)\n[[B1]]\n[[A2]]\n[[C3]]\n[84.7:] (Haaaa...)\n[:] (Oooooh...)\n[[D4]]\n[[C5]]\n[118.6:] (Haaaaaaaah...)\n[[E6]]", + "file": "80206d7a_Whisper_of_the_Dervish.mp3", + "saved_at": "2026-06-07T13:47:36.171360+00:00" +} \ No newline at end of file diff --git a/songs/80206d7a_Whisper_of_the_Dervish.mp3 b/songs/80206d7a_Whisper_of_the_Dervish.mp3 new file mode 100644 index 0000000..2d24f3a --- /dev/null +++ b/songs/80206d7a_Whisper_of_the_Dervish.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2298ff52cfe913f776fc255cf957a5a99f577ee2ca6a66b9eb68b59629bdc90b +size 3667399 diff --git a/songs/e0701762.meta.json b/songs/e0701762.meta.json new file mode 100644 index 0000000..8c3a2d9 --- /dev/null +++ b/songs/e0701762.meta.json @@ -0,0 +1,10 @@ +{ + "id": "e0701762", + "title": "Echoes of the Wandering Dervish", + "mood": "Hypnotic, warm, late-night cinematic slow burn", + "dj_line": "A slow-moving caravan across the Anatolian steppe, where the ney whispers secrets over a dubwise bassline.", + "lyria_prompt": "Turkish psychedelic rock meets Sufi electronic dub. Start with a deep, sub-heavy dub bassline at 85 BPM, pulsing with spring reverb and delay. Layer a melancholic piano phrase in a minor key, sparse and echoing. Introduce a ney flute melody, slow and hypnotic, floating over the bass. After 30 seconds, bring in a fuzz guitar playing a Middle Eastern scale phrase, heavily treated with reverb and analog warmth. Add darbuka and hand percussion in a slow, rolling pattern. Keep the arrangement spacious: no more than 4-5 elements at once. Build gradually, adding a whispered vocal texture (wordless, airy) in the second half. Avoid any sudden changes or drops. End with a slow fade on ney and bass. Analog warmth, tape saturation, and dub-style echo throughout. Instrumental with subtle vocal whispers.", + "lyrics": "[[A0]]\n[[A1]]\n[[A2]]\n[[B3]]\n[84.7:] (Haaaaaa...)\n[:] (Hoooooo...)\n[:] (Sssssssss...)\n[:] (Aaaaah...)\n[:] (Oooooh...)\n[[C4]]\n[118.6:] (Mmmmmm...)\n[:] (Haaaa...)\n[:] (Hoooo...)\n[[D5]]", + "file": "e0701762_Echoes_of_the_Wandering_Dervish.mp3", + "saved_at": "2026-06-07T13:44:08.752948+00:00" +} \ No newline at end of file diff --git a/songs/e0701762_Echoes_of_the_Wandering_Dervish.mp3 b/songs/e0701762_Echoes_of_the_Wandering_Dervish.mp3 new file mode 100644 index 0000000..be2cf18 --- /dev/null +++ b/songs/e0701762_Echoes_of_the_Wandering_Dervish.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1b38d085d981b1a255b036403817aa9cdf8edc2dddaa92d6e0bf4586a9d2e031 +size 3669907 diff --git a/songs/manifest.json b/songs/manifest.json index a9fbcad..878fe32 100644 --- a/songs/manifest.json +++ b/songs/manifest.json @@ -1,6 +1,6 @@ { "index": 2, - "count": 4, + "count": 6, "tracks": [ { "id": "b51b1cb2", @@ -37,6 +37,24 @@ "lyria_prompt": "Instrumental dubtronica track at 92 BPM in D minor. Start with a deep, sub-heavy bassline in a slow stepper rhythm. Layer a clean, plucked kora melody with long, spacey reverb and tape delay. Add a muted guitar skank playing offbeat chords with spring reverb. Hand percussion: djembe and shakers playing a relaxed, syncopated pattern. Introduce a melodica line with heavy echo, floating in the mix. Use analog warmth and subtle vinyl crackle. Build slowly, keeping the arrangement open and spacious. No vocals. Structure: intro with bass and percussion, add kora, then guitar, then melodica, then drop to just bass and percussion before returning with all elements. Fade out with delays.", "lyrics": "[[A0]]\n[[A1]]\n[[A2]]\n[[A3]]\n[[A4]]\n[[A5]]\n[[B6]]", "file": "eeebb429_Desert_Mirage.mp3" + }, + { + "id": "e0701762", + "title": "Echoes of the Wandering Dervish", + "mood": "Hypnotic, warm, late-night cinematic slow burn", + "dj_line": "A slow-moving caravan across the Anatolian steppe, where the ney whispers secrets over a dubwise bassline.", + "lyria_prompt": "Turkish psychedelic rock meets Sufi electronic dub. Start with a deep, sub-heavy dub bassline at 85 BPM, pulsing with spring reverb and delay. Layer a melancholic piano phrase in a minor key, sparse and echoing. Introduce a ney flute melody, slow and hypnotic, floating over the bass. After 30 seconds, bring in a fuzz guitar playing a Middle Eastern scale phrase, heavily treated with reverb and analog warmth. Add darbuka and hand percussion in a slow, rolling pattern. Keep the arrangement spacious: no more than 4-5 elements at once. Build gradually, adding a whispered vocal texture (wordless, airy) in the second half. Avoid any sudden changes or drops. End with a slow fade on ney and bass. Analog warmth, tape saturation, and dub-style echo throughout. Instrumental with subtle vocal whispers.", + "lyrics": "[[A0]]\n[[A1]]\n[[A2]]\n[[B3]]\n[84.7:] (Haaaaaa...)\n[:] (Hoooooo...)\n[:] (Sssssssss...)\n[:] (Aaaaah...)\n[:] (Oooooh...)\n[[C4]]\n[118.6:] (Mmmmmm...)\n[:] (Haaaa...)\n[:] (Hoooo...)\n[[D5]]", + "file": "e0701762_Echoes_of_the_Wandering_Dervish.mp3" + }, + { + "id": "80206d7a", + "title": "Whisper of the Dervish", + "mood": "hypnotic, late-night, slow-burn cinematic", + "dj_line": "This one's for the hours when the world falls away \u2014 a desert prayer for the dancing soul.", + "lyria_prompt": "Instrumental. Anadolu psychedelic rock meets Sufi electronic and dark cinematic ether. Tempo 85 BPM. Build slowly: start with a single, melancholic piano phrase and a distant, whispered vocal texture (wordless, breathy, processed with spring reverb). Layer in a melodic ney flute line in a minor maqam, doubled by a slightly distorted saz/ba\u011flama playing a hypnotic riff. Add a warm, deep sub bass pulse (dub style) and a slow, sparse darbuka pattern with hand percussion. Introduce a fuzz guitar with heavy spring reverb playing a slow, soaring solo in a Turkish psych scale (H\u00fcseyni or U\u015f\u015fak) around 1:00. Keep the mix spacious, with dub delay and analog warmth. The arrangement should feel like a slow-motion journey through a candlelit desert ruin. No drums until 0:45 \u2014 just a soft kick on the downbeat. End with the ney fading over a sustained bass note and piano chord.", + "lyrics": "[[A0]]\n[0.0:] (Haaaaaa...)\n[:] (Ooooooh...)\n[:] (Mmmmmmm...)\n[:] (Haaa-ooo-waaa...)\n[[B1]]\n[[A2]]\n[[C3]]\n[84.7:] (Haaaa...)\n[:] (Oooooh...)\n[[D4]]\n[[C5]]\n[118.6:] (Haaaaaaaah...)\n[[E6]]", + "file": "80206d7a_Whisper_of_the_Dervish.mp3" } ] } \ No newline at end of file diff --git a/src/ozan_radio/__main__.py b/src/ozan_radio/__main__.py index 87daf30..a8df6a3 100644 --- a/src/ozan_radio/__main__.py +++ b/src/ozan_radio/__main__.py @@ -10,24 +10,20 @@ from ozan_radio.dj import DeepSeekDJ from ozan_radio.lyria import LyriaEngine from ozan_radio.queue import RadioQueue from ozan_radio.server import app -from ozan_radio.spotify import SpotifyTaste from ozan_radio.taste import load_taste_seeds async def generate_one() -> None: """CLI: generate a single track and print the result.""" cfg = Config.from_env() - taste = await SpotifyTaste(cfg).fetch_taste() - seeds = None if taste else load_taste_seeds() - if taste: - print(f"Taste: {taste.summary}\n") - elif seeds: + seeds = load_taste_seeds() + if seeds: print(f"Taste seeds: {seeds.summary}\n") else: - print("No Spotify or seeds — DJ will freestyle.\n") + print("No taste_seeds.json — DJ uses settings.json only.\n") q = RadioQueue(cfg.output_dir) - plan = await DeepSeekDJ(cfg).plan_next(taste, q.recent_titles, seeds) + plan = await DeepSeekDJ(cfg).plan_next(q.recent_titles, seeds) print(f"DJ: {plan.dj_line}") print(f"Title: {plan.title}") print(f"Prompt: {plan.lyria_prompt}\n") diff --git a/src/ozan_radio/config.py b/src/ozan_radio/config.py index 979ae79..15c22a8 100644 --- a/src/ozan_radio/config.py +++ b/src/ozan_radio/config.py @@ -15,9 +15,6 @@ class Config: deepseek_base_url: str deepseek_api_key: str deepseek_model: str - spotify_client_id: str - spotify_client_secret: str - spotify_refresh_token: str radio_host: str radio_port: int output_dir: Path @@ -37,9 +34,6 @@ class Config: ), deepseek_api_key=os.getenv("DEEPSEEK_API_KEY", ""), deepseek_model=os.getenv("DEEPSEEK_MODEL", "deepseek-chat"), - spotify_client_id=os.getenv("SPOTIFY_CLIENT_ID", ""), - spotify_client_secret=os.getenv("SPOTIFY_CLIENT_SECRET", ""), - spotify_refresh_token=os.getenv("SPOTIFY_REFRESH_TOKEN", ""), radio_host=os.getenv("RADIO_HOST", "127.0.0.1"), radio_port=int(os.getenv("RADIO_PORT", "8787")), output_dir=output, @@ -54,10 +48,3 @@ class Config: if not self.deepseek_api_key: raise RuntimeError("DEEPSEEK_API_KEY is required for the DJ brain") - @property - def spotify_configured(self) -> bool: - return bool( - self.spotify_client_id - and self.spotify_client_secret - and self.spotify_refresh_token - ) diff --git a/src/ozan_radio/dj.py b/src/ozan_radio/dj.py index a8c77ce..f806b95 100644 --- a/src/ozan_radio/dj.py +++ b/src/ozan_radio/dj.py @@ -7,7 +7,6 @@ from dataclasses import dataclass import httpx from ozan_radio.config import Config -from ozan_radio.spotify import TasteProfile from ozan_radio.settings import ListenerSettings, load_settings from ozan_radio.taste import TasteSeeds @@ -29,7 +28,7 @@ DJ_SYSTEM = """You are the DJ for Live Ozan Radio — a personal AI station that catalog music. Every track is generated fresh by Google Lyria 3 based on your prompts. Your job: -1. Read the listener's Spotify taste (if provided). +1. Read settings.json and taste_seeds.json (listener profile from screenshots or manual edits). 2. Pick a mood, tempo, and genre blend that feels like a natural next track. 3. Write a Lyria prompt that produces a 1–2 minute instrumental or vocal track. 4. Keep variety — don't repeat the same vibe twice in a row. @@ -121,7 +120,6 @@ class DeepSeekDJ: async def plan_next( self, - taste: TasteProfile | None, recent_titles: list[str], seeds: TasteSeeds | None = None, request: str | None = None, @@ -132,12 +130,8 @@ class DeepSeekDJ: "Plan the next generated track for Live Ozan Radio.", f"Station taste:\n{self._taste_block()}", ] - if taste: - user_parts.append(f"Spotify taste: {taste.summary}") - if taste.top_genres: - user_parts.append(f"Genres: {', '.join(taste.top_genres[:8])}") - elif seeds: - user_parts.append(f"Taste seeds (no Spotify): {seeds.summary}") + if seeds: + user_parts.append(f"Taste seeds:\n{seeds.summary}") if recent_titles: user_parts.append(f"Already played (avoid repeating): {', '.join(recent_titles[-5:])}") if request: diff --git a/src/ozan_radio/server.py b/src/ozan_radio/server.py index baf2916..b6487f3 100644 --- a/src/ozan_radio/server.py +++ b/src/ozan_radio/server.py @@ -13,7 +13,6 @@ from ozan_radio.config import Config from ozan_radio.dj import DeepSeekDJ from ozan_radio.lyria import LyriaEngine from ozan_radio.queue import RadioQueue -from ozan_radio.spotify import SpotifyTaste from ozan_radio.chat_store import ChatStore from ozan_radio.dj import TrackPlan from ozan_radio.library import list_saved_songs @@ -117,12 +116,11 @@ async def _compose_track(request: str | None = None, *, check_limit: bool = True "message": f"Daily limit reached ({budget['max_per_day']} new songs)", "budget": budget, } - taste = await SpotifyTaste(cfg).fetch_taste() - seeds = None if taste else load_taste_seeds() + seeds = load_taste_seeds() q = _get_queue() vibe = request or _chat.take_vibe_hint() plan = await DeepSeekDJ(cfg).plan_next( - taste, q.recent_titles, seeds, request=vibe or None + q.recent_titles, seeds, request=vibe or None ) _set_generation(busy=True, phase="composing", title=plan.title) track = LyriaEngine(cfg).generate(plan) @@ -137,7 +135,7 @@ async def _compose_track(request: str | None = None, *, check_limit: bool = True "status": "ok", "source": "generated", "track": np.__dict__ if np else None, - "taste_used": taste.summary if taste else (seeds.summary if seeds else None), + "taste_used": seeds.summary if seeds else None, "cost_usd": cost, "budget": budget, } diff --git a/src/ozan_radio/spotify.py b/src/ozan_radio/spotify.py deleted file mode 100644 index 090d576..0000000 --- a/src/ozan_radio/spotify.py +++ /dev/null @@ -1,102 +0,0 @@ -from __future__ import annotations - -import base64 -from dataclasses import dataclass - -import httpx - -from ozan_radio.config import Config - - -@dataclass -class TasteProfile: - top_artists: list[str] - top_genres: list[str] - recent_tracks: list[str] - summary: str - - -class SpotifyTaste: - """Read Ozan's listening taste — no playback, profile only.""" - - def __init__(self, config: Config) -> None: - self._config = config - self._token: str | None = None - - async def _access_token(self) -> str: - if self._token: - return self._token - creds = f"{self._config.spotify_client_id}:{self._config.spotify_client_secret}" - auth = base64.b64encode(creds.encode()).decode() - async with httpx.AsyncClient(timeout=30) as client: - resp = await client.post( - "https://accounts.spotify.com/api/token", - headers={ - "Authorization": f"Basic {auth}", - "Content-Type": "application/x-www-form-urlencoded", - }, - data={ - "grant_type": "refresh_token", - "refresh_token": self._config.spotify_refresh_token, - }, - ) - resp.raise_for_status() - self._token = resp.json()["access_token"] - return self._token - - async def fetch_taste(self) -> TasteProfile | None: - if not self._config.spotify_configured: - return None - - token = await self._access_token() - headers = {"Authorization": f"Bearer {token}"} - - async with httpx.AsyncClient(timeout=30) as client: - artists_resp = await client.get( - "https://api.spotify.com/v1/me/top/artists", - headers=headers, - params={"limit": 10, "time_range": "medium_term"}, - ) - tracks_resp = await client.get( - "https://api.spotify.com/v1/me/top/tracks", - headers=headers, - params={"limit": 10, "time_range": "medium_term"}, - ) - artists_resp.raise_for_status() - tracks_resp.raise_for_status() - - artists = artists_resp.json().get("items", []) - tracks = tracks_resp.json().get("items", []) - - top_artists = [a["name"] for a in artists] - genres: list[str] = [] - for artist in artists: - genres.extend(artist.get("genres", [])) - # dedupe while preserving order - seen: set[str] = set() - top_genres = [] - for g in genres: - if g not in seen: - seen.add(g) - top_genres.append(g) - - recent_tracks = [ - f"{t['name']} — {t['artists'][0]['name']}" for t in tracks if t.get("artists") - ] - - genre_hint = ", ".join(top_genres[:6]) if top_genres else "eclectic" - artist_hint = ", ".join(top_artists[:5]) if top_artists else "varied" - summary = ( - f"Listener leans toward {genre_hint}. " - f"Frequent artists: {artist_hint}. " - f"Recent favorites include {', '.join(recent_tracks[:3])}." - if recent_tracks - else f"Listener leans toward {genre_hint}. Frequent artists: {artist_hint}." - ) - - return TasteProfile( - top_artists=top_artists, - top_genres=top_genres[:12], - recent_tracks=recent_tracks, - summary=summary, - ) diff --git a/src/ozan_radio/taste.py b/src/ozan_radio/taste.py index f97c18e..61f741c 100644 --- a/src/ozan_radio/taste.py +++ b/src/ozan_radio/taste.py @@ -9,11 +9,14 @@ from pathlib import Path class TasteSeeds: genres: list[str] tracks: list[dict] + artists: list[str] + playlists: list[str] + albums: list[dict] summary: str def load_taste_seeds(repo_root: Path | None = None) -> TasteSeeds | None: - """Load taste_seeds.json from repo root — fallback when Spotify is offline.""" + """Load taste_seeds.json — manual taste from screenshots or hand-edited seeds.""" root = repo_root or Path(__file__).resolve().parents[2] path = root / "taste_seeds.json" if not path.exists(): @@ -26,19 +29,30 @@ def load_taste_seeds(repo_root: Path | None = None) -> TasteSeeds | None: genres = data.get("genres", []) tracks = data.get("tracks", []) - if not genres and not tracks: + artists = data.get("artists", []) + playlists = data.get("playlists", []) + albums = data.get("albums", []) + if not any([genres, tracks, artists, playlists, albums]): return None - track_hints = [] - for t in tracks[:5]: - artists = ", ".join(t.get("artists", [])) - vibe = t.get("vibe", "") - track_hints.append(f"{t.get('title', '?')} ({artists}) — {vibe}") - - summary = "Seed taste profile. " + parts = ["Listener taste seeds (screenshot / manual profile)."] + if artists: + parts.append(f"Artists: {', '.join(artists[:12])}.") + if playlists: + parts.append(f"Playlists: {', '.join(playlists[:6])}.") if genres: - summary += f"Genres: {', '.join(genres[:8])}. " - if track_hints: - summary += "Reference vibes: " + "; ".join(track_hints) + parts.append(f"Genres: {', '.join(genres[:10])}.") + for album in albums[:4]: + parts.append(f"{album.get('title', '?')} — {album.get('vibe', '')}") + for t in tracks[:5]: + artist_str = ", ".join(t.get("artists", [])) + parts.append(f"{t.get('title', '?')} ({artist_str}) — {t.get('vibe', '')}") - return TasteSeeds(genres=genres, tracks=tracks, summary=summary.strip()) + return TasteSeeds( + genres=genres, + tracks=tracks, + artists=artists, + playlists=playlists, + albums=albums, + summary=" ".join(parts).strip(), + ) diff --git a/taste_seeds.json b/taste_seeds.json index daebb5f..8ed361f 100644 --- a/taste_seeds.json +++ b/taste_seeds.json @@ -1,22 +1,72 @@ { "listener": "ozan", - "notes": "Manual taste seeds when Spotify API is not wired. Add tracks from your library — DJ reads vibe, never replays catalog.", + "source": "Spotify screenshots → Cursor (no API). Re-run docs/TASTE-FROM-SCREENSHOTS.md when your library shifts.", + "playlists": [ + "Buddha Bar Lounge - Ethnic Chill", + "Reggelectro and Dubtronica", + "Sci-Fi Scapes & Café Africa", + "Liked Songs", + "Your Top Songs 2025" + ], + "artists": [ + "Altın Gün", + "Baaba Maal", + "Thievery Corporation", + "islandman", + "Cem Karaca", + "Kaya Project", + "Blanco White", + "Shye Ben Tzur", + "Anoushka Shankar", + "Jon Hopkins", + "Glass Beams", + "Francis Bebey", + "Mungo's Hi Fi", + "Matthew Halsall" + ], + "albums": [ + { "title": "Garip", "artist": "Altın Gün", "vibe": "Anadolu psych-folk, bağlama, fuzzy warmth" }, + { "title": "Estuaire", "artist": "Ablaye Cissoko", "vibe": "kora, West African elegance, spacious" }, + { "title": "Mirage", "artist": "Glass Beams", "vibe": "psychedelic instrumental, Middle Eastern tint" }, + { "title": "African Electronic Music 1975-1982", "artist": "Francis Bebey", "vibe": "early African electronic, playful hypnotic" } + ], "genres": [ - "ethnic world", + "anadolu psychedelic rock", + "ethnic chill", "dubtronica", "world dub", - "west african", - "afro-folk", - "desert blues" + "trip-hop", + "desert blues", + "afro-dub", + "atmospheric electronic", + "global fusion jazz" ], "tracks": [ { "title": "Boboyillo", "artists": ["Baaba Maal", "Rougi"], "album": "Being", - "added": "2023-07-16", - "duration_sec": 245, - "vibe": "Sahel warmth, rolling desert pulse, call-and-response vocals, acoustic strings and hand percussion, spiritual but danceable" + "vibe": "Sahel warmth, rolling desert pulse, call-and-response, spiritual but danceable" + }, + { + "title": "Olalla", + "artists": ["Blanco White"], + "vibe": "melancholic indie folk, Spanish ether, intimate late-night" + }, + { + "title": "Daily Mix 01", + "artists": ["BALTHVS", "Pentagram", "Cem Karaca"], + "vibe": "Turkish rock and psych crossover" + }, + { + "title": "Daily Mix 03", + "artists": ["Thievery Corporation", "Télépopmusik"], + "vibe": "downtempo trip-hop, lounge space, dubbed edges" + }, + { + "title": "Daily Mix 04", + "artists": ["Shye Ben Tzur", "Anoushka Shankar"], + "vibe": "Indian classical fusion, devotional texture, world spiritual" } ] }