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:
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: ozan-radio
|
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
|
# Live Ozan Radio
|
||||||
@@ -8,12 +8,17 @@ description: Operate Live Ozan Radio — DeepSeek DJ plans tracks, Google Lyria
|
|||||||
## When to use
|
## When to use
|
||||||
|
|
||||||
- User says "ozan radio", "live radio", "generate a track", "what's playing"
|
- 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`
|
- 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
|
## Prerequisites
|
||||||
|
|
||||||
- `GEMINI_API_KEY` and `DEEPSEEK_API_KEY` in `.env`
|
- `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
|
## Operations
|
||||||
|
|
||||||
@@ -22,18 +27,14 @@ description: Operate Live Ozan Radio — DeepSeek DJ plans tracks, Google Lyria
|
|||||||
| Start station | `python -m ozan_radio serve` |
|
| Start station | `python -m ozan_radio serve` |
|
||||||
| Generate track | `POST http://127.0.0.1:8787/api/generate` |
|
| Generate track | `POST http://127.0.0.1:8787/api/generate` |
|
||||||
| Now playing | `GET http://127.0.0.1:8787/api/now` |
|
| 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` |
|
| One-shot CLI | `python -m ozan_radio generate` |
|
||||||
|
|
||||||
## DJ behavior
|
## 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
|
## Models
|
||||||
|
|
||||||
- Lyria: `lyria-3-pro-preview` (songs) or `lyria-3-clip-preview` (30s)
|
- Lyria: `lyria-3-pro-preview` (songs) or `lyria-3-clip-preview` (30s)
|
||||||
- DeepSeek: `deepseek-chat` via Tinqs inference proxy by default
|
- DeepSeek: `deepseek-chat` via Tinqs inference or `https://api.deepseek.com/v1`
|
||||||
|
|
||||||
## Mac live layer
|
|
||||||
|
|
||||||
Magenta RealTime 2 (`pip install "magenta-rt[mlx]"`) for real-time beds — see README.
|
|
||||||
|
|||||||
+2
-4
@@ -8,10 +8,8 @@ DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
|
|||||||
DEEPSEEK_API_KEY=
|
DEEPSEEK_API_KEY=
|
||||||
DEEPSEEK_MODEL=deepseek-chat
|
DEEPSEEK_MODEL=deepseek-chat
|
||||||
|
|
||||||
# Spotify taste profile (optional — DJ works without it)
|
# Taste: settings.json + taste_seeds.json (see docs/TASTE-FROM-SCREENSHOTS.md)
|
||||||
SPOTIFY_CLIENT_ID=
|
# No Spotify API — use Cursor + library screenshots instead.
|
||||||
SPOTIFY_CLIENT_SECRET=
|
|
||||||
SPOTIFY_REFRESH_TOKEN=
|
|
||||||
|
|
||||||
# Radio server
|
# Radio server
|
||||||
RADIO_HOST=127.0.0.1
|
RADIO_HOST=127.0.0.1
|
||||||
|
|||||||
@@ -4,13 +4,16 @@ Public repo under `tinqs/live-radio`. AI agents run the station — humans liste
|
|||||||
|
|
||||||
## Identity
|
## Identity
|
||||||
|
|
||||||
- **No catalog playback.** Spotify is taste input only. Every track is generated.
|
- **No catalog playback.** Every track is generated by Lyria 3.
|
||||||
- **DeepSeek** plans mood + Lyria prompts. **Google Lyria 3** renders audio.
|
- **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.
|
- 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
|
## Session start
|
||||||
|
|
||||||
@@ -32,7 +35,6 @@ curl -X POST http://127.0.0.1:8787/api/generate
|
|||||||
|-----|---------|
|
|-----|---------|
|
||||||
| `GEMINI_API_KEY` | Lyria 3 |
|
| `GEMINI_API_KEY` | Lyria 3 |
|
||||||
| `DEEPSEEK_API_KEY` | DJ brain |
|
| `DEEPSEEK_API_KEY` | DJ brain |
|
||||||
| `SPOTIFY_*` | Optional taste |
|
|
||||||
|
|
||||||
## Siblings
|
## Siblings
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,14 @@ python -m ozan_radio serve
|
|||||||
|
|
||||||
Player: `http://127.0.0.1:8787/player`
|
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
|
## 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).
|
Optional: Magenta RealTime 2 on Mac for live MIDI/text steering (~200ms latency).
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Live Ozan Radio
|
# 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.
|
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 |
|
| DJ brain | DeepSeek (Tinqs proxy or BYOK) | Mood, prompts, chat, variety |
|
||||||
| Music engine | Google Lyria 3 Pro / Clip | Generate MP3 tracks |
|
| Music engine | Google Lyria 3 Pro / Clip | Generate MP3 tracks |
|
||||||
| Taste | `settings.json` + `taste_seeds.json` | Genres, mood, instruments — DJ reads every request |
|
| Taste | `settings.json` + `taste_seeds.json` | Profile from screenshots or manual edit — see below |
|
||||||
| Taste (optional) | Spotify Web API | Top artists, genres — never plays Spotify |
|
|
||||||
| Player | FastAPI + `gateway/player.html` | Stream queue, library, chat, dashboard |
|
| Player | FastAPI + `gateway/player.html` | Stream queue, library, chat, dashboard |
|
||||||
| Live (optional) | Magenta RealTime 2 | Apple Silicon only — see below |
|
| 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).
|
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`)
|
## 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
|
```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.
|
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
|
.venv\Scripts\activate
|
||||||
pip install -e .
|
pip install -e .
|
||||||
copy .env.example .env
|
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"
|
$env:DEEPSEEK_BASE_URL = "https://api.deepseek.com/v1"
|
||||||
python -m ozan_radio serve
|
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 |
|
| `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_API_KEY` | Yes | Tinqs proxy token or DeepSeek direct |
|
||||||
| `DEEPSEEK_BASE_URL` | No | Default `https://api.deepseek.com/v1` |
|
| `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` |
|
| `LYRIA_MODEL` | No | `lyria-3-pro-preview` (default) or `lyria-3-clip-preview` |
|
||||||
| `RADIO_OUTPUT_DIR` | No | Default `./songs` |
|
| `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.
|
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
|
## API
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|
|||||||
@@ -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.
|
||||||
+2
-2
@@ -390,7 +390,7 @@
|
|||||||
<h1>Ozan Radio</h1>
|
<h1>Ozan Radio</h1>
|
||||||
<button type="button" class="icon-btn" id="settingsBtn" title="Settings" aria-label="Settings">⚙</button>
|
<button type="button" class="icon-btn" id="settingsBtn" title="Settings" aria-label="Settings">⚙</button>
|
||||||
</div>
|
</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">
|
<section class="dashboard" id="dashboard">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
@@ -484,7 +484,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="stack">
|
<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
|
Optional live layer: Magenta RealTime 2 on Apple Silicon
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
|
Before
After
|
+1
-1
@@ -8,7 +8,7 @@ where = ["src"]
|
|||||||
[project]
|
[project]
|
||||||
name = "live-ozan-radio"
|
name = "live-ozan-radio"
|
||||||
version = "0.1.0"
|
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"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"google-genai>=1.0",
|
"google-genai>=1.0",
|
||||||
|
|||||||
+19
-13
@@ -1,18 +1,20 @@
|
|||||||
{
|
{
|
||||||
"listener": "ozan",
|
"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",
|
"station": "Live Ozan Radio",
|
||||||
"taste": {
|
"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": [
|
"genres": [
|
||||||
|
"anadolu psychedelic rock",
|
||||||
|
"turkish psychedelic rock",
|
||||||
"ethnic world",
|
"ethnic world",
|
||||||
"world dub",
|
"world dub",
|
||||||
"dubtronica",
|
"dubtronica",
|
||||||
"dub",
|
"dark ethereal indie",
|
||||||
"global electronica",
|
"sufi electronic",
|
||||||
"desert blues",
|
"desert blues",
|
||||||
"afro-dub",
|
"afro-dub",
|
||||||
"middle eastern dub",
|
"middle eastern dub",
|
||||||
"balkan dub",
|
|
||||||
"trip-hop dub"
|
"trip-hop dub"
|
||||||
],
|
],
|
||||||
"mood": [
|
"mood": [
|
||||||
@@ -20,22 +22,26 @@
|
|||||||
"warm",
|
"warm",
|
||||||
"spacious",
|
"spacious",
|
||||||
"late-night",
|
"late-night",
|
||||||
"sunset caravan",
|
"cinematic gothic",
|
||||||
"meditative but danceable"
|
"only lovers left alive",
|
||||||
|
"meditative but danceable",
|
||||||
|
"magnum opus slow burn"
|
||||||
],
|
],
|
||||||
"instruments": [
|
"instruments": [
|
||||||
|
"bağlama or saz",
|
||||||
|
"ney flute",
|
||||||
"sub bass",
|
"sub bass",
|
||||||
"dub delay and spring reverb",
|
"dub delay and spring reverb",
|
||||||
|
"fuzz guitar with middle eastern scales",
|
||||||
"hand percussion",
|
"hand percussion",
|
||||||
"kora or oud",
|
"darbuka",
|
||||||
"nose flute or melodica",
|
"oud",
|
||||||
"tabla",
|
"melancholic piano",
|
||||||
"djembe",
|
"whispered vocal texture",
|
||||||
"muted guitar skank",
|
|
||||||
"analog warmth"
|
"analog warmth"
|
||||||
],
|
],
|
||||||
"tempo_bpm": [82, 108],
|
"tempo_bpm": [78, 102],
|
||||||
"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.",
|
"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": [
|
"avoid": [
|
||||||
"big-room EDM drops",
|
"big-room EDM drops",
|
||||||
"four-on-the-floor house",
|
"four-on-the-floor house",
|
||||||
|
|||||||
@@ -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.
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"index": 2,
|
"index": 2,
|
||||||
"count": 4,
|
"count": 6,
|
||||||
"tracks": [
|
"tracks": [
|
||||||
{
|
{
|
||||||
"id": "b51b1cb2",
|
"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.",
|
"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]]",
|
"lyrics": "[[A0]]\n[[A1]]\n[[A2]]\n[[A3]]\n[[A4]]\n[[A5]]\n[[B6]]",
|
||||||
"file": "eeebb429_Desert_Mirage.mp3"
|
"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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -10,24 +10,20 @@ from ozan_radio.dj import DeepSeekDJ
|
|||||||
from ozan_radio.lyria import LyriaEngine
|
from ozan_radio.lyria import LyriaEngine
|
||||||
from ozan_radio.queue import RadioQueue
|
from ozan_radio.queue import RadioQueue
|
||||||
from ozan_radio.server import app
|
from ozan_radio.server import app
|
||||||
from ozan_radio.spotify import SpotifyTaste
|
|
||||||
from ozan_radio.taste import load_taste_seeds
|
from ozan_radio.taste import load_taste_seeds
|
||||||
|
|
||||||
|
|
||||||
async def generate_one() -> None:
|
async def generate_one() -> None:
|
||||||
"""CLI: generate a single track and print the result."""
|
"""CLI: generate a single track and print the result."""
|
||||||
cfg = Config.from_env()
|
cfg = Config.from_env()
|
||||||
taste = await SpotifyTaste(cfg).fetch_taste()
|
seeds = load_taste_seeds()
|
||||||
seeds = None if taste else load_taste_seeds()
|
if seeds:
|
||||||
if taste:
|
|
||||||
print(f"Taste: {taste.summary}\n")
|
|
||||||
elif seeds:
|
|
||||||
print(f"Taste seeds: {seeds.summary}\n")
|
print(f"Taste seeds: {seeds.summary}\n")
|
||||||
else:
|
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)
|
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"DJ: {plan.dj_line}")
|
||||||
print(f"Title: {plan.title}")
|
print(f"Title: {plan.title}")
|
||||||
print(f"Prompt: {plan.lyria_prompt}\n")
|
print(f"Prompt: {plan.lyria_prompt}\n")
|
||||||
|
|||||||
@@ -15,9 +15,6 @@ class Config:
|
|||||||
deepseek_base_url: str
|
deepseek_base_url: str
|
||||||
deepseek_api_key: str
|
deepseek_api_key: str
|
||||||
deepseek_model: str
|
deepseek_model: str
|
||||||
spotify_client_id: str
|
|
||||||
spotify_client_secret: str
|
|
||||||
spotify_refresh_token: str
|
|
||||||
radio_host: str
|
radio_host: str
|
||||||
radio_port: int
|
radio_port: int
|
||||||
output_dir: Path
|
output_dir: Path
|
||||||
@@ -37,9 +34,6 @@ class Config:
|
|||||||
),
|
),
|
||||||
deepseek_api_key=os.getenv("DEEPSEEK_API_KEY", ""),
|
deepseek_api_key=os.getenv("DEEPSEEK_API_KEY", ""),
|
||||||
deepseek_model=os.getenv("DEEPSEEK_MODEL", "deepseek-chat"),
|
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_host=os.getenv("RADIO_HOST", "127.0.0.1"),
|
||||||
radio_port=int(os.getenv("RADIO_PORT", "8787")),
|
radio_port=int(os.getenv("RADIO_PORT", "8787")),
|
||||||
output_dir=output,
|
output_dir=output,
|
||||||
@@ -54,10 +48,3 @@ class Config:
|
|||||||
if not self.deepseek_api_key:
|
if not self.deepseek_api_key:
|
||||||
raise RuntimeError("DEEPSEEK_API_KEY is required for the DJ brain")
|
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
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from dataclasses import dataclass
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from ozan_radio.config import Config
|
from ozan_radio.config import Config
|
||||||
from ozan_radio.spotify import TasteProfile
|
|
||||||
from ozan_radio.settings import ListenerSettings, load_settings
|
from ozan_radio.settings import ListenerSettings, load_settings
|
||||||
from ozan_radio.taste import TasteSeeds
|
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.
|
catalog music. Every track is generated fresh by Google Lyria 3 based on your prompts.
|
||||||
|
|
||||||
Your job:
|
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.
|
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.
|
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.
|
4. Keep variety — don't repeat the same vibe twice in a row.
|
||||||
@@ -121,7 +120,6 @@ class DeepSeekDJ:
|
|||||||
|
|
||||||
async def plan_next(
|
async def plan_next(
|
||||||
self,
|
self,
|
||||||
taste: TasteProfile | None,
|
|
||||||
recent_titles: list[str],
|
recent_titles: list[str],
|
||||||
seeds: TasteSeeds | None = None,
|
seeds: TasteSeeds | None = None,
|
||||||
request: str | None = None,
|
request: str | None = None,
|
||||||
@@ -132,12 +130,8 @@ class DeepSeekDJ:
|
|||||||
"Plan the next generated track for Live Ozan Radio.",
|
"Plan the next generated track for Live Ozan Radio.",
|
||||||
f"Station taste:\n{self._taste_block()}",
|
f"Station taste:\n{self._taste_block()}",
|
||||||
]
|
]
|
||||||
if taste:
|
if seeds:
|
||||||
user_parts.append(f"Spotify taste: {taste.summary}")
|
user_parts.append(f"Taste seeds:\n{seeds.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 recent_titles:
|
if recent_titles:
|
||||||
user_parts.append(f"Already played (avoid repeating): {', '.join(recent_titles[-5:])}")
|
user_parts.append(f"Already played (avoid repeating): {', '.join(recent_titles[-5:])}")
|
||||||
if request:
|
if request:
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ from ozan_radio.config import Config
|
|||||||
from ozan_radio.dj import DeepSeekDJ
|
from ozan_radio.dj import DeepSeekDJ
|
||||||
from ozan_radio.lyria import LyriaEngine
|
from ozan_radio.lyria import LyriaEngine
|
||||||
from ozan_radio.queue import RadioQueue
|
from ozan_radio.queue import RadioQueue
|
||||||
from ozan_radio.spotify import SpotifyTaste
|
|
||||||
from ozan_radio.chat_store import ChatStore
|
from ozan_radio.chat_store import ChatStore
|
||||||
from ozan_radio.dj import TrackPlan
|
from ozan_radio.dj import TrackPlan
|
||||||
from ozan_radio.library import list_saved_songs
|
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)",
|
"message": f"Daily limit reached ({budget['max_per_day']} new songs)",
|
||||||
"budget": budget,
|
"budget": budget,
|
||||||
}
|
}
|
||||||
taste = await SpotifyTaste(cfg).fetch_taste()
|
seeds = load_taste_seeds()
|
||||||
seeds = None if taste else load_taste_seeds()
|
|
||||||
q = _get_queue()
|
q = _get_queue()
|
||||||
vibe = request or _chat.take_vibe_hint()
|
vibe = request or _chat.take_vibe_hint()
|
||||||
plan = await DeepSeekDJ(cfg).plan_next(
|
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)
|
_set_generation(busy=True, phase="composing", title=plan.title)
|
||||||
track = LyriaEngine(cfg).generate(plan)
|
track = LyriaEngine(cfg).generate(plan)
|
||||||
@@ -137,7 +135,7 @@ async def _compose_track(request: str | None = None, *, check_limit: bool = True
|
|||||||
"status": "ok",
|
"status": "ok",
|
||||||
"source": "generated",
|
"source": "generated",
|
||||||
"track": np.__dict__ if np else None,
|
"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,
|
"cost_usd": cost,
|
||||||
"budget": budget,
|
"budget": budget,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -9,11 +9,14 @@ from pathlib import Path
|
|||||||
class TasteSeeds:
|
class TasteSeeds:
|
||||||
genres: list[str]
|
genres: list[str]
|
||||||
tracks: list[dict]
|
tracks: list[dict]
|
||||||
|
artists: list[str]
|
||||||
|
playlists: list[str]
|
||||||
|
albums: list[dict]
|
||||||
summary: str
|
summary: str
|
||||||
|
|
||||||
|
|
||||||
def load_taste_seeds(repo_root: Path | None = None) -> TasteSeeds | None:
|
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]
|
root = repo_root or Path(__file__).resolve().parents[2]
|
||||||
path = root / "taste_seeds.json"
|
path = root / "taste_seeds.json"
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
@@ -26,19 +29,30 @@ def load_taste_seeds(repo_root: Path | None = None) -> TasteSeeds | None:
|
|||||||
|
|
||||||
genres = data.get("genres", [])
|
genres = data.get("genres", [])
|
||||||
tracks = data.get("tracks", [])
|
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
|
return None
|
||||||
|
|
||||||
track_hints = []
|
parts = ["Listener taste seeds (screenshot / manual profile)."]
|
||||||
for t in tracks[:5]:
|
if artists:
|
||||||
artists = ", ".join(t.get("artists", []))
|
parts.append(f"Artists: {', '.join(artists[:12])}.")
|
||||||
vibe = t.get("vibe", "")
|
if playlists:
|
||||||
track_hints.append(f"{t.get('title', '?')} ({artists}) — {vibe}")
|
parts.append(f"Playlists: {', '.join(playlists[:6])}.")
|
||||||
|
|
||||||
summary = "Seed taste profile. "
|
|
||||||
if genres:
|
if genres:
|
||||||
summary += f"Genres: {', '.join(genres[:8])}. "
|
parts.append(f"Genres: {', '.join(genres[:10])}.")
|
||||||
if track_hints:
|
for album in albums[:4]:
|
||||||
summary += "Reference vibes: " + "; ".join(track_hints)
|
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
@@ -1,22 +1,72 @@
|
|||||||
{
|
{
|
||||||
"listener": "ozan",
|
"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": [
|
"genres": [
|
||||||
"ethnic world",
|
"anadolu psychedelic rock",
|
||||||
|
"ethnic chill",
|
||||||
"dubtronica",
|
"dubtronica",
|
||||||
"world dub",
|
"world dub",
|
||||||
"west african",
|
"trip-hop",
|
||||||
"afro-folk",
|
"desert blues",
|
||||||
"desert blues"
|
"afro-dub",
|
||||||
|
"atmospheric electronic",
|
||||||
|
"global fusion jazz"
|
||||||
],
|
],
|
||||||
"tracks": [
|
"tracks": [
|
||||||
{
|
{
|
||||||
"title": "Boboyillo",
|
"title": "Boboyillo",
|
||||||
"artists": ["Baaba Maal", "Rougi"],
|
"artists": ["Baaba Maal", "Rougi"],
|
||||||
"album": "Being",
|
"album": "Being",
|
||||||
"added": "2023-07-16",
|
"vibe": "Sahel warmth, rolling desert pulse, call-and-response, spiritual but danceable"
|
||||||
"duration_sec": 245,
|
},
|
||||||
"vibe": "Sahel warmth, rolling desert pulse, call-and-response vocals, acoustic strings and hand percussion, 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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user