diff --git a/.gitignore b/.gitignore index 4d47656..46f72ce 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ dist/ # Legacy cache (migrated to songs/) radio_cache/ +songs/stats.json # IDE / OS .DS_Store diff --git a/README.md b/README.md index d7768cd..364d141 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Live Ozan Radio -Personal AI radio — **no catalog music, ever**. DeepSeek is the DJ. Google **Lyria 3** composes every track. Spotify is read-only taste input. +Personal AI radio — **no catalog music, ever**. DeepSeek is the DJ. Google **Lyria 3** composes every track. Taste comes from `settings.json` (and optionally Spotify). Inspired by [Magenta RealTime 2](https://magenta.withgoogle.com/magenta-realtime-2) (live, ~200ms) and [Lyria 3](https://deepmind.google/models/lyria/) (full songs via Gemini API). On Mac you can layer MRT2 for true live improvisation; this repo ships the cross-platform Lyria + DeepSeek stack first. @@ -8,15 +8,59 @@ Inspired by [Magenta RealTime 2](https://magenta.withgoogle.com/magenta-realtime | Layer | Product | Role | |-------|---------|------| -| DJ brain | DeepSeek (via Tinqs inference or BYOK) | Mood, prompts, variety | +| DJ brain | DeepSeek (Tinqs proxy or BYOK) | Mood, prompts, chat, variety | | Music engine | Google Lyria 3 Pro / Clip | Generate MP3 tracks | -| Taste | Spotify Web API (optional) | Top artists, genres — never plays Spotify | -| Player | FastAPI + `gateway/player.html` | Stream generated queue | +| Taste | `settings.json` + `taste_seeds.json` | Genres, mood, instruments — DJ reads every request | +| Taste (optional) | Spotify Web API | Top artists, genres — never plays Spotify | +| Player | FastAPI + `gateway/player.html` | Stream queue, library, chat, dashboard | | Live (optional) | Magenta RealTime 2 | Apple Silicon only — see below | +## Player dashboard + +Open **http://127.0.0.1:8787/player** after starting the server. + +| Feature | What it does | +|---------|----------------| +| **Cost dashboard** | Today's estimated spend, per-track cost, songs generated vs daily cap | +| **Settings (gear icon)** | Shuffle mode, mix saved + new tracks, new-song chance, max songs/day | +| **Shuffle mode** | On track end or Skip, picks a random saved song or composes a new one | +| **Saved songs** | Click any track in the library to play | +| **DJ chat** | Talk to DeepSeek — requests can trigger new Lyria generations | + +### Shuffle behaviour + +When shuffle is on (default): + +1. If you have saved songs **and** daily quota remains, each next track has a `new_song_chance` (default 35%) of being freshly composed. +2. Otherwise a random saved song plays (never the same track twice in a row if alternatives exist). +3. If the library is empty, it generates until the daily cap is hit. + +Daily generation stats live in `songs/stats.json` (gitignored, local runtime only). + ## 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). +Edit `settings.json` at the repo root. The DJ reloads it on every generate and chat. + +```json +{ + "taste": { "summary": "...", "genres": [], "mood": [], "instruments": [] }, + "playback": { + "shuffle": true, + "mix_existing_and_new": true, + "new_song_chance": 0.35 + }, + "limits": { "max_new_songs_per_day": 10 }, + "costs": { + "lyria_pro_usd": 0.08, + "lyria_clip_usd": 0.04, + "deepseek_per_track_usd": 0.002 + } +} +``` + +Default taste profile: **ethnic world dubtronica** (global roots + dub space + electronic groove). + +The player settings panel PATCHes `playback` and `limits` via `/api/settings` and writes back to this file. ## Saved songs @@ -26,6 +70,7 @@ Every track is written to `./songs/` and **committed via Git LFS** (audio) + pla |------|---------|----------| | `{id}_{title}.mp3` | LFS | Audio | | `{id}.meta.json` | git | Title, mood, DJ line, prompt, lyrics, timestamp | +| `manifest.json` | gitignored | Runtime queue index | Browse in the player under **Saved songs**, or `GET /api/songs`. After clone: `git lfs install` then `git lfs pull`. @@ -39,6 +84,7 @@ pip install -e . copy .env.example .env # Fill GEMINI_API_KEY + DEEPSEEK_API_KEY (and Spotify if you have them) +$env:DEEPSEEK_BASE_URL = "https://api.deepseek.com/v1" python -m ozan_radio serve # Open http://127.0.0.1:8787/player ``` @@ -54,19 +100,29 @@ python -m ozan_radio generate | Variable | Required | Notes | |----------|----------|-------| | `GEMINI_API_KEY` | Yes | [Google AI Studio](https://aistudio.google.com/apikey) — Lyria 3 | -| `DEEPSEEK_API_KEY` | Yes | Tinqs proxy or DeepSeek direct | -| `DEEPSEEK_BASE_URL` | No | Default `https://tinqs.com/api/v1/inference` | +| `DEEPSEEK_API_KEY` | Yes | Tinqs proxy token or DeepSeek direct | +| `DEEPSEEK_BASE_URL` | No | Default `https://api.deepseek.com/v1` | | `SPOTIFY_*` | No | Refresh token flow — taste only | | `LYRIA_MODEL` | No | `lyria-3-pro-preview` (default) or `lyria-3-clip-preview` | +| `RADIO_OUTPUT_DIR` | No | Default `./songs` | -### Spotify setup (taste profile) +### Cost estimates (defaults) + +| Model | ~USD / track | +|-------|----------------| +| Lyria 3 Pro + DeepSeek | ~$0.082 | +| Lyria 3 Clip | ~$0.04 | + +At the default cap of 10 new songs/day with Lyria Pro, projected max spend is **~$0.82/day**. Adjust `costs` and `limits` in `settings.json` or the player settings panel. + +### Spotify setup (optional taste) 1. Create an app at [Spotify Developer Dashboard](https://developer.spotify.com/dashboard). 2. Add redirect URI `http://127.0.0.1:8888/callback`. 3. Complete OAuth once to obtain a refresh token (scope: `user-top-read`). 4. Paste `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET`, `SPOTIFY_REFRESH_TOKEN` into `.env`. -The Spotify MCP / tool you wired to DeepSeek can call the same endpoints — this repo exposes them natively for the DJ loop. +If Spotify is not configured, the DJ uses `settings.json` + `taste_seeds.json`. ## API @@ -74,8 +130,15 @@ The Spotify MCP / tool you wired to DeepSeek can call the same endpoints — thi |--------|------|-------------| | GET | `/api/now` | Current track metadata | | GET | `/api/queue` | Full queue | +| GET | `/api/stats` | Dashboard: today's spend, quota, cost estimates | +| GET | `/api/settings` | Playback, limits, costs | +| PATCH | `/api/settings` | Update shuffle, daily cap, etc. | | POST | `/api/generate` | DJ plans + Lyria renders next track | -| POST | `/api/skip` | Advance queue | +| POST | `/api/shuffle/next` | Smart next: library shuffle or new generation | +| POST | `/api/skip` | Advance (uses shuffle when enabled) | +| GET/POST | `/api/chat` | DJ chat log and messages | +| GET | `/api/songs` | Saved library | +| POST | `/api/songs/{id}/play` | Play a saved track | | GET | `/stream/{file}` | MP3 stream | | GET | `/player` | Web UI | @@ -91,24 +154,17 @@ mrt mlx generate --prompt "disco funk" --duration 4.0 --model=mrt2_small Wire MRT2 as a bridge between tracks or as a live “bed” under the Lyria queue — PRs welcome. -## Publish on tinqs.com (public repo) +## Repo -1. On Git Studio: **+ → New Repository** - - Owner: `tinqs` - - Name: `live-radio` - - Visibility: **Public** -2. Push (with LFS for song MP3s): +Public on Git Studio: **https://tinqs.com/tinqs/live-radio** ```bash -git lfs install -git init -git remote add origin git@ssh.tinqs.com:tinqs/live-radio.git -git add . -git commit -m "Live Ozan Radio — DeepSeek DJ + Lyria 3" -git push -u origin main +git clone git@ssh.tinqs.com:tinqs/live-radio.git +cd live-radio +git lfs install && git lfs pull ``` -3. Preview the player: `https://tinqs.com/tinqs/live-radio/src/branch/main/gateway/player.html` (static shell; audio streams from your running server). +Static player preview: `https://tinqs.com/tinqs/live-radio/src/branch/main/gateway/player.html` (shell only — audio streams from your running server). ## Agent usage diff --git a/gateway/player.html b/gateway/player.html index 738444c..7964a41 100644 --- a/gateway/player.html +++ b/gateway/player.html @@ -35,6 +35,33 @@ padding: 2rem; box-shadow: 0 24px 80px rgba(0,0,0,0.5); } + .header-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.25rem; + } + .header-row h1 { margin-bottom: 0; } + .icon-btn { + flex: 0 0 auto; + width: 40px; + height: 40px; + min-width: 40px; + padding: 0; + border-radius: 10px; + background: #2a2a3a; + border: 1px solid #3a3a50; + color: var(--text); + font-size: 1.1rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: border-color 0.15s, background 0.15s; + } + .icon-btn:hover { border-color: var(--accent); background: #32324a; } + .icon-btn.active { border-color: var(--accent); color: var(--accent); } .badge { display: inline-block; font-size: 0.7rem; @@ -55,9 +82,116 @@ .tagline { color: var(--muted); font-size: 0.85rem; - margin-bottom: 1.5rem; + margin-bottom: 1rem; line-height: 1.4; } + .dashboard { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; + margin-bottom: 1.25rem; + } + .stat-card { + background: #1a1a28; + border: 1px solid #2a2a3a; + border-radius: 12px; + padding: 0.65rem 0.75rem; + } + .stat-card.wide { grid-column: 1 / -1; } + .stat-label { + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); + margin-bottom: 0.2rem; + } + .stat-value { + font-size: 1rem; + font-weight: 700; + color: var(--text); + } + .stat-value.accent { color: var(--accent); } + .stat-sub { + font-size: 0.7rem; + color: var(--muted); + margin-top: 0.15rem; + } + .settings-panel { + display: none; + background: #1a1a28; + border: 1px solid #3a3a50; + border-radius: 14px; + padding: 1rem; + margin-bottom: 1.25rem; + } + .settings-panel.open { display: block; } + .settings-panel h2 { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); + margin-bottom: 0.75rem; + } + .setting-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.5rem 0; + border-bottom: 1px solid #2a2a3a; + font-size: 0.85rem; + } + .setting-row:last-child { border-bottom: none; } + .setting-row label { flex: 1; color: var(--text); } + .setting-row .hint { + display: block; + font-size: 0.7rem; + color: var(--muted); + margin-top: 0.15rem; + } + .toggle { + position: relative; + width: 44px; + height: 24px; + flex-shrink: 0; + } + .toggle input { opacity: 0; width: 0; height: 0; } + .toggle span { + position: absolute; + inset: 0; + background: #3a3a50; + border-radius: 12px; + cursor: pointer; + transition: background 0.2s; + } + .toggle span::before { + content: ""; + position: absolute; + width: 18px; + height: 18px; + left: 3px; + top: 3px; + background: #fff; + border-radius: 50%; + transition: transform 0.2s; + } + .toggle input:checked + span { background: var(--accent); } + .toggle input:checked + span::before { transform: translateX(20px); } + .num-input { + width: 64px; + background: #14141f; + border: 1px solid #3a3a50; + border-radius: 8px; + padding: 0.35rem 0.5rem; + color: var(--text); + font-size: 0.85rem; + text-align: center; + } + .num-input:focus { outline: none; border-color: var(--accent); } + .range-input { + width: 100px; + accent-color: var(--accent); + } .viz { height: 64px; display: flex; @@ -79,6 +213,7 @@ .bar:nth-child(5) { height: 28px; animation-delay: 0.05s; } .bar:nth-child(6) { height: 44px; animation-delay: 0.25s; } .bar:nth-child(7) { height: 32px; animation-delay: 0.12s; } + .viz.paused .bar { animation-play-state: paused; opacity: 0.35; } @keyframes bounce { from { transform: scaleY(0.4); opacity: 0.6; } to { transform: scaleY(1); opacity: 1; } @@ -247,10 +382,69 @@
-
● Live
-

