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…
+
+
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()