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 <cursoragent@cursor.com>
This commit is contained in:
2026-06-07 14:18:17 +01:00
parent 4924db5617
commit b8ff25f370
18 changed files with 357 additions and 12 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+7
View File
@@ -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
+1 -3
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+16 -4
View File
@@ -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
+90
View File
@@ -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; }
</style>
</head>
<body>
@@ -232,6 +270,13 @@
<div class="status" id="status">Connecting…</div>
<section class="library">
<h2>Saved songs</h2>
<div class="library-list" id="libraryList">
<div class="library-empty">Loading library…</div>
</div>
</section>
<section class="chat">
<h2>Talk to the DJ</h2>
<div class="chat-log" id="chatLog"></div>
@@ -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 = '<div class="library-empty">No saved songs yet — generate one!</div>';
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 = `<span>${song.title}</span><span class="song-meta">${sizeMb}</span>`;
btn.addEventListener('click', () => playSong(song.id));
libraryList.appendChild(btn);
}
} catch (_) {
libraryList.innerHTML = '<div class="library-empty">Library offline</div>';
}
}
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);
</script>
Before
After
+18
View File
@@ -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.
+10
View File
@@ -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"
}
Binary file not shown.
+10
View File
@@ -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"
}
Binary file not shown.
+24
View File
@@ -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"
}
]
}
+4 -1
View File
@@ -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(
+119
View File
@@ -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
+10
View File
@@ -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]
+38
View File
@@ -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()