Robust candidate-part parsing, quota-aware errors, live composing feedback, and two new desert dub tracks in the library. Co-authored-by: Cursor <cursoragent@cursor.com>
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).
Inspired by Magenta RealTime 2 (live, ~200ms) and Lyria 3 (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.
Stack
| Layer | Product | Role |
|---|---|---|
| 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 |
| Player | FastAPI + gateway/player.html |
Stream queue, library, chat, dashboard |
| Live (optional) | Magenta RealTime 2 | Apple Silicon only — see below |
Player dashboard
Open http://127.0.0.1:8787/player after starting the server.
| Feature | What it does |
|---|---|
| Cost dashboard | Today's estimated spend, per-track cost, songs generated vs daily cap |
| Settings (gear icon) | Shuffle mode, mix saved + new tracks, new-song chance, max songs/day |
| Shuffle mode | On track end or Skip, picks a random saved song or composes a new one |
| Saved songs | Click any track in the library to play |
| DJ chat | Talk to DeepSeek — requests can trigger new Lyria generations |
Shuffle behaviour
When shuffle is on (default):
- If you have saved songs and daily quota remains, each next track has a
new_song_chance(default 35%) of being freshly composed. - Otherwise a random saved song plays (never the same track twice in a row if alternatives exist).
- If the library is empty, it generates until the daily cap is hit.
Daily generation stats live in songs/stats.json (gitignored, local runtime only).
Taste (settings.json)
Edit settings.json at the repo root. The DJ reloads it on every generate and chat.
{
"taste": { "summary": "...", "genres": [], "mood": [], "instruments": [] },
"playback": {
"shuffle": true,
"mix_existing_and_new": true,
"new_song_chance": 0.35
},
"limits": { "max_new_songs_per_day": 10 },
"costs": {
"lyria_pro_usd": 0.08,
"lyria_clip_usd": 0.04,
"deepseek_per_track_usd": 0.002
}
}
Default taste profile: ethnic world dubtronica (global roots + dub space + electronic groove).
The player settings panel PATCHes playback and limits via /api/settings and writes back to this file.
Saved songs
Every track is written to ./songs/ and committed via Git LFS (audio) + plain git (metadata):
| File | Storage | Contents |
|---|---|---|
{id}_{title}.mp3 |
LFS | Audio |
{id}.meta.json |
git | Title, mood, DJ line, prompt, lyrics, timestamp |
manifest.json |
gitignored | Runtime queue index |
Browse in the player under Saved songs, or GET /api/songs. After clone: git lfs install then git lfs pull.
Quick start (Forge / Windows)
cd live-ozan-radio
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)
$env:DEEPSEEK_BASE_URL = "https://api.deepseek.com/v1"
python -m ozan_radio serve
# Open http://127.0.0.1:8787/player
One-shot track (no server):
python -m ozan_radio generate
Environment
| Variable | Required | Notes |
|---|---|---|
GEMINI_API_KEY |
Yes | Google AI Studio — 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 |
Cost estimates (defaults)
| Model | ~USD / track |
|---|---|
| Lyria 3 Pro + DeepSeek | ~$0.082 |
| Lyria 3 Clip | ~$0.04 |
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)
- Create an app at Spotify Developer Dashboard.
- Add redirect URI
http://127.0.0.1:8888/callback. - Complete OAuth once to obtain a refresh token (scope:
user-top-read). - Paste
SPOTIFY_CLIENT_ID,SPOTIFY_CLIENT_SECRET,SPOTIFY_REFRESH_TOKENinto.env.
If Spotify is not configured, the DJ uses settings.json + taste_seeds.json.
API
| Method | Path | Description |
|---|---|---|
| GET | /api/now |
Current track metadata |
| GET | /api/queue |
Full queue |
| GET | /api/stats |
Dashboard: today's spend, quota, cost estimates |
| GET | /api/settings |
Playback, limits, costs |
| PATCH | /api/settings |
Update shuffle, daily cap, etc. |
| POST | /api/generate |
DJ plans + Lyria renders next track |
| POST | /api/shuffle/next |
Smart next: library shuffle or new generation |
| POST | /api/skip |
Advance (uses shuffle when enabled) |
| GET/POST | /api/chat |
DJ chat log and messages |
| GET | /api/songs |
Saved library |
| POST | /api/songs/{id}/play |
Play a saved track |
| GET | /stream/{file} |
MP3 stream |
| GET | /player |
Web UI |
Magenta RealTime 2 (optional live layer)
On Apple Silicon (Kraken), install magenta-rt for sub-second live generation:
uv pip install "magenta-rt[mlx]"
mrt models init && mrt models download
mrt mlx generate --prompt "disco funk" --duration 4.0 --model=mrt2_small
Wire MRT2 as a bridge between tracks or as a live “bed” under the Lyria queue — PRs welcome.
Repo
Public on Git Studio: https://tinqs.com/tinqs/live-radio
git clone git@ssh.tinqs.com:tinqs/live-radio.git
cd live-radio
git lfs install && git lfs pull
Static player preview: https://tinqs.com/tinqs/live-radio/src/branch/main/gateway/player.html (shell only — audio streams from your running server).
Agent usage
DeepSeek (Pi, Cursor, Claude Code) can operate the station via HTTP or the skill in .cursor/skills/ozan-radio/SKILL.md.
License
Apache 2.0 — same spirit as Magenta RealTime 2 open weights.