Files
live-radio/src/ozan_radio/dj.py
T
ozan 4924db5617 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>
2026-06-07 14:15:42 +01:00

147 lines
5.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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."),
)