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 @@
No Spotify playback. No catalog tracks. DeepSeek picks the vibe — Google Lyria 3 composes it fresh.
+