Files
live-radio/src/ozan_radio/dj.py
T
ozan 5f90945b97 Define techno-ethnic taste lane and notify when generation is ready.
Bonobo, Jamaica dub, Sahara, Mongolia overtone, and Urdu colour in settings and DJ prompts. Generate runs in background with polling, ready toast, optional browser notification, and autoplay of the new track.

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

176 lines
6.8 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.curation import curation_summary_for_dj
from ozan_radio.settings import ListenerSettings, load_lyria_settings, 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.
Lyria cannot remix or edit an existing MP3. If the listener asks to "add vocals to this song"
or change a saved track, explain you're composing a NEW successor track in that spirit — not
overlaying on the file they're hearing.
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.
7. If the request references a saved track ("more like Sahara's Saz", "add vocals to X"),
compose a fresh successor — same palette, new title. Never imply remixing an MP3.
8. For vocals Lyria accepts well: "wordless vocal textures", "Sahel blues male chants",
"melodic call-and-response". Avoid "vocal sample" / "griot sample" phrasing.
9. Read listener curation metadata — clone what they loved, hard-avoid what they disliked
(e.g. fuzz electric guitar on vocal tracks, long guitar-only intros before saz).
10. Station mission: TECHNO-ETHNIC — beautiful electronica meets world dub. Big
inspirations: Bonobo (lush melodic downtempo), Jon Hopkins Singularity, Jamaica
dub (spring reverb, bass culture), Sahara/Sahel warmth, Mongolian overtone throat
textures (wordless ethereal layer), Urdu poetic vocal colour. Blend many cultures
in one gorgeous track — Jamaica + Sahara + Mongolia + Urdu + synth beauty.
11. NOT Turkish folk, NOT slow country homages, NOT Indian pop. Clone keepers:
ceremonial-dub (Chac's Dub), gregorian-ether (Frostbite Dub). Forward tempo 88+ BPM.
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:
parts: list[str] = []
settings = load_settings()
if settings:
parts.append(settings.dj_context())
else:
parts.append("No settings.json — freestyle eclectic world groove.")
parts.append(load_lyria_settings().dj_context())
cfg = Config.from_env()
parts.append(curation_summary_for_dj(cfg.output_dir))
return "\n".join(parts)
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."),
)