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
|
||||
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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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>
|
||||
<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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
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
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user