From b8ff25f370ff267e344ba0a784cce63f8df72d0e Mon Sep 17 00:00:00 2001 From: tinqs-limited Date: Sun, 7 Jun 2026 14:18:17 +0100 Subject: [PATCH] Add song library with Git LFS, DJ chat, and tinqs/live-radio publish path. Songs persist under songs/ (MP3 via LFS, metadata in git). Player shows saved library. Co-authored-by: Cursor --- .cursor/skills/ozan-radio/SKILL.md | 2 +- .env.example | 2 +- .gitattributes | 7 ++ .gitignore | 4 +- AGENTS.md | 2 +- CLAUDE.md | 2 +- README.md | 20 ++++- gateway/player.html | 90 ++++++++++++++++++++++ songs/README.md | 18 +++++ songs/b51b1cb2.meta.json | 10 +++ songs/b51b1cb2_Sahara_Moon.mp3 | 3 + songs/b548d6a6.meta.json | 10 +++ songs/b548d6a6_Dune_Chant.mp3 | 3 + songs/manifest.json | 24 ++++++ src/ozan_radio/config.py | 5 +- src/ozan_radio/library.py | 119 +++++++++++++++++++++++++++++ src/ozan_radio/queue.py | 10 +++ src/ozan_radio/server.py | 38 +++++++++ 18 files changed, 357 insertions(+), 12 deletions(-) create mode 100644 .gitattributes create mode 100644 songs/README.md create mode 100644 songs/b51b1cb2.meta.json create mode 100644 songs/b51b1cb2_Sahara_Moon.mp3 create mode 100644 songs/b548d6a6.meta.json create mode 100644 songs/b548d6a6_Dune_Chant.mp3 create mode 100644 songs/manifest.json create mode 100644 src/ozan_radio/library.py diff --git a/.cursor/skills/ozan-radio/SKILL.md b/.cursor/skills/ozan-radio/SKILL.md index 7a96f17..079a898 100644 --- a/.cursor/skills/ozan-radio/SKILL.md +++ b/.cursor/skills/ozan-radio/SKILL.md @@ -8,7 +8,7 @@ description: Operate Live Ozan Radio — DeepSeek DJ plans tracks, Google Lyria ## When to use - User says "ozan radio", "live radio", "generate a track", "what's playing" -- Operating or debugging `tinqs/live-ozan-radio` +- Operating or debugging `tinqs/live-radio` ## Prerequisites diff --git a/.env.example b/.env.example index 20b9075..7a341f3 100644 --- a/.env.example +++ b/.env.example @@ -16,7 +16,7 @@ SPOTIFY_REFRESH_TOKEN= # Radio server RADIO_HOST=127.0.0.1 RADIO_PORT=8787 -RADIO_OUTPUT_DIR=./radio_cache +RADIO_OUTPUT_DIR=./songs # Generation defaults LYRIA_MODEL=lyria-3-pro-preview diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6046c7f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +[attr]lfs filter=lfs diff=lfs merge=lfs -text + +# Generated song audio — Git LFS on tinqs.com +*.mp3 lfs +*.wav lfs + +* text=auto diff --git a/.gitignore b/.gitignore index e84ea53..4d47656 100644 --- a/.gitignore +++ b/.gitignore @@ -10,10 +10,8 @@ dist/ .env .env.local -# Generated audio (cache — regenerate on demand) +# Legacy cache (migrated to songs/) radio_cache/ -*.mp3 -*.wav # IDE / OS .DS_Store diff --git a/AGENTS.md b/AGENTS.md index b2a9428..4e62dfc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md — Live Ozan Radio -Public demo repo under `tinqs/live-ozan-radio`. AI agents run the station — humans listen. +Public repo under `tinqs/live-radio`. AI agents run the station — humans listen. ## Identity diff --git a/CLAUDE.md b/CLAUDE.md index 4a77233..bd5830d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ Read `AGENTS.md` and `README.md` first. ## What this is -Ozan's personal AI radio. DeepSeek DJ + Google Lyria 3. Public repo on `tinqs/live-ozan-radio`. +Ozan's personal AI radio. DeepSeek DJ + Google Lyria 3. Public repo on `tinqs/live-radio`. ## Run diff --git a/README.md b/README.md index e166b10..f1377b9 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,17 @@ 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 | +## Saved songs + +Every track is written to `./songs/` and **committed via Git LFS** (audio) + plain git (metadata): + +| File | Storage | Contents | +|------|---------|----------| +| `{id}_{title}.mp3` | LFS | Audio | +| `{id}.meta.json` | git | Title, mood, DJ line, prompt, lyrics, timestamp | + +Browse in the player under **Saved songs**, or `GET /api/songs`. After clone: `git lfs install` then `git lfs pull`. + ## Quick start (Forge / Windows) ```powershell @@ -80,19 +91,20 @@ Wire MRT2 as a bridge between tracks or as a live “bed” under the Lyria queu 1. On Git Studio: **+ → New Repository** - Owner: `tinqs` - - Name: `live-ozan-radio` + - Name: `live-radio` - Visibility: **Public** -2. Push: +2. Push (with LFS for song MP3s): ```bash +git lfs install git init -git remote add origin git@ssh.tinqs.com:tinqs/live-ozan-radio.git +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 ``` -3. Preview the player: `https://tinqs.com/tinqs/live-ozan-radio/src/branch/main/gateway/player.html` (static shell; audio streams from your running server). +3. Preview the player: `https://tinqs.com/tinqs/live-radio/src/branch/main/gateway/player.html` (static shell; audio streams from your running server). ## Agent usage diff --git a/gateway/player.html b/gateway/player.html index a9d99d5..738444c 100644 --- a/gateway/player.html +++ b/gateway/player.html @@ -205,6 +205,44 @@ min-width: 72px; padding: 0.6rem 1rem; } + .library { + margin-top: 1.25rem; + border-top: 1px solid #2a2a3a; + padding-top: 1rem; + } + .library h2 { + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); + margin-bottom: 0.5rem; + } + .library-list { + display: flex; + flex-direction: column; + gap: 0.35rem; + max-height: 140px; + overflow-y: auto; + } + .song-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.5rem 0.65rem; + background: #1a1a28; + border: 1px solid #2a2a3a; + border-radius: 10px; + cursor: pointer; + font-size: 0.82rem; + text-align: left; + color: var(--text); + width: 100%; + } + .song-row:hover { border-color: var(--accent); } + .song-row.playing { border-color: var(--accent); background: rgba(255,107,53,0.08); } + .song-meta { color: var(--muted); font-size: 0.72rem; } + .library-empty { color: var(--muted); font-size: 0.8rem; } @@ -232,6 +270,13 @@
Connecting…
+
+

