Files
live-radio/src/ozan_radio/dj.py
T
ozan 02ea026851 Replace Spotify API with screenshot taste workflow and example profile.
Remove spotify integration; add TASTE-FROM-SCREENSHOTS guide; ship Ozan settings.json and taste_seeds.json as Cursor examples plus new wandering dervish track.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-07 14:47:47 +01:00

152 lines
5.2 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.settings import ListenerSettings, load_settings
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 settings.json and taste_seeds.json (listener profile from screenshots or manual edits).
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. Follow settings.json taste profile when provided — it overrides generic defaults.
6. Stay in the listener's lane unless they ask for something else in chat.
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
def _taste_block(self) -> str:
settings = load_settings()
if settings:
return settings.dj_context()
return "No settings.json — freestyle eclectic world groove."
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}")
chat_system = f"{CHAT_SYSTEM}\n\nStation taste:\n{self._taste_block()}"
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,
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.",
f"Station taste:\n{self._taste_block()}",
]
if seeds:
user_parts.append(f"Taste seeds:\n{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."),
)