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