diff --git a/AGENTS.md b/AGENTS.md index 4e62dfc..6d20b59 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,10 @@ Public repo under `tinqs/live-radio`. AI agents run the station — humans liste - **DeepSeek** plans mood + Lyria prompts. **Google Lyria 3** renders audio. - Respond in English. +## Taste + +Edit `settings.json` — DJ + chat read it every request. Default: ethnic world dubtronica. + ## Session start 1. Read `README.md` diff --git a/README.md b/README.md index f1377b9..d7768cd 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,10 @@ Inspired by [Magenta RealTime 2](https://magenta.withgoogle.com/magenta-realtime | Player | FastAPI + `gateway/player.html` | Stream generated queue | | Live (optional) | Magenta RealTime 2 | Apple Silicon only — see below | +## Taste (`settings.json`) + +Edit `settings.json` at the repo root — the DJ reads it on every generate and chat. Default profile: **ethnic world dubtronica** (global roots + dub space + electronic groove). + ## Saved songs Every track is written to `./songs/` and **committed via Git LFS** (audio) + plain git (metadata): diff --git a/settings.json b/settings.json new file mode 100644 index 0000000..0ca3b1c --- /dev/null +++ b/settings.json @@ -0,0 +1,53 @@ +{ + "listener": "ozan", + "station": "Live Ozan Radio", + "taste": { + "summary": "Ethnic world dubtronica — global roots, dub space, and electronic groove.", + "genres": [ + "ethnic world", + "world dub", + "dubtronica", + "dub", + "global electronica", + "desert blues", + "afro-dub", + "middle eastern dub", + "balkan dub", + "trip-hop dub" + ], + "mood": [ + "hypnotic", + "warm", + "spacious", + "late-night", + "sunset caravan", + "meditative but danceable" + ], + "instruments": [ + "sub bass", + "dub delay and spring reverb", + "hand percussion", + "kora or oud", + "nose flute or melodica", + "tabla", + "djembe", + "muted guitar skank", + "analog warmth" + ], + "tempo_bpm": [82, 108], + "references": "Thievery Corporation meets Sahel dub; Baaba Maal warmth over a stepper bassline; Khruangbin haze with Lee Scratch Perry space; ethnic samples woven into dubtronica, not EDM.", + "avoid": [ + "big-room EDM drops", + "four-on-the-floor house", + "generic corporate lounge", + "overcompressed pop EDM" + ] + }, + "dj": { + "variety": true, + "default_length": "1-2 minutes" + }, + "lyria": { + "prefer_instrumental": true + } +} diff --git a/src/ozan_radio/dj.py b/src/ozan_radio/dj.py index d6a50a7..7b3b1d9 100644 --- a/src/ozan_radio/dj.py +++ b/src/ozan_radio/dj.py @@ -8,6 +8,7 @@ import httpx from ozan_radio.config import Config from ozan_radio.spotify import TasteProfile +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, @@ -32,8 +33,8 @@ Your job: 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. 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). +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: { @@ -66,6 +67,12 @@ class DeepSeekDJ: def __init__(self, config: Config) -> None: self._config = config + self._settings = load_settings() + + def _taste_block(self) -> str: + if self._settings: + return self._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() @@ -98,7 +105,8 @@ class DeepSeekDJ: if now_playing: context.append(f"Currently playing: {now_playing}") - messages = [{"role": "system", "content": CHAT_SYSTEM}] + 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"]}) @@ -120,7 +128,10 @@ class DeepSeekDJ: ) -> TrackPlan: self._config.require_deepseek() - user_parts = ["Plan the next generated track for Live Ozan Radio."] + user_parts = [ + "Plan the next generated track for Live Ozan Radio.", + f"Station taste:\n{self._taste_block()}", + ] if taste: user_parts.append(f"Spotify taste: {taste.summary}") if taste.top_genres: diff --git a/src/ozan_radio/settings.py b/src/ozan_radio/settings.py new file mode 100644 index 0000000..ade5606 --- /dev/null +++ b/src/ozan_radio/settings.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path + + +@dataclass +class ListenerSettings: + listener: str + summary: str + genres: list[str] + mood: list[str] + instruments: list[str] + tempo_bpm: list[int] + references: str + avoid: list[str] + prefer_instrumental: bool + + def dj_context(self) -> str: + bpm = self.tempo_bpm + tempo = f"{bpm[0]}-{bpm[1]} BPM" if len(bpm) >= 2 else "mid tempo" + lines = [ + f"Listener taste ({self.listener}): {self.summary}", + f"Genres: {', '.join(self.genres)}.", + f"Mood: {', '.join(self.mood)}.", + f"Instruments / production: {', '.join(self.instruments)}.", + f"Tempo: {tempo}.", + f"References: {self.references}", + ] + if self.avoid: + lines.append(f"Avoid: {', '.join(self.avoid)}.") + if self.prefer_instrumental: + lines.append("Default to instrumental unless vocals are requested.") + return "\n".join(lines) + + +def load_settings(repo_root: Path | None = None) -> ListenerSettings | None: + root = repo_root or Path(__file__).resolve().parents[2] + path = root / "settings.json" + if not path.exists(): + return None + + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return None + + taste = data.get("taste", {}) + lyria = data.get("lyria", {}) + if not taste.get("summary") and not taste.get("genres"): + return None + + return ListenerSettings( + listener=data.get("listener", "listener"), + summary=taste.get("summary", ""), + genres=taste.get("genres", []), + mood=taste.get("mood", []), + instruments=taste.get("instruments", []), + tempo_bpm=taste.get("tempo_bpm", []), + references=taste.get("references", ""), + avoid=taste.get("avoid", []), + prefer_instrumental=lyria.get("prefer_instrumental", True), + ) diff --git a/taste_seeds.json b/taste_seeds.json index 37c9979..daebb5f 100644 --- a/taste_seeds.json +++ b/taste_seeds.json @@ -2,12 +2,12 @@ "listener": "ozan", "notes": "Manual taste seeds when Spotify API is not wired. Add tracks from your library — DJ reads vibe, never replays catalog.", "genres": [ + "ethnic world", + "dubtronica", + "world dub", "west african", "afro-folk", - "desert blues", - "griot", - "world", - "eclectic groove" + "desert blues" ], "tracks": [ {