2026-06-07 14:15:42 +01:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
|
import uuid
|
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
|
|
|
|
|
|
|
from ozan_radio.config import Config
|
2026-06-07 15:20:10 +01:00
|
|
|
|
from ozan_radio.curation import curation_summary_for_dj
|
|
|
|
|
|
from ozan_radio.settings import ListenerSettings, load_lyria_settings, load_settings
|
2026-06-07 14:15:42 +01:00
|
|
|
|
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.
|
|
|
|
|
|
|
2026-06-07 15:20:10 +01:00
|
|
|
|
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.
|
|
|
|
|
|
|
2026-06-07 14:15:42 +01:00
|
|
|
|
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:
|
2026-06-07 14:47:47 +01:00
|
|
|
|
1. Read settings.json and taste_seeds.json (listener profile from screenshots or manual edits).
|
2026-06-07 14:15:42 +01:00
|
|
|
|
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.
|
2026-06-07 14:19:38 +01:00
|
|
|
|
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.
|
2026-06-07 15:20:10 +01:00
|
|
|
|
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. Gold standard track: Sahara's Saz — saz+ney+sub bass by 0:20, whispered textures OK.
|
2026-06-07 14:15:42 +01:00
|
|
|
|
|
|
|
|
|
|
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
|
2026-06-07 14:19:38 +01:00
|
|
|
|
|
|
|
|
|
|
def _taste_block(self) -> str:
|
2026-06-07 15:20:10 +01:00
|
|
|
|
parts: list[str] = []
|
2026-06-07 14:22:39 +01:00
|
|
|
|
settings = load_settings()
|
|
|
|
|
|
if settings:
|
2026-06-07 15:20:10 +01:00
|
|
|
|
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)
|
2026-06-07 14:15:42 +01:00
|
|
|
|
|
|
|
|
|
|
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}")
|
|
|
|
|
|
|
2026-06-07 14:19:38 +01:00
|
|
|
|
chat_system = f"{CHAT_SYSTEM}\n\nStation taste:\n{self._taste_block()}"
|
|
|
|
|
|
messages = [{"role": "system", "content": chat_system}]
|
2026-06-07 14:15:42 +01:00
|
|
|
|
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()
|
|
|
|
|
|
|
2026-06-07 14:19:38 +01:00
|
|
|
|
user_parts = [
|
|
|
|
|
|
"Plan the next generated track for Live Ozan Radio.",
|
|
|
|
|
|
f"Station taste:\n{self._taste_block()}",
|
|
|
|
|
|
]
|
2026-06-07 14:47:47 +01:00
|
|
|
|
if seeds:
|
|
|
|
|
|
user_parts.append(f"Taste seeds:\n{seeds.summary}")
|
2026-06-07 14:15:42 +01:00
|
|
|
|
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."),
|
|
|
|
|
|
)
|