Ozan Radio

+
● Live
+
+

Ozan Radio

+ +

No Spotify playback. No catalog tracks. DeepSeek picks the vibe — Google Lyria 3 composes it fresh.

+
+
+
Today spent
+
$0.00
+
~$0.08 / track
+
+
+
New songs today
+
0 / 10
+
10 remaining
+
+
+
Max daily budget (at limit)
+
$0.82
+
Lyria Pro + DeepSeek estimate
+
+
+ +
+

Playback & limits

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
@@ -287,7 +481,7 @@
- DJ: DeepSeek · Music: Google Lyria 3 · Taste: Spotify (read-only)
+ DJ: DeepSeek · Music: Google Lyria 3 · Taste: settings.json
Optional live layer: Magenta RealTime 2 on Apple Silicon
@@ -295,6 +489,7 @@ diff --git a/settings.json b/settings.json index 0ca3b1c..d94e02b 100644 --- a/settings.json +++ b/settings.json @@ -47,7 +47,21 @@ "variety": true, "default_length": "1-2 minutes" }, + "playback": { + "shuffle": true, + "mix_existing_and_new": true, + "new_song_chance": 0.35 + }, + "limits": { + "max_new_songs_per_day": 10 + }, + "costs": { + "lyria_pro_usd": 0.08, + "lyria_clip_usd": 0.04, + "deepseek_per_track_usd": 0.002 + }, "lyria": { - "prefer_instrumental": true + "prefer_instrumental": true, + "model": "lyria-3-pro-preview" } } diff --git a/songs/manifest.json b/songs/manifest.json index 1a60a34..2efdb50 100644 --- a/songs/manifest.json +++ b/songs/manifest.json @@ -1,5 +1,5 @@ { - "index": 1, + "index": 0, "count": 2, "tracks": [ { diff --git a/src/ozan_radio/dj.py b/src/ozan_radio/dj.py index 7b3b1d9..a8c77ce 100644 --- a/src/ozan_radio/dj.py +++ b/src/ozan_radio/dj.py @@ -67,11 +67,11 @@ 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() + 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: diff --git a/src/ozan_radio/queue.py b/src/ozan_radio/queue.py index ca546b8..fb3c271 100644 --- a/src/ozan_radio/queue.py +++ b/src/ozan_radio/queue.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import random from dataclasses import asdict, dataclass from pathlib import Path @@ -127,6 +128,24 @@ class RadioQueue: self._save_manifest() return self.current() + def shuffle_to_id(self, track_id: str) -> GeneratedTrack | None: + if self.play_id(track_id): + return self.current() + return None + + def shuffle_advance(self) -> GeneratedTrack | None: + if not self._tracks: + return None + if len(self._tracks) == 1: + return self.current() + current_id = self.current().plan.id if self.current() else None + indices = [i for i, t in enumerate(self._tracks) if t.plan.id != current_id] + if not indices: + indices = list(range(len(self._tracks))) + self._index = random.choice(indices) + self._save_manifest() + return self.current() + def now_playing(self, base_url: str = "") -> NowPlaying | None: track = self.current() if not track: diff --git a/src/ozan_radio/radio_settings.py b/src/ozan_radio/radio_settings.py new file mode 100644 index 0000000..9bc724e --- /dev/null +++ b/src/ozan_radio/radio_settings.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path + + +@dataclass +class PlaybackSettings: + shuffle: bool + mix_existing_and_new: bool + new_song_chance: float + + +@dataclass +class LimitSettings: + max_new_songs_per_day: int + + +@dataclass +class CostSettings: + lyria_pro_usd: float + lyria_clip_usd: float + deepseek_per_track_usd: float + + +@dataclass +class RadioSettings: + playback: PlaybackSettings + limits: LimitSettings + costs: CostSettings + lyria_model: str + + def to_public_dict(self) -> dict: + return { + "playback": { + "shuffle": self.playback.shuffle, + "mix_existing_and_new": self.playback.mix_existing_and_new, + "new_song_chance": self.playback.new_song_chance, + }, + "limits": {"max_new_songs_per_day": self.limits.max_new_songs_per_day}, + "costs": { + "lyria_pro_usd": self.costs.lyria_pro_usd, + "lyria_clip_usd": self.costs.lyria_clip_usd, + "deepseek_per_track_usd": self.costs.deepseek_per_track_usd, + "per_track_estimate_usd": self.per_track_cost(), + }, + "lyria_model": self.lyria_model, + } + + def per_track_cost(self) -> float: + from ozan_radio.stats import cost_per_track + + return cost_per_track(self.lyria_model, self.costs.__dict__) + + +def _settings_path(repo_root: Path | None = None) -> Path: + root = repo_root or Path(__file__).resolve().parents[2] + return root / "settings.json" + + +def load_radio_settings( + repo_root: Path | None = None, *, lyria_model: str = "lyria-3-pro-preview" +) -> RadioSettings: + path = _settings_path(repo_root) + data: dict = {} + if path.exists(): + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + pass + + playback = data.get("playback", {}) + limits = data.get("limits", {}) + costs = data.get("costs", {}) + + return RadioSettings( + playback=PlaybackSettings( + shuffle=playback.get("shuffle", True), + mix_existing_and_new=playback.get("mix_existing_and_new", True), + new_song_chance=float(playback.get("new_song_chance", 0.35)), + ), + limits=LimitSettings(max_new_songs_per_day=int(limits.get("max_new_songs_per_day", 10))), + costs=CostSettings( + lyria_pro_usd=float(costs.get("lyria_pro_usd", 0.08)), + lyria_clip_usd=float(costs.get("lyria_clip_usd", 0.04)), + deepseek_per_track_usd=float(costs.get("deepseek_per_track_usd", 0.002)), + ), + lyria_model=data.get("lyria", {}).get("model") or lyria_model, + ) + + +def save_radio_settings_patch(patch: dict, repo_root: Path | None = None) -> dict: + path = _settings_path(repo_root) + data: dict = {} + if path.exists(): + data = json.loads(path.read_text(encoding="utf-8")) + + if "playback" in patch: + data.setdefault("playback", {}).update(patch["playback"]) + if "limits" in patch: + data.setdefault("limits", {}).update(patch["limits"]) + if "costs" in patch: + data.setdefault("costs", {}).update(patch["costs"]) + + path.write_text(json.dumps(data, indent=2), encoding="utf-8") + return load_radio_settings(repo_root).to_public_dict() diff --git a/src/ozan_radio/server.py b/src/ozan_radio/server.py index 1145875..28f106b 100644 --- a/src/ozan_radio/server.py +++ b/src/ozan_radio/server.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import random from pathlib import Path from fastapi import BackgroundTasks, FastAPI, HTTPException @@ -14,7 +15,11 @@ from ozan_radio.lyria import LyriaEngine from ozan_radio.queue import RadioQueue from ozan_radio.spotify import SpotifyTaste from ozan_radio.chat_store import ChatStore +from ozan_radio.dj import TrackPlan from ozan_radio.library import list_saved_songs +from ozan_radio.lyria import GeneratedTrack +from ozan_radio.radio_settings import load_radio_settings, save_radio_settings_patch +from ozan_radio.stats import cost_per_track, record_generation, today_stats from ozan_radio.taste import load_taste_seeds app = FastAPI(title="Live Ozan Radio", version="0.1.0") @@ -35,7 +40,54 @@ class ChatRequest(BaseModel): message: str = Field(min_length=1, max_length=500) -async def _compose_track(request: str | None = None) -> dict: +class SettingsPatch(BaseModel): + playback: dict | None = None + limits: dict | None = None + costs: dict | None = None + + +def _can_generate_today(cfg: Config) -> tuple[bool, dict]: + rs = load_radio_settings(lyria_model=cfg.lyria_model) + stats = today_stats(cfg.output_dir) + remaining = rs.limits.max_new_songs_per_day - stats["generated"] + return remaining > 0, { + **stats, + "max_per_day": rs.limits.max_new_songs_per_day, + "remaining": max(0, remaining), + } + + +def _dashboard_stats(cfg: Config) -> dict: + rs = load_radio_settings(lyria_model=cfg.lyria_model) + budget = _can_generate_today(cfg)[1] + per_track = rs.per_track_cost() + return { + "today": budget, + "costs": rs.costs.__dict__ | {"per_track_estimate_usd": per_track}, + "playback": rs.playback.__dict__, + "projected_daily_max_usd": round(per_track * rs.limits.max_new_songs_per_day, 2), + } + + +def _play_library_entry(q: RadioQueue, cfg: Config, entry: dict) -> dict: + track = q.play_id(entry["id"]) + if not track: + plan = TrackPlan( + id=entry["id"], + title=entry.get("title", entry["id"]), + mood=entry.get("mood", ""), + dj_line=entry.get("dj_line", ""), + lyria_prompt=entry.get("lyria_prompt", ""), + ) + path = cfg.output_dir / entry["file"] + restored = GeneratedTrack(plan=plan, audio_path=path, lyrics=entry.get("lyrics", "")) + q.add(restored) + q.play_id(entry["id"]) + np = q.now_playing() + return {"status": "ok", "source": "library", "track": np.__dict__ if np else None} + + +async def _compose_track(request: str | None = None, *, check_limit: bool = True) -> dict: global _generating if _generating: return {"status": "busy", "message": "Already generating a track"} @@ -43,6 +95,14 @@ async def _compose_track(request: str | None = None) -> dict: _generating = True try: cfg = _get_config() + if check_limit: + ok, budget = _can_generate_today(cfg) + if not ok: + return { + "status": "limit", + "message": f"Daily limit reached ({budget['max_per_day']} new songs)", + "budget": budget, + } taste = await SpotifyTaste(cfg).fetch_taste() seeds = None if taste else load_taste_seeds() q = _get_queue() @@ -52,11 +112,18 @@ async def _compose_track(request: str | None = None) -> dict: ) track = LyriaEngine(cfg).generate(plan) q.add(track) + rs = load_radio_settings(lyria_model=cfg.lyria_model) + cost = cost_per_track(cfg.lyria_model, rs.costs.__dict__) + record_generation(cfg.output_dir, cost, track.plan.id, track.plan.title) np = q.now_playing() + _, budget = _can_generate_today(cfg) return { "status": "ok", + "source": "generated", "track": np.__dict__ if np else None, "taste_used": taste.summary if taste else (seeds.summary if seeds else None), + "cost_usd": cost, + "budget": budget, } finally: _generating = False @@ -88,6 +155,9 @@ async def root() -> dict: "chat": "POST /api/chat", "chat_log": "/api/chat", "songs": "/api/songs", + "stats": "/api/stats", + "settings": "/api/settings", + "shuffle": "POST /api/shuffle/next", "player": "/player", }, } @@ -118,11 +188,73 @@ async def stream_track(filename: str) -> FileResponse: return FileResponse(path, media_type="audio/mpeg") +@app.get("/api/stats") +async def dashboard_stats() -> dict: + cfg = _get_config() + return _dashboard_stats(cfg) + + +@app.get("/api/settings") +async def get_settings() -> dict: + cfg = _get_config() + rs = load_radio_settings(lyria_model=cfg.lyria_model) + return rs.to_public_dict() | {"budget": _can_generate_today(cfg)[1]} + + +@app.patch("/api/settings") +async def patch_settings(body: SettingsPatch) -> dict: + patch = body.model_dump(exclude_none=True) + updated = save_radio_settings_patch(patch) + cfg = _get_config() + return updated | {"budget": _can_generate_today(cfg)[1]} + + @app.post("/api/generate") async def generate_track() -> dict: return await _compose_track() +@app.post("/api/shuffle/next") +async def shuffle_next() -> dict: + cfg = _get_config() + rs = load_radio_settings(lyria_model=cfg.lyria_model) + q = _get_queue() + songs = list_saved_songs(cfg.output_dir) + can_new, budget = _can_generate_today(cfg) + + want_new = False + if rs.playback.shuffle and rs.playback.mix_existing_and_new and can_new and songs: + want_new = random.random() < rs.playback.new_song_chance + elif rs.playback.shuffle and not songs and can_new: + want_new = True + + if want_new: + result = await _compose_track() + if result.get("status") == "ok": + q.shuffle_to_id(result["track"]["track_id"]) if result.get("track") else None + return result + + if songs: + current_id = None + np = q.now_playing() + if np: + current_id = np.track_id + pool = [s for s in songs if s["id"] != current_id] or songs + pick = random.choice(pool) + result = _play_library_entry(q, cfg, pick) + result["budget"] = budget + return result + + if can_new: + return await _compose_track() + + return { + "status": "limit", + "message": "No saved songs and daily generation limit reached", + "budget": budget, + } + + @app.get("/api/chat") async def chat_log() -> dict: return {"messages": _chat.public_log()} @@ -150,8 +282,12 @@ async def chat_with_dj(body: ChatRequest, background: BackgroundTasks) -> dict: if reply.vibe_hint: _chat.pending_vibe = reply.vibe_hint if not _generating: - result["generating"] = True - background.add_task(_compose_track, reply.vibe_hint or None) + ok, _ = _can_generate_today(cfg) + if ok: + result["generating"] = True + background.add_task(_compose_track, reply.vibe_hint or None) + else: + result["reply"] += " (Daily new-song limit reached — playing saved tracks only.)" return result @@ -171,9 +307,6 @@ async def play_saved(track_id: str) -> dict: cfg = _get_config() for entry in list_saved_songs(cfg.output_dir): if entry["id"] == track_id: - from ozan_radio.dj import TrackPlan - from ozan_radio.lyria import GeneratedTrack - plan = TrackPlan( id=entry["id"], title=entry.get("title", track_id), @@ -194,12 +327,16 @@ async def play_saved(track_id: str) -> dict: @app.post("/api/skip") async def skip_track() -> dict: + cfg = _get_config() + rs = load_radio_settings(lyria_model=cfg.lyria_model) + if rs.playback.shuffle: + return await shuffle_next() q = _get_queue() track = q.advance() if not track: return {"status": "idle"} np = q.now_playing() - return {"status": "ok", "track": np.__dict__ if np else None} + return {"status": "ok", "track": np.__dict__ if np else None, "source": "queue"} @app.get("/player", response_class=HTMLResponse) diff --git a/src/ozan_radio/stats.py b/src/ozan_radio/stats.py new file mode 100644 index 0000000..49a3c4a --- /dev/null +++ b/src/ozan_radio/stats.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import json +from datetime import date, datetime, timezone +from pathlib import Path + + +def _today_key() -> str: + return date.today().isoformat() + + +def _stats_path(output_dir: Path) -> Path: + return output_dir / "stats.json" + + +def _load_raw(output_dir: Path) -> dict: + path = _stats_path(output_dir) + if not path.exists(): + return {"days": {}} + try: + return json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return {"days": {}} + + +def _save_raw(output_dir: Path, data: dict) -> None: + _stats_path(output_dir).write_text(json.dumps(data, indent=2), encoding="utf-8") + + +def record_generation(output_dir: Path, cost_usd: float, track_id: str, title: str) -> dict: + data = _load_raw(output_dir) + day = _today_key() + entry = data.setdefault("days", {}).setdefault( + day, {"generated": 0, "estimated_usd": 0.0, "tracks": []} + ) + entry["generated"] = entry.get("generated", 0) + 1 + entry["estimated_usd"] = round(entry.get("estimated_usd", 0.0) + cost_usd, 4) + entry.setdefault("tracks", []).append( + { + "id": track_id, + "title": title, + "at": datetime.now(timezone.utc).isoformat(), + "cost_usd": cost_usd, + } + ) + _save_raw(output_dir, data) + return entry + + +def today_stats(output_dir: Path) -> dict: + data = _load_raw(output_dir) + day = _today_key() + entry = data.get("days", {}).get(day, {"generated": 0, "estimated_usd": 0.0, "tracks": []}) + return { + "date": day, + "generated": entry.get("generated", 0), + "estimated_usd": round(entry.get("estimated_usd", 0.0), 4), + "tracks": entry.get("tracks", []), + } + + +def cost_per_track(lyria_model: str, costs: dict) -> float: + if "clip" in lyria_model: + return float(costs.get("lyria_clip_usd", 0.04)) + return float(costs.get("lyria_pro_usd", 0.08)) + float(costs.get("deepseek_per_track_usd", 0.002))