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:
@@ -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.
|
||||
@@ -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
@@ -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/
|
||||
@@ -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
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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
|
@@ -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"]
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Live Ozan Radio — AI DJ powered by DeepSeek + Google Lyria 3."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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 1–2 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."),
|
||||
)
|
||||
@@ -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())
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user