Replace Spotify API with screenshot taste workflow and example profile.

Remove spotify integration; add TASTE-FROM-SCREENSHOTS guide; ship Ozan settings.json and taste_seeds.json as Cursor examples plus new wandering dervish track.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-07 14:47:47 +01:00
parent 6843ecd6b0
commit 02ea026851
21 changed files with 294 additions and 210 deletions
+10 -9
View File
@@ -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`
+2 -4
View File
@@ -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
+7 -5
View File
@@ -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
+5 -1
View File
@@ -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).
+12 -16
View File
@@ -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 |
+96
View File
@@ -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 515 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.
+2 -2
View File
@@ -390,7 +390,7 @@
<h1>Ozan Radio</h1>
<button type="button" class="icon-btn" id="settingsBtn" title="Settings" aria-label="Settings"></button>
</div>
<p class="tagline">No Spotify playback. No catalog tracks. DeepSeek picks the vibe — Google Lyria 3 composes it fresh.</p>
<p class="tagline">No catalog tracks. Taste from settings.json — set yours via Cursor + Spotify screenshots.</p>
<section class="dashboard" id="dashboard">
<div class="stat-card">
@@ -484,7 +484,7 @@
</section>
<div class="stack">
DJ: DeepSeek · Music: Google Lyria 3 · Taste: settings.json<br>
DJ: DeepSeek · Music: Lyria 3 · Taste: screenshots → Cursor → settings.json<br>
Optional live layer: Magenta RealTime 2 on Apple Silicon
</div>
</div>
Before
After
+1 -1
View File
@@ -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",
+19 -13
View File
@@ -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",
+10
View File
@@ -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"
}
Binary file not shown.
+10
View File
@@ -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"
}
Binary file not shown.
+19 -1
View File
@@ -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"
}
]
}
+4 -8
View File
@@ -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")
-13
View File
@@ -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
)
+3 -9
View File
@@ -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 12 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:
+3 -5
View File
@@ -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,
}
-102
View File
@@ -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,
)
+27 -13
View File
@@ -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(),
)
+58 -8
View File
@@ -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"
}
]
}