Add Live Ozan Radio — DeepSeek DJ, Lyria 3, and player chat.

Personal AI station that generates tracks from taste seeds or Spotify; safe to share — secrets and cache are gitignored.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-07 14:15:42 +01:00
commit 4924db5617
19 changed files with 1551 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
---
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.
---
# Live Ozan Radio
## When to use
- User says "ozan radio", "live radio", "generate a track", "what's playing"
- Operating or debugging `tinqs/live-ozan-radio`
## Prerequisites
- `GEMINI_API_KEY` and `DEEPSEEK_API_KEY` in `.env`
- Server running: `python -m ozan_radio serve` (port 8787)
## Operations
| Intent | Action |
|--------|--------|
| 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` |
| 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.
## 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.
+23
View File
@@ -0,0 +1,23 @@
# Google Lyria 3 (Gemini API) — music generation
GEMINI_API_KEY=
# DeepSeek DJ — OpenAI-compatible endpoint
# Default: Tinqs inference proxy. Override for direct DeepSeek or Cursor BYOK.
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
# Or Tinqs proxy: https://tinqs.com/api/v1/inference (use TINQS_AGENT_TOKEN as DEEPSEEK_API_KEY)
DEEPSEEK_API_KEY=
DEEPSEEK_MODEL=deepseek-chat
# Spotify taste profile (optional — DJ works without it)
SPOTIFY_CLIENT_ID=
SPOTIFY_CLIENT_SECRET=
SPOTIFY_REFRESH_TOKEN=
# Radio server
RADIO_HOST=127.0.0.1
RADIO_PORT=8787
RADIO_OUTPUT_DIR=./radio_cache
# Generation defaults
LYRIA_MODEL=lyria-3-pro-preview
# Use lyria-3-clip-preview for faster 30s segments
+22
View File
@@ -0,0 +1,22 @@
# Python
__pycache__/
*.py[cod]
.venv/
venv/
dist/
*.egg-info/
# Secrets
.env
.env.local
# Generated audio (cache — regenerate on demand)
radio_cache/
*.mp3
*.wav
# IDE / OS
.DS_Store
Thumbs.db
.idea/
.vscode/
+37
View File
@@ -0,0 +1,37 @@
# AGENTS.md — Live Ozan Radio
Public demo repo under `tinqs/live-ozan-radio`. AI agents run the station — humans listen.
## Identity
- **No catalog playback.** Spotify is taste input only. Every track is generated.
- **DeepSeek** plans mood + Lyria prompts. **Google Lyria 3** renders audio.
- Respond in English.
## Session start
1. Read `README.md`
2. Check `.env` exists (never commit secrets)
3. Skill: `.cursor/skills/ozan-radio/SKILL.md`
## Commands
```bash
python -m ozan_radio serve # radio server :8787
python -m ozan_radio generate # one track, CLI
curl -X POST http://127.0.0.1:8787/api/generate
```
## Keys
| Key | Purpose |
|-----|---------|
| `GEMINI_API_KEY` | Lyria 3 |
| `DEEPSEEK_API_KEY` | DJ brain |
| `SPOTIFY_*` | Optional taste |
## Siblings
- `tinqs-ltd/docs` — hub
- `tinqs-ltd/audio` / `music` — Ariki production audio (FMOD, stems)
- `tinqs/studio` — inference proxy for DeepSeek
+25
View File
@@ -0,0 +1,25 @@
# CLAUDE.md — Live Ozan Radio
Read `AGENTS.md` and `README.md` first.
## What this is
Ozan's personal AI radio. DeepSeek DJ + Google Lyria 3. Public repo on `tinqs/live-ozan-radio`.
## Run
```bash
pip install -e .
cp .env.example .env # GEMINI_API_KEY + DEEPSEEK_API_KEY
python -m ozan_radio serve
```
Player: `http://127.0.0.1:8787/player`
## Architecture
```
Spotify taste ──► DeepSeek DJ ──► Lyria 3 ──► radio_cache/*.mp3 ──► FastAPI stream
```
Optional: Magenta RealTime 2 on Mac for live MIDI/text steering (~200ms latency).
+103
View File
@@ -0,0 +1,103 @@
# Live Ozan Radio
Personal AI radio — **no catalog music, ever**. DeepSeek is the DJ. Google **Lyria 3** composes every track. Spotify is read-only taste input.
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.
## Stack
| Layer | Product | Role |
|-------|---------|------|
| DJ brain | DeepSeek (via Tinqs inference or BYOK) | Mood, prompts, variety |
| Music engine | Google Lyria 3 Pro / Clip | Generate MP3 tracks |
| Taste | Spotify Web API (optional) | Top artists, genres — never plays Spotify |
| Player | FastAPI + `gateway/player.html` | Stream generated queue |
| Live (optional) | Magenta RealTime 2 | Apple Silicon only — see below |
## Quick start (Forge / Windows)
```powershell
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)
python -m ozan_radio serve
# Open http://127.0.0.1:8787/player
```
One-shot track (no server):
```powershell
python -m ozan_radio generate
```
## Environment
| Variable | Required | Notes |
|----------|----------|-------|
| `GEMINI_API_KEY` | Yes | [Google AI Studio](https://aistudio.google.com/apikey) — Lyria 3 |
| `DEEPSEEK_API_KEY` | Yes | Tinqs proxy or DeepSeek direct |
| `DEEPSEEK_BASE_URL` | No | Default `https://tinqs.com/api/v1/inference` |
| `SPOTIFY_*` | No | Refresh token flow — taste only |
| `LYRIA_MODEL` | No | `lyria-3-pro-preview` (default) or `lyria-3-clip-preview` |
### Spotify setup (taste profile)
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`.
The Spotify MCP / tool you wired to DeepSeek can call the same endpoints — this repo exposes them natively for the DJ loop.
## API
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/now` | Current track metadata |
| GET | `/api/queue` | Full queue |
| POST | `/api/generate` | DJ plans + Lyria renders next track |
| POST | `/api/skip` | Advance queue |
| GET | `/stream/{file}` | MP3 stream |
| GET | `/player` | Web UI |
## Magenta RealTime 2 (optional live layer)
On **Apple Silicon** (Kraken), install [magenta-rt](https://github.com/magenta/magenta-realtime) for sub-second live generation:
```bash
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.
## Publish on tinqs.com (public repo)
1. On Git Studio: **+ → New Repository**
- Owner: `tinqs`
- Name: `live-ozan-radio`
- Visibility: **Public**
2. Push:
```bash
git init
git remote add origin git@ssh.tinqs.com:tinqs/live-ozan-radio.git
git add .
git commit -m "Live Ozan Radio — DeepSeek DJ + Lyria 3"
git push -u origin main
```
3. Preview the player: `https://tinqs.com/tinqs/live-ozan-radio/src/branch/main/gateway/player.html` (static shell; 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.
+392
View File
@@ -0,0 +1,392 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Live Ozan Radio</title>
<style>
:root {
--bg: #0a0a0f;
--panel: #14141f;
--accent: #ff6b35;
--accent2: #7b5cff;
--text: #f0f0f5;
--muted: #8888a0;
--glow: rgba(255, 107, 53, 0.35);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: "Segoe UI", system-ui, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-image:
radial-gradient(ellipse 80% 50% at 50% -10%, var(--glow), transparent),
radial-gradient(ellipse 60% 40% at 100% 100%, rgba(123,92,255,0.15), transparent);
}
.radio {
width: min(440px, 92vw);
background: var(--panel);
border: 1px solid #2a2a3a;
border-radius: 20px;
padding: 2rem;
box-shadow: 0 24px 80px rgba(0,0,0,0.5);
}
.badge {
display: inline-block;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
border: 1px solid var(--accent);
border-radius: 4px;
padding: 0.2rem 0.5rem;
margin-bottom: 0.75rem;
}
h1 {
font-size: 1.6rem;
font-weight: 700;
margin-bottom: 0.25rem;
}
.tagline {
color: var(--muted);
font-size: 0.85rem;
margin-bottom: 1.5rem;
line-height: 1.4;
}
.viz {
height: 64px;
display: flex;
align-items: flex-end;
justify-content: center;
gap: 4px;
margin-bottom: 1.25rem;
}
.bar {
width: 6px;
background: linear-gradient(to top, var(--accent), var(--accent2));
border-radius: 3px;
animation: bounce 0.8s ease-in-out infinite alternate;
}
.bar:nth-child(1) { height: 20px; animation-delay: 0s; }
.bar:nth-child(2) { height: 36px; animation-delay: 0.1s; }
.bar:nth-child(3) { height: 52px; animation-delay: 0.2s; }
.bar:nth-child(4) { height: 40px; animation-delay: 0.15s; }
.bar:nth-child(5) { height: 28px; animation-delay: 0.05s; }
.bar:nth-child(6) { height: 44px; animation-delay: 0.25s; }
.bar:nth-child(7) { height: 32px; animation-delay: 0.12s; }
@keyframes bounce {
from { transform: scaleY(0.4); opacity: 0.6; }
to { transform: scaleY(1); opacity: 1; }
}
.now-title {
font-size: 1.15rem;
font-weight: 600;
margin-bottom: 0.35rem;
}
.now-mood, .dj-line {
font-size: 0.85rem;
color: var(--muted);
margin-bottom: 0.5rem;
}
.dj-line { font-style: italic; color: #b0b0c8; }
audio {
width: 100%;
margin: 1rem 0;
border-radius: 8px;
}
.controls {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
button {
flex: 1;
min-width: 100px;
padding: 0.65rem 1rem;
border: none;
border-radius: 10px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: transform 0.15s, opacity 0.15s;
}
button:hover { transform: translateY(-1px); }
button:active { transform: translateY(0); }
button:disabled { opacity: 0.45; cursor: not-allowed; transform: none; }
.primary {
background: linear-gradient(135deg, var(--accent), #ff8f65);
color: #fff;
}
.secondary {
background: #2a2a3a;
color: var(--text);
border: 1px solid #3a3a50;
}
.status {
margin-top: 1rem;
font-size: 0.75rem;
color: var(--muted);
text-align: center;
}
.stack {
font-size: 0.7rem;
color: #555568;
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px solid #2a2a3a;
line-height: 1.6;
}
.chat {
margin-top: 1.25rem;
border-top: 1px solid #2a2a3a;
padding-top: 1rem;
}
.chat h2 {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
margin-bottom: 0.65rem;
}
.chat-log {
height: 160px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 0.65rem;
padding-right: 0.25rem;
}
.chat-log::-webkit-scrollbar { width: 4px; }
.chat-log::-webkit-scrollbar-thumb { background: #3a3a50; border-radius: 2px; }
.bubble {
max-width: 88%;
padding: 0.55rem 0.75rem;
border-radius: 12px;
font-size: 0.82rem;
line-height: 1.4;
}
.bubble.user {
align-self: flex-end;
background: #2a2a3a;
color: var(--text);
border-bottom-right-radius: 4px;
}
.bubble.dj {
align-self: flex-start;
background: linear-gradient(135deg, rgba(255,107,53,0.15), rgba(123,92,255,0.12));
border: 1px solid #3a3a50;
border-bottom-left-radius: 4px;
}
.chat-form {
display: flex;
gap: 0.5rem;
}
.chat-form input {
flex: 1;
background: #1a1a28;
border: 1px solid #3a3a50;
border-radius: 10px;
padding: 0.6rem 0.75rem;
color: var(--text);
font-size: 0.85rem;
}
.chat-form input:focus {
outline: none;
border-color: var(--accent);
}
.chat-form button {
flex: 0 0 auto;
min-width: 72px;
padding: 0.6rem 1rem;
}
</style>
</head>
<body>
<div class="radio">
<div class="badge">● Live</div>
<h1>Ozan Radio</h1>
<p class="tagline">No Spotify playback. No catalog tracks. DeepSeek picks the vibe — Google Lyria 3 composes it fresh.</p>
<div class="viz" id="viz">
<div class="bar"></div><div class="bar"></div><div class="bar"></div>
<div class="bar"></div><div class="bar"></div><div class="bar"></div><div class="bar"></div>
</div>
<div class="now-title" id="title">Waiting for first track…</div>
<div class="now-mood" id="mood"></div>
<div class="dj-line" id="dj"></div>
<audio id="player" controls></audio>
<div class="controls">
<button class="primary" id="genBtn">Generate next</button>
<button class="secondary" id="skipBtn">Skip</button>
<button class="secondary" id="refreshBtn">Refresh</button>
</div>
<div class="status" id="status">Connecting…</div>
<section class="chat">
<h2>Talk to the DJ</h2>
<div class="chat-log" id="chatLog"></div>
<form class="chat-form" id="chatForm">
<input id="chatInput" type="text" placeholder="Ask for a vibe… e.g. more Sahel, slower, surprise me" maxlength="500" autocomplete="off">
<button type="submit" class="primary" id="chatSend">Send</button>
</form>
</section>
<div class="stack">
DJ: DeepSeek · Music: Google Lyria 3 · Taste: Spotify (read-only)<br>
Optional live layer: Magenta RealTime 2 on Apple Silicon
</div>
</div>
<script>
const API = window.location.origin;
const player = document.getElementById('player');
const titleEl = document.getElementById('title');
const moodEl = document.getElementById('mood');
const djEl = document.getElementById('dj');
const statusEl = document.getElementById('status');
const genBtn = document.getElementById('genBtn');
const skipBtn = document.getElementById('skipBtn');
const refreshBtn = document.getElementById('refreshBtn');
const chatLog = document.getElementById('chatLog');
const chatForm = document.getElementById('chatForm');
const chatInput = document.getElementById('chatInput');
const chatSend = document.getElementById('chatSend');
function addBubble(role, text) {
const el = document.createElement('div');
el.className = `bubble ${role}`;
el.textContent = text;
chatLog.appendChild(el);
chatLog.scrollTop = chatLog.scrollHeight;
}
async function loadChat() {
try {
const res = await fetch(`${API}/api/chat`);
const data = await res.json();
chatLog.innerHTML = '';
for (const m of data.messages || []) {
addBubble(m.role, m.content);
}
} catch (_) {}
}
async function sendChat(text) {
chatSend.disabled = true;
chatInput.disabled = true;
addBubble('user', text);
try {
const res = await fetch(`${API}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: text }),
});
const data = await res.json();
if (data.reply) addBubble('dj', data.reply);
if (data.generating) {
statusEl.textContent = 'DJ is composing your request…';
pollForNewTrack();
}
} catch (_) {
addBubble('dj', 'Signal lost — try again.');
} finally {
chatSend.disabled = false;
chatInput.disabled = false;
chatInput.focus();
}
}
let pollTimer = null;
function pollForNewTrack() {
let n = 0;
clearInterval(pollTimer);
pollTimer = setInterval(async () => {
n += 1;
await refreshNow();
if (n > 60) clearInterval(pollTimer);
}, 5000);
}
async function refreshNow() {
try {
const res = await fetch(`${API}/api/now`);
const data = await res.json();
if (data.status === 'idle') {
titleEl.textContent = 'Queue empty';
moodEl.textContent = '';
djEl.textContent = 'Hit Generate to compose the first track.';
statusEl.textContent = data.message || 'Idle';
return;
}
const t = data.track;
titleEl.textContent = t.title;
moodEl.textContent = t.mood ? `Mood: ${t.mood}` : '';
djEl.textContent = t.dj_line ? `"${t.dj_line}"` : '';
if (t.audio_url && player.src !== `${API}${t.audio_url}`) {
player.src = `${API}${t.audio_url}`;
player.play().catch(() => {});
}
statusEl.textContent = `Playing · ${t.track_id}`;
} catch (e) {
statusEl.textContent = 'Server offline — start: python -m ozan_radio';
}
}
async function generate() {
genBtn.disabled = true;
statusEl.textContent = 'DeepSeek is planning… Lyria is composing…';
try {
const res = await fetch(`${API}/api/generate`, { method: 'POST' });
const data = await res.json();
if (data.status === 'busy') {
statusEl.textContent = data.message;
} else if (data.status === 'ok') {
await refreshNow();
} else {
statusEl.textContent = 'Generation failed — check server logs';
}
} catch (e) {
statusEl.textContent = 'Request failed';
} finally {
genBtn.disabled = false;
}
}
async function skip() {
skipBtn.disabled = true;
try {
await fetch(`${API}/api/skip`, { method: 'POST' });
await refreshNow();
} finally {
skipBtn.disabled = false;
}
}
genBtn.addEventListener('click', generate);
skipBtn.addEventListener('click', skip);
refreshBtn.addEventListener('click', refreshNow);
player.addEventListener('ended', () => { skip(); });
chatForm.addEventListener('submit', (e) => {
e.preventDefault();
const text = chatInput.value.trim();
if (!text) return;
chatInput.value = '';
sendChat(text);
});
refreshNow();
loadChat();
setInterval(refreshNow, 15000);
</script>
</body>
</html>
After
+35
View File
@@ -0,0 +1,35 @@
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["src"]
[project]
name = "live-ozan-radio"
version = "0.1.0"
description = "AI radio — DeepSeek DJ + Google Lyria 3, tuned to your Spotify taste"
requires-python = ">=3.11"
dependencies = [
"google-genai>=1.0",
"httpx>=0.27",
"python-dotenv>=1.0",
"uvicorn>=0.30",
"fastapi>=0.115",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"ruff>=0.4",
]
[project.scripts]
ozan-radio = "ozan_radio.__main__:main"
[tool.ruff]
target-version = "py311"
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "B", "SIM"]
+3
View File
@@ -0,0 +1,3 @@
"""Live Ozan Radio — AI DJ powered by DeepSeek + Google Lyria 3."""
__version__ = "0.1.0"
+61
View File
@@ -0,0 +1,61 @@
from __future__ import annotations
import argparse
import asyncio
import uvicorn
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.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:
print(f"Taste seeds: {seeds.summary}\n")
else:
print("No Spotify or seeds — DJ will freestyle.\n")
q = RadioQueue(cfg.output_dir)
plan = await DeepSeekDJ(cfg).plan_next(taste, q.recent_titles, seeds)
print(f"DJ: {plan.dj_line}")
print(f"Title: {plan.title}")
print(f"Prompt: {plan.lyria_prompt}\n")
print("Generating with Lyria 3…")
track = LyriaEngine(cfg).generate(plan)
q.add(track)
print(f"Saved: {track.audio_path}")
def main() -> None:
parser = argparse.ArgumentParser(description="Live Ozan Radio")
parser.add_argument(
"command",
nargs="?",
default="serve",
choices=["serve", "generate"],
help="serve = start radio server, generate = one-shot track",
)
args = parser.parse_args()
if args.command == "generate":
asyncio.run(generate_one())
return
cfg = Config.from_env()
uvicorn.run(app, host=cfg.radio_host, port=cfg.radio_port, log_level="info")
if __name__ == "__main__":
main()
+40
View File
@@ -0,0 +1,40 @@
from __future__ import annotations
from dataclasses import dataclass, field
from time import time
@dataclass
class ChatTurn:
role: str
content: str
ts: float = field(default_factory=time)
class ChatStore:
"""In-memory chat history for the DJ panel."""
def __init__(self, max_turns: int = 40) -> None:
self._turns: list[ChatTurn] = []
self._max = max_turns
self.pending_vibe: str = ""
def add(self, role: str, content: str) -> None:
self._turns.append(ChatTurn(role=role, content=content))
if len(self._turns) > self._max:
self._turns = self._turns[-self._max :]
def history(self) -> list[dict]:
return [{"role": t.role, "content": t.content} for t in self._turns]
def public_log(self) -> list[dict]:
return [
{"role": t.role, "content": t.content, "ts": t.ts}
for t in self._turns
if t.role in ("user", "dj")
]
def take_vibe_hint(self) -> str:
hint = self.pending_vibe.strip()
self.pending_vibe = ""
return hint
+60
View File
@@ -0,0 +1,60 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
@dataclass(frozen=True)
class Config:
gemini_api_key: str
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
lyria_model: str
@classmethod
def from_env(cls) -> Config:
output = Path(os.getenv("RADIO_OUTPUT_DIR", "./radio_cache"))
output.mkdir(parents=True, exist_ok=True)
return cls(
gemini_api_key=os.getenv("GEMINI_API_KEY", ""),
deepseek_base_url=os.getenv(
"DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1"
),
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,
lyria_model=os.getenv("LYRIA_MODEL", "lyria-3-pro-preview"),
)
def require_gemini(self) -> None:
if not self.gemini_api_key:
raise RuntimeError("GEMINI_API_KEY is required for Lyria music generation")
def require_deepseek(self) -> None:
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
)
+146
View File
@@ -0,0 +1,146 @@
from __future__ import annotations
import json
import uuid
from dataclasses import dataclass
import httpx
from ozan_radio.config import Config
from ozan_radio.spotify import TasteProfile
from ozan_radio.taste import TasteSeeds
CHAT_SYSTEM = """You are the on-air DJ for Live Ozan Radio. Chat with the listener in a warm,
concise radio-host voice. You never play catalog music — only AI-generated tracks via Lyria 3.
When the listener asks for a vibe, mood, genre, or "play something X", set action to "generate"
and include a vibe_hint Lyria can use. Otherwise action is "none".
Respond with JSON only:
{
"reply": "your on-air response (1-3 sentences)",
"action": "none" or "generate",
"vibe_hint": "optional — detailed direction for the next composed track"
}
"""
DJ_SYSTEM = """You are the DJ for Live Ozan Radio — a personal AI station that never plays
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).
2. Pick a mood, tempo, and genre blend that feels like a natural next track.
3. Write a Lyria prompt that produces a 12 minute instrumental or vocal track.
4. Keep variety — don't repeat the same vibe twice in a row.
5. Favor warm, groove-forward, slightly eclectic picks (Ozan's lane).
6. West African / Sahel / desert blues / griot energy is on-brand (think Baaba Maal warmth).
Respond with JSON only:
{
"title": "short track title",
"mood": "one-line mood",
"dj_line": "what you'd say on air (1 sentence)",
"lyria_prompt": "detailed prompt for Lyria 3 Pro — structure, instruments, tempo, energy"
}
"""
@dataclass
class TrackPlan:
id: str
title: str
mood: str
dj_line: str
lyria_prompt: str
@dataclass
class ChatReply:
reply: str
action: str
vibe_hint: str
class DeepSeekDJ:
"""DeepSeek orchestrates what plays next — taste in, Lyria prompt out."""
def __init__(self, config: Config) -> None:
self._config = config
async def _completion(self, messages: list[dict], *, json_mode: bool = False) -> str:
self._config.require_deepseek()
payload: dict = {
"model": self._config.deepseek_model,
"messages": messages,
"temperature": 0.85,
}
if json_mode:
payload["response_format"] = {"type": "json_object"}
url = f"{self._config.deepseek_base_url.rstrip('/')}/chat/completions"
async with httpx.AsyncClient(timeout=120) as client:
resp = await client.post(
url,
headers={"Authorization": f"Bearer {self._config.deepseek_api_key}"},
json=payload,
)
resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"]
async def chat(
self,
message: str,
history: list[dict],
*,
now_playing: str | None = None,
) -> ChatReply:
context = [f"Listener says: {message}"]
if now_playing:
context.append(f"Currently playing: {now_playing}")
messages = [{"role": "system", "content": CHAT_SYSTEM}]
for turn in history[-8:]:
role = "assistant" if turn["role"] == "dj" else turn["role"]
messages.append({"role": role, "content": turn["content"]})
messages.append({"role": "user", "content": "\n".join(context)})
data = json.loads(await self._completion(messages, json_mode=True))
return ChatReply(
reply=data.get("reply", "Copy that — stay tuned."),
action=data.get("action", "none"),
vibe_hint=data.get("vibe_hint", ""),
)
async def plan_next(
self,
taste: TasteProfile | None,
recent_titles: list[str],
seeds: TasteSeeds | None = None,
request: str | None = None,
) -> TrackPlan:
self._config.require_deepseek()
user_parts = ["Plan the next generated track for Live Ozan Radio."]
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 recent_titles:
user_parts.append(f"Already played (avoid repeating): {', '.join(recent_titles[-5:])}")
if request:
user_parts.append(f"Listener request: {request}")
messages = [
{"role": "system", "content": DJ_SYSTEM},
{"role": "user", "content": "\n".join(user_parts)},
]
data = json.loads(await self._completion(messages, json_mode=True))
return TrackPlan(
id=str(uuid.uuid4())[:8],
title=data.get("title", "Untitled Transmission"),
mood=data.get("mood", ""),
dj_line=data.get("dj_line", "Coming up — something new, just for you."),
lyria_prompt=data.get("lyria_prompt", "An upbeat instrumental groove."),
)
+60
View File
@@ -0,0 +1,60 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from google import genai
from google.genai import types
from ozan_radio.config import Config
from ozan_radio.dj import TrackPlan
@dataclass
class GeneratedTrack:
plan: TrackPlan
audio_path: Path
lyrics: str
class LyriaEngine:
"""Google Lyria 3 — generates tracks from DJ prompts."""
def __init__(self, config: Config) -> None:
self._config = config
config.require_gemini()
self._client = genai.Client(api_key=config.gemini_api_key)
def generate(self, plan: TrackPlan) -> GeneratedTrack:
prompt = (
f"{plan.lyria_prompt}\n\n"
"Instrumental preferred unless the prompt explicitly asks for vocals. "
"High fidelity, stereo, radio-ready mix."
)
response = self._client.models.generate_content(
model=self._config.lyria_model,
contents=prompt,
config=types.GenerateContentConfig(
response_modalities=["AUDIO", "TEXT"],
),
)
lyrics = ""
audio_bytes: bytes | None = None
for part in response.parts:
if part.text:
lyrics += part.text + "\n"
elif part.inline_data and part.inline_data.data:
audio_bytes = part.inline_data.data
if not audio_bytes:
raise RuntimeError(f"Lyria returned no audio for track {plan.id}")
ext = "mp3"
safe_title = "".join(c if c.isalnum() or c in "-_" else "_" for c in plan.title)[:40]
out_path = self._config.output_dir / f"{plan.id}_{safe_title}.{ext}"
out_path.write_bytes(audio_bytes)
return GeneratedTrack(plan=plan, audio_path=out_path, lyrics=lyrics.strip())
+149
View File
@@ -0,0 +1,149 @@
from __future__ import annotations
import json
from dataclasses import asdict, dataclass
from pathlib import Path
from ozan_radio.dj import TrackPlan
from ozan_radio.lyria import GeneratedTrack
@dataclass
class NowPlaying:
track_id: str
title: str
mood: str
dj_line: str
audio_url: str
lyrics: str
class RadioQueue:
"""Simple in-memory + disk queue for generated tracks."""
def __init__(self, output_dir: Path) -> None:
self._tracks: list[GeneratedTrack] = []
self._index = 0
self._manifest = output_dir / "manifest.json"
self._output_dir = output_dir
self._load_manifest()
def _load_manifest(self) -> None:
if self._manifest.exists():
try:
data = json.loads(self._manifest.read_text(encoding="utf-8"))
self._index = data.get("index", 0)
for entry in data.get("tracks", []):
path = self._output_dir / entry["file"]
if not path.exists():
continue
plan = TrackPlan(
id=entry.get("id", "????"),
title=entry.get("title", path.stem),
mood=entry.get("mood", ""),
dj_line=entry.get("dj_line", ""),
lyria_prompt=entry.get("lyria_prompt", ""),
)
self._tracks.append(
GeneratedTrack(plan=plan, audio_path=path, lyrics=entry.get("lyrics", ""))
)
if self._tracks:
return
except (json.JSONDecodeError, OSError, KeyError):
self._tracks = []
self._index = 0
self._restore_from_filenames()
def _restore_from_filenames(self) -> None:
for path in sorted(self._output_dir.glob("*.mp3")):
stem = path.stem
if "_" in stem:
track_id, title = stem.split("_", 1)
title = title.replace("_", " ")
else:
track_id, title = stem[:8], stem
plan = TrackPlan(
id=track_id,
title=title,
mood="",
dj_line="Restored from cache.",
lyria_prompt="",
)
self._tracks.append(GeneratedTrack(plan=plan, audio_path=path, lyrics=""))
def _save_manifest(self) -> None:
payload = {
"index": self._index,
"count": len(self._tracks),
"tracks": [
{
"id": t.plan.id,
"title": t.plan.title,
"mood": t.plan.mood,
"dj_line": t.plan.dj_line,
"lyria_prompt": t.plan.lyria_prompt,
"lyrics": t.lyrics,
"file": t.audio_path.name,
}
for t in self._tracks
],
}
self._manifest.write_text(json.dumps(payload, indent=2), encoding="utf-8")
@property
def length(self) -> int:
return len(self._tracks)
def add(self, track: GeneratedTrack) -> None:
self._tracks.append(track)
self._save_manifest()
@property
def recent_titles(self) -> list[str]:
return [t.plan.title for t in self._tracks]
def current(self) -> GeneratedTrack | None:
if not self._tracks:
return None
if self._index >= len(self._tracks):
self._index = 0
return self._tracks[self._index]
def advance(self) -> GeneratedTrack | None:
if not self._tracks:
return None
self._index = (self._index + 1) % len(self._tracks)
self._save_manifest()
return self.current()
def now_playing(self, base_url: str = "") -> NowPlaying | None:
track = self.current()
if not track:
return None
rel = track.audio_path.name
return NowPlaying(
track_id=track.plan.id,
title=track.plan.title,
mood=track.plan.mood,
dj_line=track.plan.dj_line,
audio_url=f"{base_url}/stream/{rel}",
lyrics=track.lyrics,
)
def to_dict(self) -> dict:
current = self.current()
return {
"index": self._index,
"queue_length": len(self._tracks),
"tracks": [
{
"id": t.plan.id,
"title": t.plan.title,
"mood": t.plan.mood,
"file": t.audio_path.name,
}
for t in self._tracks
],
"current": asdict(current.plan) if current else None,
}
+188
View File
@@ -0,0 +1,188 @@
from __future__ import annotations
import asyncio
from pathlib import Path
from fastapi import BackgroundTasks, FastAPI, HTTPException
from pydantic import BaseModel, Field
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse
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.taste import load_taste_seeds
app = FastAPI(title="Live Ozan Radio", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
_config: Config | None = None
_queue: RadioQueue | None = None
_generating = False
_chat = ChatStore()
class ChatRequest(BaseModel):
message: str = Field(min_length=1, max_length=500)
async def _compose_track(request: str | None = None) -> dict:
global _generating
if _generating:
return {"status": "busy", "message": "Already generating a track"}
_generating = True
try:
cfg = _get_config()
taste = await SpotifyTaste(cfg).fetch_taste()
seeds = None if taste else 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
)
track = LyriaEngine(cfg).generate(plan)
q.add(track)
np = q.now_playing()
return {
"status": "ok",
"track": np.__dict__ if np else None,
"taste_used": taste.summary if taste else (seeds.summary if seeds else None),
}
finally:
_generating = False
def _get_config() -> Config:
global _config
if _config is None:
_config = Config.from_env()
return _config
def _get_queue() -> RadioQueue:
global _queue
if _queue is None:
_queue = RadioQueue(_get_config().output_dir)
return _queue
@app.get("/")
async def root() -> dict:
return {
"station": "Live Ozan Radio",
"tagline": "No catalog. No repeats. DeepSeek DJ + Google Lyria 3.",
"endpoints": {
"now": "/api/now",
"queue": "/api/queue",
"generate": "POST /api/generate",
"chat": "POST /api/chat",
"chat_log": "/api/chat",
"player": "/player",
},
}
@app.get("/api/now")
async def now_playing() -> dict:
q = _get_queue()
np = q.now_playing()
if not np:
return {"status": "idle", "message": "Queue empty — POST /api/generate to start"}
return {"status": "playing", "track": np.__dict__}
@app.get("/api/queue")
async def queue_status() -> dict:
return _get_queue().to_dict()
@app.get("/stream/{filename}")
async def stream_track(filename: str) -> FileResponse:
cfg = _get_config()
path = (cfg.output_dir / filename).resolve()
if not str(path).startswith(str(cfg.output_dir.resolve())):
raise HTTPException(403, "Invalid path")
if not path.exists():
raise HTTPException(404, "Track not found")
return FileResponse(path, media_type="audio/mpeg")
@app.post("/api/generate")
async def generate_track() -> dict:
return await _compose_track()
@app.get("/api/chat")
async def chat_log() -> dict:
return {"messages": _chat.public_log()}
@app.post("/api/chat")
async def chat_with_dj(body: ChatRequest, background: BackgroundTasks) -> dict:
cfg = _get_config()
q = _get_queue()
np = q.now_playing()
now = f"{np.title}{np.mood}" if np else None
reply = await DeepSeekDJ(cfg).chat(body.message, _chat.history(), now_playing=now)
_chat.add("user", body.message)
_chat.add("dj", reply.reply)
result = {
"status": "ok",
"reply": reply.reply,
"action": reply.action,
"generating": False,
}
if reply.action == "generate":
if reply.vibe_hint:
_chat.pending_vibe = reply.vibe_hint
if not _generating:
result["generating"] = True
background.add_task(_compose_track, reply.vibe_hint or None)
return result
@app.post("/api/skip")
async def skip_track() -> dict:
q = _get_queue()
track = q.advance()
if not track:
return {"status": "idle"}
np = q.now_playing()
return {"status": "ok", "track": np.__dict__ if np else None}
@app.get("/player", response_class=HTMLResponse)
async def player_page() -> HTMLResponse:
gateway = Path(__file__).resolve().parents[2] / "gateway" / "player.html"
if gateway.exists():
return HTMLResponse(gateway.read_text(encoding="utf-8"))
return HTMLResponse("<h1>Live Ozan Radio</h1><p>gateway/player.html missing</p>")
async def _autofill_queue(target: int = 2) -> None:
"""Background: keep a small buffer of generated tracks."""
while True:
q = _get_queue()
if q.length < target and not _generating:
try:
await generate_track()
except Exception:
pass
await asyncio.sleep(30)
def create_app() -> FastAPI:
return app
+102
View File
@@ -0,0 +1,102 @@
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,
)
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
@dataclass
class TasteSeeds:
genres: list[str]
tracks: 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."""
root = repo_root or Path(__file__).resolve().parents[2]
path = root / "taste_seeds.json"
if not path.exists():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return None
genres = data.get("genres", [])
tracks = data.get("tracks", [])
if not genres and not tracks:
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. "
if genres:
summary += f"Genres: {', '.join(genres[:8])}. "
if track_hints:
summary += "Reference vibes: " + "; ".join(track_hints)
return TasteSeeds(genres=genres, tracks=tracks, summary=summary.strip())
+22
View File
@@ -0,0 +1,22 @@
{
"listener": "ozan",
"notes": "Manual taste seeds when Spotify API is not wired. Add tracks from your library — DJ reads vibe, never replays catalog.",
"genres": [
"west african",
"afro-folk",
"desert blues",
"griot",
"world",
"eclectic groove"
],
"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"
}
]
}