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"
}
]
}