Saved songs

+
+
Loading library…
+
+
+

Talk to the DJ

@@ -261,6 +306,48 @@ const chatForm = document.getElementById('chatForm'); const chatInput = document.getElementById('chatInput'); const chatSend = document.getElementById('chatSend'); + const libraryList = document.getElementById('libraryList'); + let currentTrackId = null; + + async function loadLibrary() { + try { + const res = await fetch(`${API}/api/songs`); + const data = await res.json(); + libraryList.innerHTML = ''; + if (!data.songs || data.songs.length === 0) { + libraryList.innerHTML = '
No saved songs yet — generate one!
'; + return; + } + for (const song of data.songs) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'song-row' + (song.id === currentTrackId ? ' playing' : ''); + const sizeMb = song.size_bytes ? (song.size_bytes / 1e6).toFixed(1) + ' MB' : ''; + btn.innerHTML = `${song.title}${sizeMb}`; + btn.addEventListener('click', () => playSong(song.id)); + libraryList.appendChild(btn); + } + } catch (_) { + libraryList.innerHTML = '
Library offline
'; + } + } + + async function playSong(trackId) { + try { + const res = await fetch(`${API}/api/songs/${trackId}/play`, { method: 'POST' }); + const data = await res.json(); + if (data.status === 'ok' && data.track) { + currentTrackId = data.track.track_id; + titleEl.textContent = data.track.title; + moodEl.textContent = data.track.mood ? `Mood: ${data.track.mood}` : ''; + djEl.textContent = data.track.dj_line ? `"${data.track.dj_line}"` : ''; + player.src = `${API}${data.track.audio_url}`; + player.play().catch(() => {}); + statusEl.textContent = `Playing · ${data.track.track_id}`; + loadLibrary(); + } + } catch (_) {} + } function addBubble(role, text) { const el = document.createElement('div'); @@ -329,6 +416,7 @@ return; } const t = data.track; + currentTrackId = t.track_id; titleEl.textContent = t.title; moodEl.textContent = t.mood ? `Mood: ${t.mood}` : ''; djEl.textContent = t.dj_line ? `"${t.dj_line}"` : ''; @@ -352,6 +440,7 @@ statusEl.textContent = data.message; } else if (data.status === 'ok') { await refreshNow(); + await loadLibrary(); } else { statusEl.textContent = 'Generation failed — check server logs'; } @@ -385,6 +474,7 @@ }); refreshNow(); + loadLibrary(); loadChat(); setInterval(refreshNow, 15000); diff --git a/songs/README.md b/songs/README.md new file mode 100644 index 0000000..06ae38c --- /dev/null +++ b/songs/README.md @@ -0,0 +1,18 @@ +# Song library + +Every generated track is saved here: + +| File | Git | Contents | +|------|-----|----------| +| `{id}_{title}.mp3` | **LFS** | Audio (~2 MB per track) | +| `{id}.meta.json` | plain git | Title, mood, DJ line, Lyria prompt, lyrics, timestamp | +| `manifest.json` | plain git | Queue index | + +**Clone with LFS:** + +```bash +git lfs install +git clone git@ssh.tinqs.com:tinqs/live-radio.git +``` + +Friends get the same saved songs after pull. New generations are committed the same way — `*.mp3` via LFS, metadata as normal files. diff --git a/songs/b51b1cb2.meta.json b/songs/b51b1cb2.meta.json new file mode 100644 index 0000000..d1e8d5a --- /dev/null +++ b/songs/b51b1cb2.meta.json @@ -0,0 +1,10 @@ +{ + "id": "b51b1cb2", + "title": "Sahara Moon", + "mood": "", + "dj_line": "Restored from cache.", + "lyria_prompt": "", + "lyrics": "", + "file": "b51b1cb2_Sahara_Moon.mp3", + "saved_at": "2026-06-07T13:04:14.135971+00:00" +} \ No newline at end of file diff --git a/songs/b51b1cb2_Sahara_Moon.mp3 b/songs/b51b1cb2_Sahara_Moon.mp3 new file mode 100644 index 0000000..52572e1 --- /dev/null +++ b/songs/b51b1cb2_Sahara_Moon.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3ca8ba6118908ff79a204506a8ca576c8b93e6b0af80948753f07bfc7cc1f77e +size 2140176 diff --git a/songs/b548d6a6.meta.json b/songs/b548d6a6.meta.json new file mode 100644 index 0000000..baa5941 --- /dev/null +++ b/songs/b548d6a6.meta.json @@ -0,0 +1,10 @@ +{ + "id": "b548d6a6", + "title": "Dune Chant", + "mood": "rolling desert pulse with call-and-response", + "dj_line": "Sahel breeze right through the speakers \u2014 let the sand dance.", + "lyria_prompt": "A 1-2 minute instrumental track in the style of Sahelian desert blues. Acoustic guitar (kora-like fingerpicking), hand percussion (djembe and calabash), and a warm bass line. Slow-medium tempo (90 BPM), spiritual yet danceable energy. Call-and-response vocal samples woven into the texture, evoking griot traditions. Build from sparse to full, then ease out. No sharp transitions, keep a rolling, hypnotic groove.", + "lyrics": "[[A0]]\n[[B1]]\n[16.0:] Ah-lay-la, ay-oh... (ay-oh)\n[:] Ah-lay-la, ay-oh... (ay-oh)\n[:] The spirits wake in the morning light,\n[:] Moving through the sand,\n[:] (Moving through the sand).\n[[C2]]\n[48.0:] Oh-lay-ka, ho-lay-ka!\n[:] Oh-lay-ka, ho-lay-ka!\n[:] Feel the heartbeat in the soil!\n[:] Feel the heartbeat in the soil!\n[:] From the river to the dune!\n[:] We are chanting with the sun!\n[:] We are chanting with the sun!\n[[D3]]\n[80.0:] (Mmm-hmmm...)\n[:] (Aaaa-la-ma...)\n[:] (Aaaa-la-ma...)", + "file": "b548d6a6_Dune_Chant.mp3", + "saved_at": "2026-06-07T13:06:11.955590+00:00" +} \ No newline at end of file diff --git a/songs/b548d6a6_Dune_Chant.mp3 b/songs/b548d6a6_Dune_Chant.mp3 new file mode 100644 index 0000000..e5bbed1 --- /dev/null +++ b/songs/b548d6a6_Dune_Chant.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0963b1400202a11f2ac9a9f2d9bee7330f3a173b0ad63d05d79091f76978f80e +size 2395340 diff --git a/songs/manifest.json b/songs/manifest.json new file mode 100644 index 0000000..1a60a34 --- /dev/null +++ b/songs/manifest.json @@ -0,0 +1,24 @@ +{ + "index": 1, + "count": 2, + "tracks": [ + { + "id": "b51b1cb2", + "title": "Sahara Moon", + "mood": "", + "dj_line": "Restored from cache.", + "lyria_prompt": "", + "lyrics": "", + "file": "b51b1cb2_Sahara_Moon.mp3" + }, + { + "id": "b548d6a6", + "title": "Dune Chant", + "mood": "rolling desert pulse with call-and-response", + "dj_line": "Sahel breeze right through the speakers \u2014 let the sand dance.", + "lyria_prompt": "A 1-2 minute instrumental track in the style of Sahelian desert blues. Acoustic guitar (kora-like fingerpicking), hand percussion (djembe and calabash), and a warm bass line. Slow-medium tempo (90 BPM), spiritual yet danceable energy. Call-and-response vocal samples woven into the texture, evoking griot traditions. Build from sparse to full, then ease out. No sharp transitions, keep a rolling, hypnotic groove.", + "lyrics": "[[A0]]\n[[B1]]\n[16.0:] Ah-lay-la, ay-oh... (ay-oh)\n[:] Ah-lay-la, ay-oh... (ay-oh)\n[:] The spirits wake in the morning light,\n[:] Moving through the sand,\n[:] (Moving through the sand).\n[[C2]]\n[48.0:] Oh-lay-ka, ho-lay-ka!\n[:] Oh-lay-ka, ho-lay-ka!\n[:] Feel the heartbeat in the soil!\n[:] Feel the heartbeat in the soil!\n[:] From the river to the dune!\n[:] We are chanting with the sun!\n[:] We are chanting with the sun!\n[[D3]]\n[80.0:] (Mmm-hmmm...)\n[:] (Aaaa-la-ma...)\n[:] (Aaaa-la-ma...)", + "file": "b548d6a6_Dune_Chant.mp3" + } + ] +} \ No newline at end of file diff --git a/src/ozan_radio/config.py b/src/ozan_radio/config.py index 719a7dc..979ae79 100644 --- a/src/ozan_radio/config.py +++ b/src/ozan_radio/config.py @@ -25,8 +25,11 @@ class Config: @classmethod def from_env(cls) -> Config: - output = Path(os.getenv("RADIO_OUTPUT_DIR", "./radio_cache")) + output = Path(os.getenv("RADIO_OUTPUT_DIR", "./songs")) output.mkdir(parents=True, exist_ok=True) + from ozan_radio.library import migrate_legacy_cache + + migrate_legacy_cache(output) return cls( gemini_api_key=os.getenv("GEMINI_API_KEY", ""), deepseek_base_url=os.getenv( diff --git a/src/ozan_radio/library.py b/src/ozan_radio/library.py new file mode 100644 index 0000000..cacb839 --- /dev/null +++ b/src/ozan_radio/library.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path + +from ozan_radio.lyria import GeneratedTrack + + +def migrate_legacy_cache(output_dir: Path) -> None: + """Move radio_cache/ → songs/ on first run if needed.""" + legacy = output_dir.parent / "radio_cache" + if legacy.exists() and legacy.resolve() != output_dir.resolve(): + output_dir.mkdir(parents=True, exist_ok=True) + for item in legacy.iterdir(): + dest = output_dir / item.name + if not dest.exists(): + item.rename(dest) + + manifest = output_dir / "manifest.json" + if manifest.exists(): + try: + data = json.loads(manifest.read_text(encoding="utf-8")) + for entry in data.get("tracks", []): + meta_path = output_dir / f"{entry['id']}.meta.json" + if meta_path.exists(): + continue + audio = output_dir / entry.get("file", "") + if not audio.exists(): + continue + meta_path.write_text( + json.dumps( + { + "id": entry["id"], + "title": entry.get("title", ""), + "mood": entry.get("mood", ""), + "dj_line": entry.get("dj_line", ""), + "lyria_prompt": entry.get("lyria_prompt", ""), + "lyrics": entry.get("lyrics", ""), + "file": entry.get("file", audio.name), + "saved_at": datetime.fromtimestamp( + audio.stat().st_mtime, tz=timezone.utc + ).isoformat(), + }, + indent=2, + ), + encoding="utf-8", + ) + except (json.JSONDecodeError, OSError, KeyError): + pass + + +def save_track_record(track: GeneratedTrack, output_dir: Path) -> Path: + """Write per-track metadata JSON alongside the MP3.""" + meta_path = output_dir / f"{track.plan.id}.meta.json" + payload = { + "id": track.plan.id, + "title": track.plan.title, + "mood": track.plan.mood, + "dj_line": track.plan.dj_line, + "lyria_prompt": track.plan.lyria_prompt, + "lyrics": track.lyrics, + "file": track.audio_path.name, + "saved_at": datetime.now(timezone.utc).isoformat(), + } + meta_path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + return meta_path + + +def load_track_from_meta(meta_path: Path, output_dir: Path) -> dict | None: + try: + data = json.loads(meta_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return None + + audio = output_dir / data.get("file", "") + if not audio.exists(): + return None + + return { + **data, + "audio_url": f"/stream/{audio.name}", + "size_bytes": audio.stat().st_size, + } + + +def list_saved_songs(output_dir: Path) -> list[dict]: + """All persisted tracks, newest first.""" + songs: list[dict] = [] + seen: set[str] = set() + + for meta_path in sorted(output_dir.glob("*.meta.json"), reverse=True): + entry = load_track_from_meta(meta_path, output_dir) + if entry and entry["id"] not in seen: + seen.add(entry["id"]) + songs.append(entry) + + # MP3s without metadata (legacy) + for mp3 in sorted(output_dir.glob("*.mp3"), reverse=True): + stem = mp3.stem + track_id = stem.split("_", 1)[0] if "_" in stem else stem[:8] + if track_id in seen: + continue + title = stem.split("_", 1)[1].replace("_", " ") if "_" in stem else stem + songs.append( + { + "id": track_id, + "title": title, + "mood": "", + "dj_line": "", + "file": mp3.name, + "audio_url": f"/stream/{mp3.name}", + "size_bytes": mp3.stat().st_size, + "saved_at": datetime.fromtimestamp(mp3.stat().st_mtime, tz=timezone.utc).isoformat(), + } + ) + + songs.sort(key=lambda s: s.get("saved_at", ""), reverse=True) + return songs diff --git a/src/ozan_radio/queue.py b/src/ozan_radio/queue.py index 02d1c6f..ca546b8 100644 --- a/src/ozan_radio/queue.py +++ b/src/ozan_radio/queue.py @@ -5,6 +5,7 @@ from dataclasses import asdict, dataclass from pathlib import Path from ozan_radio.dj import TrackPlan +from ozan_radio.library import save_track_record from ozan_radio.lyria import GeneratedTrack @@ -96,9 +97,18 @@ class RadioQueue: return len(self._tracks) def add(self, track: GeneratedTrack) -> None: + save_track_record(track, self._output_dir) self._tracks.append(track) self._save_manifest() + def play_id(self, track_id: str) -> GeneratedTrack | None: + for i, track in enumerate(self._tracks): + if track.plan.id == track_id: + self._index = i + self._save_manifest() + return track + return None + @property def recent_titles(self) -> list[str]: return [t.plan.title for t in self._tracks] diff --git a/src/ozan_radio/server.py b/src/ozan_radio/server.py index 074c00c..1145875 100644 --- a/src/ozan_radio/server.py +++ b/src/ozan_radio/server.py @@ -14,6 +14,7 @@ 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.library import list_saved_songs from ozan_radio.taste import load_taste_seeds app = FastAPI(title="Live Ozan Radio", version="0.1.0") @@ -86,6 +87,7 @@ async def root() -> dict: "generate": "POST /api/generate", "chat": "POST /api/chat", "chat_log": "/api/chat", + "songs": "/api/songs", "player": "/player", }, } @@ -154,6 +156,42 @@ async def chat_with_dj(body: ChatRequest, background: BackgroundTasks) -> dict: return result +@app.get("/api/songs") +async def saved_songs() -> dict: + cfg = _get_config() + songs = list_saved_songs(cfg.output_dir) + return {"count": len(songs), "songs": songs, "folder": str(cfg.output_dir)} + + +@app.post("/api/songs/{track_id}/play") +async def play_saved(track_id: str) -> dict: + q = _get_queue() + track = q.play_id(track_id) + if not track: + 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), + 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(track_id) + np = q.now_playing() + return {"status": "ok", "track": np.__dict__ if np else None} + raise HTTPException(404, "Song not found") + np = q.now_playing() + return {"status": "ok", "track": np.__dict__ if np else None} + + @app.post("/api/skip") async def skip_track() -> dict: q = _get_queue()