5f90945b97
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>
176 lines
6.8 KiB
Python
176 lines
6.8 KiB
Python
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 1–2 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."),
|
||
)
|