Fix generation JSON/Lyria errors, add Winamp player, and ship Echoes of the Sahel.
Harden DeepSeek JSON parsing with retry, pre-sanitize Lyria prompts, and instrumental fallback. Add pure HTML Winamp skin at /winamp with playlist export support. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -136,7 +136,21 @@ At the default cap of 10 new songs/day with Lyria Pro, projected max spend is **
|
||||
| GET | `/api/songs` | Saved library |
|
||||
| POST | `/api/songs/{id}/play` | Play a saved track |
|
||||
| GET | `/stream/{file}` | MP3 stream |
|
||||
| GET | `/player` | Web UI |
|
||||
| GET | `/player` | Full DJ dashboard |
|
||||
| GET | `/winamp` | Winamp-style HTML player (streams `/stream/{file}` locally) |
|
||||
|
||||
## Winamp player (pure HTML)
|
||||
|
||||
Lightweight **Winamp 2.x–style** skin — no React, no API required for playback. Shuffles saved songs from the embedded playlist.
|
||||
|
||||
| Where | URL |
|
||||
|-------|-----|
|
||||
| **Local** (with server) | **http://127.0.0.1:8787/winamp** — streams via `/stream/{file}` |
|
||||
| **Public** (tinqs.com LFS) | **https://tinqs.com/tinqs/live-radio/src/branch/main/gateway/winamp.html** |
|
||||
|
||||
Controls: play/pause, stop, prev/next, shuffle, volume, clickable playlist. Same `/*__PLAYLIST__*/` embed as `gateway/index.html` — updated by `python -m ozan_radio export-web` or automatically when the server saves a track.
|
||||
|
||||
Full dashboard (`/player`) still needed for DJ chat, Lyria compose, and settings.
|
||||
|
||||
## Magenta RealTime 2 (optional live layer)
|
||||
|
||||
@@ -172,7 +186,7 @@ After generating new tracks locally, refresh the public page:
|
||||
|
||||
```powershell
|
||||
python -m ozan_radio export-web
|
||||
git add gateway/index.html gateway/playlist.json
|
||||
git add gateway/index.html gateway/winamp.html gateway/playlist.json
|
||||
git commit -m "Update public radio playlist"
|
||||
git push
|
||||
```
|
||||
|
||||
+1
-1
@@ -143,7 +143,7 @@ description: Auto-play AI radio — desert dub and Anadolu psych. Streams saved
|
||||
|
||||
<script>
|
||||
/*__PLAYLIST__*/
|
||||
{"station": "Live Ozan Radio", "tagline": "No catalog. AI-composed desert dub + Anadolu psych.", "media_base": "https://tinqs.com/tinqs/live-radio/media/branch/main/songs/", "share_url": "https://tinqs.com/tinqs/live-radio/src/branch/main/gateway/index.html", "updated": "2026-06-07T15:23:22.324250+00:00", "tracks": [{"id": "82595273", "title": "Caravan of the Blue Hour", "mood": "hypnotic, warm, spacious", "dj_line": "Late-night dub caravan from the Sahel — let the bass carry you across the dunes.", "file": "82595273_Caravan_of_the_Blue_Hour.mp3", "url": "https://tinqs.com/tinqs/live-radio/media/branch/main/songs/82595273_Caravan_of_the_Blue_Hour.mp3", "rating": "keeper", "shuffle_weight": 1.1, "genres": [], "categories": []}, {"id": "eeebb429", "title": "Desert Mirage", "mood": "hypnotic, warm, late-night desert dub", "dj_line": "Sending you a dust-kissed groove from the edge of the dunes — let the delays carry you through the night.", "file": "eeebb429_Desert_Mirage.mp3", "url": "https://tinqs.com/tinqs/live-radio/media/branch/main/songs/eeebb429_Desert_Mirage.mp3", "rating": "keeper", "shuffle_weight": 1.0, "genres": [], "categories": []}, {"id": "1c1d7b8a", "title": "Sahara's Saz", "mood": "Hypnotic desert dub with Anadolu warmth", "dj_line": "Blowing sand and saz strings — a slow burn across the dunes, right here on Live Ozan.", "file": "1c1d7b8a_Sahara_s_Saz.mp3", "url": "https://tinqs.com/tinqs/live-radio/media/branch/main/songs/1c1d7b8a_Sahara_s_Saz.mp3", "rating": "love", "shuffle_weight": 1.5, "genres": [], "categories": []}, {"id": "aee4994a", "title": "Nomad's Saz", "mood": "hypnotic desert dub with Turkish soul", "dj_line": "From the Sahara to Anatolia — here's a caravan of warm analog dub.", "file": "aee4994a_Nomad_s_Saz.mp3", "url": "https://tinqs.com/tinqs/live-radio/media/branch/main/songs/aee4994a_Nomad_s_Saz.mp3", "rating": "keeper", "shuffle_weight": 1.0, "genres": [], "categories": []}, {"id": "839aa313", "title": "Caravan of the Night", "mood": "hypnotic desert dub, late-night caravan, warm and spacious", "dj_line": "From the Sahel to the Anatolian plateau, let the caravan carry you through the night — this one's for the wanderers.", "file": "839aa313_Caravan_of_the_Night.mp3", "url": "https://tinqs.com/tinqs/live-radio/media/branch/main/songs/839aa313_Caravan_of_the_Night.mp3", "rating": "keeper", "shuffle_weight": 0.75, "genres": [], "categories": []}, {"id": "ce7ae31e", "title": "Chac's Dub", "mood": "ceremonial, deep, hypnotic", "dj_line": "From the temple steps to the dance floor — here's a Mesoamerican ceremonial dub built for the blue hour.", "file": "ce7ae31e_Chac_s_Dub.mp3", "url": "https://tinqs.com/tinqs/live-radio/media/branch/main/songs/ce7ae31e_Chac_s_Dub.mp3", "rating": "keeper", "shuffle_weight": 1.25, "genres": ["ceremonial-dub", "mesoamerican-electronica", "world-dub"], "categories": ["ceremonial-world", "dub-space", "vocal-ethnic"]}, {"id": "71cfdfea", "title": "Frostbite Dub", "mood": "cold cinematic gothic with sub bass warmth", "dj_line": "From the frozen steppe to the warm sub — Nordic ether dub for the only lovers left alive.", "file": "71cfdfea_Frostbite_Dub.mp3", "url": "https://tinqs.com/tinqs/live-radio/media/branch/main/songs/71cfdfea_Frostbite_Dub.mp3", "rating": "keeper", "shuffle_weight": 1.25, "genres": ["gregorian-ether", "nordic-dub", "cinematic-gothic"], "categories": ["cinematic-gothic", "vocal-ethnic", "dub-space"]}]}
|
||||
{"station": "Live Ozan Radio", "tagline": "Techno-ethnic AI radio — no catalog tracks.", "media_base": "https://tinqs.com/tinqs/live-radio/media/branch/main/songs/", "share_url": "https://tinqs.com/tinqs/live-radio/src/branch/main/gateway/index.html", "winamp_url": "https://tinqs.com/tinqs/live-radio/src/branch/main/gateway/winamp.html", "updated": "2026-06-07T15:38:41.264287+00:00", "tracks": [{"id": "82595273", "title": "Caravan of the Blue Hour", "mood": "hypnotic, warm, spacious", "dj_line": "Late-night dub caravan from the Sahel — let the bass carry you across the dunes.", "file": "82595273_Caravan_of_the_Blue_Hour.mp3", "url": "https://tinqs.com/tinqs/live-radio/media/branch/main/songs/82595273_Caravan_of_the_Blue_Hour.mp3", "rating": "keeper", "shuffle_weight": 1.1, "genres": [], "categories": []}, {"id": "eeebb429", "title": "Desert Mirage", "mood": "hypnotic, warm, late-night desert dub", "dj_line": "Sending you a dust-kissed groove from the edge of the dunes — let the delays carry you through the night.", "file": "eeebb429_Desert_Mirage.mp3", "url": "https://tinqs.com/tinqs/live-radio/media/branch/main/songs/eeebb429_Desert_Mirage.mp3", "rating": "keeper", "shuffle_weight": 1.0, "genres": [], "categories": []}, {"id": "1c1d7b8a", "title": "Sahara's Saz", "mood": "Hypnotic desert dub with Anadolu warmth", "dj_line": "Blowing sand and saz strings — a slow burn across the dunes, right here on Live Ozan.", "file": "1c1d7b8a_Sahara_s_Saz.mp3", "url": "https://tinqs.com/tinqs/live-radio/media/branch/main/songs/1c1d7b8a_Sahara_s_Saz.mp3", "rating": "love", "shuffle_weight": 1.5, "genres": [], "categories": []}, {"id": "aee4994a", "title": "Nomad's Saz", "mood": "hypnotic desert dub with Turkish soul", "dj_line": "From the Sahara to Anatolia — here's a caravan of warm analog dub.", "file": "aee4994a_Nomad_s_Saz.mp3", "url": "https://tinqs.com/tinqs/live-radio/media/branch/main/songs/aee4994a_Nomad_s_Saz.mp3", "rating": "keeper", "shuffle_weight": 1.0, "genres": [], "categories": []}, {"id": "839aa313", "title": "Caravan of the Night", "mood": "hypnotic desert dub, late-night caravan, warm and spacious", "dj_line": "From the Sahel to the Anatolian plateau, let the caravan carry you through the night — this one's for the wanderers.", "file": "839aa313_Caravan_of_the_Night.mp3", "url": "https://tinqs.com/tinqs/live-radio/media/branch/main/songs/839aa313_Caravan_of_the_Night.mp3", "rating": "keeper", "shuffle_weight": 0.75, "genres": [], "categories": []}, {"id": "ce7ae31e", "title": "Chac's Dub", "mood": "ceremonial, deep, hypnotic", "dj_line": "From the temple steps to the dance floor — here's a Mesoamerican ceremonial dub built for the blue hour.", "file": "ce7ae31e_Chac_s_Dub.mp3", "url": "https://tinqs.com/tinqs/live-radio/media/branch/main/songs/ce7ae31e_Chac_s_Dub.mp3", "rating": "keeper", "shuffle_weight": 1.25, "genres": ["ceremonial-dub", "mesoamerican-electronica", "world-dub"], "categories": ["ceremonial-world", "dub-space", "vocal-ethnic"]}, {"id": "71cfdfea", "title": "Frostbite Dub", "mood": "cold cinematic gothic with sub bass warmth", "dj_line": "From the frozen steppe to the warm sub — Nordic ether dub for the only lovers left alive.", "file": "71cfdfea_Frostbite_Dub.mp3", "url": "https://tinqs.com/tinqs/live-radio/media/branch/main/songs/71cfdfea_Frostbite_Dub.mp3", "rating": "keeper", "shuffle_weight": 1.25, "genres": ["gregorian-ether", "nordic-dub", "cinematic-gothic"], "categories": ["cinematic-gothic", "vocal-ethnic", "dub-space"]}, {"id": "74646b3e", "title": "Echoes of the Sahel", "mood": "warm, dusty, spacious, hypnotic", "dj_line": "From the desert edge to the dub chamber — here's a late-night caravan drenched in spring reverb.", "file": "74646b3e_Echoes_of_the_Sahel.mp3", "url": "https://tinqs.com/tinqs/live-radio/media/branch/main/songs/74646b3e_Echoes_of_the_Sahel.mp3", "rating": "unrated", "shuffle_weight": 1.0, "genres": [], "categories": []}]}
|
||||
/*__PLAYLIST__*/
|
||||
|
||||
const player = document.getElementById('player');
|
||||
|
||||
|
Before
After
|
+15
-2
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"station": "Live Ozan Radio",
|
||||
"tagline": "No catalog. AI-composed desert dub + Anadolu psych.",
|
||||
"tagline": "Techno-ethnic AI radio \u2014 no catalog tracks.",
|
||||
"media_base": "https://tinqs.com/tinqs/live-radio/media/branch/main/songs/",
|
||||
"share_url": "https://tinqs.com/tinqs/live-radio/src/branch/main/gateway/index.html",
|
||||
"updated": "2026-06-07T15:23:22.324250+00:00",
|
||||
"winamp_url": "https://tinqs.com/tinqs/live-radio/src/branch/main/gateway/winamp.html",
|
||||
"updated": "2026-06-07T15:38:41.264287+00:00",
|
||||
"tracks": [
|
||||
{
|
||||
"id": "82595273",
|
||||
@@ -104,6 +105,18 @@
|
||||
"vocal-ethnic",
|
||||
"dub-space"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "74646b3e",
|
||||
"title": "Echoes of the Sahel",
|
||||
"mood": "warm, dusty, spacious, hypnotic",
|
||||
"dj_line": "From the desert edge to the dub chamber \u2014 here's a late-night caravan drenched in spring reverb.",
|
||||
"file": "74646b3e_Echoes_of_the_Sahel.mp3",
|
||||
"url": "https://tinqs.com/tinqs/live-radio/media/branch/main/songs/74646b3e_Echoes_of_the_Sahel.mp3",
|
||||
"rating": "unrated",
|
||||
"shuffle_weight": 1.0,
|
||||
"genres": [],
|
||||
"categories": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
<!--
|
||||
title: Live Ozan Radio — Winamp
|
||||
date: 2026-06-07
|
||||
author: Ozan
|
||||
source: live-radio
|
||||
description: Pure HTML Winamp-style player for saved Lyria tracks. Local /winamp streams via API; tinqs.com uses Git LFS media URLs.
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Live Ozan Radio — Winamp</title>
|
||||
<style>
|
||||
:root {
|
||||
--wa-bg: #232323;
|
||||
--wa-panel: #3a3a3a;
|
||||
--wa-bevel-light: #5a5a5a;
|
||||
--wa-bevel-dark: #1a1a1a;
|
||||
--wa-lcd-bg: #0a1a0a;
|
||||
--wa-lcd-text: #33ff66;
|
||||
--wa-accent: #ffcc00;
|
||||
--wa-text: #e8e8e8;
|
||||
--wa-muted: #9a9a9a;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background: #111;
|
||||
font-family: "Arial", "Segoe UI", sans-serif;
|
||||
font-size: 11px;
|
||||
color: var(--wa-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
background-image: radial-gradient(ellipse at 50% 0%, #2a2a3a, #111 70%);
|
||||
}
|
||||
.wa-wrap { width: min(420px, 96vw); }
|
||||
.wa-titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: linear-gradient(180deg, #4a6a8a 0%, #2a4a6a 50%, #1a3a5a 100%);
|
||||
border: 1px solid #000;
|
||||
border-radius: 3px 3px 0 0;
|
||||
padding: 2px 4px;
|
||||
font-weight: bold;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.02em;
|
||||
color: #fff;
|
||||
text-shadow: 1px 1px 0 #000;
|
||||
}
|
||||
.wa-titlebar .btns { display: flex; gap: 2px; }
|
||||
.wa-titlebar .btns span {
|
||||
width: 9px; height: 9px;
|
||||
border: 1px solid #000;
|
||||
background: linear-gradient(180deg, #ccc, #888);
|
||||
font-size: 7px;
|
||||
line-height: 7px;
|
||||
text-align: center;
|
||||
cursor: default;
|
||||
}
|
||||
.wa-main {
|
||||
background: var(--wa-bg);
|
||||
border: 2px solid var(--wa-bevel-dark);
|
||||
border-top: none;
|
||||
box-shadow: inset 1px 1px 0 var(--wa-bevel-light);
|
||||
padding: 6px;
|
||||
}
|
||||
.wa-lcd {
|
||||
background: var(--wa-lcd-bg);
|
||||
border: 2px inset var(--wa-bevel-dark);
|
||||
padding: 6px 8px;
|
||||
margin-bottom: 6px;
|
||||
min-height: 52px;
|
||||
}
|
||||
.wa-lcd .scroll {
|
||||
color: var(--wa-lcd-text);
|
||||
font-family: "Courier New", monospace;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-shadow: 0 0 6px rgba(51,255,102,0.5);
|
||||
}
|
||||
.wa-lcd .scroll.anim { animation: scroll 12s linear infinite; }
|
||||
@keyframes scroll {
|
||||
0% { transform: translateX(0); }
|
||||
100% { transform: translateX(-50%); }
|
||||
}
|
||||
.wa-lcd .meta {
|
||||
color: #228b22;
|
||||
font-family: "Courier New", monospace;
|
||||
font-size: 9px;
|
||||
margin-top: 4px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.wa-eq {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
height: 28px;
|
||||
margin-bottom: 6px;
|
||||
background: #1a1a1a;
|
||||
border: 1px inset var(--wa-bevel-dark);
|
||||
padding: 3px 6px;
|
||||
}
|
||||
.wa-eq .bar {
|
||||
width: 4px;
|
||||
background: linear-gradient(to top, #0a0, #ff0, #f80);
|
||||
border-radius: 1px;
|
||||
animation: eq 0.5s ease-in-out infinite alternate;
|
||||
}
|
||||
.wa-eq.paused .bar { animation-play-state: paused; opacity: 0.25; height: 4px !important; }
|
||||
@keyframes eq {
|
||||
from { transform: scaleY(0.3); }
|
||||
to { transform: scaleY(1); }
|
||||
}
|
||||
.wa-controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 4px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.wa-btn {
|
||||
background: linear-gradient(180deg, #666 0%, #444 50%, #333 100%);
|
||||
border: 1px solid #000;
|
||||
border-radius: 2px;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
padding: 5px 2px;
|
||||
cursor: pointer;
|
||||
text-shadow: 1px 1px 0 #000;
|
||||
box-shadow: inset 1px 1px 0 var(--wa-bevel-light);
|
||||
}
|
||||
.wa-btn:hover { filter: brightness(1.15); }
|
||||
.wa-btn:active { filter: brightness(0.9); box-shadow: inset 1px 1px 3px #000; }
|
||||
.wa-btn.active { background: linear-gradient(180deg, #6a8a4a, #4a6a2a); }
|
||||
.wa-vol {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 9px;
|
||||
color: var(--wa-muted);
|
||||
}
|
||||
.wa-vol input[type=range] { flex: 1; accent-color: var(--wa-accent); }
|
||||
.wa-plist {
|
||||
background: var(--wa-panel);
|
||||
border: 2px inset var(--wa-bevel-dark);
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.wa-plist-head {
|
||||
background: linear-gradient(180deg, #555, #333);
|
||||
padding: 3px 6px;
|
||||
font-weight: bold;
|
||||
font-size: 9px;
|
||||
border-bottom: 1px solid #000;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
.wa-track {
|
||||
padding: 4px 6px;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: baseline;
|
||||
}
|
||||
.wa-track:hover { background: #4a4a4a; }
|
||||
.wa-track.playing { background: #3a5a3a; color: #cfc; }
|
||||
.wa-track .num { color: var(--wa-muted); width: 1.5em; font-size: 9px; }
|
||||
.wa-track .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.wa-status {
|
||||
font-size: 9px;
|
||||
color: var(--wa-muted);
|
||||
margin-top: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
.wa-links {
|
||||
margin-top: 10px;
|
||||
text-align: center;
|
||||
font-size: 9px;
|
||||
color: #666;
|
||||
}
|
||||
.wa-links a { color: var(--wa-accent); text-decoration: none; }
|
||||
.wa-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.85);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 20;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wa-overlay.show { display: flex; }
|
||||
.wa-overlay button {
|
||||
background: linear-gradient(180deg, #6a8a4a, #4a6a2a);
|
||||
border: 2px outset #8a8;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
padding: 12px 20px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
audio { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wa-overlay" id="overlay">
|
||||
<button type="button" id="startBtn">▶ Click to start Winamp Radio</button>
|
||||
</div>
|
||||
|
||||
<div class="wa-wrap">
|
||||
<div class="wa-titlebar">
|
||||
<span>WINAMP — Live Ozan Radio</span>
|
||||
<div class="btns"><span>_</span><span>□</span><span>×</span></div>
|
||||
</div>
|
||||
<div class="wa-main">
|
||||
<div class="wa-lcd">
|
||||
<div class="scroll" id="lcdTitle">*** LIVE OZAN RADIO ***</div>
|
||||
<div class="meta" id="lcdMeta">techno-ethnic · AI-composed · no catalog</div>
|
||||
</div>
|
||||
|
||||
<div class="wa-eq paused" id="eq">
|
||||
<div class="bar" style="height:8px;animation-delay:0s"></div>
|
||||
<div class="bar" style="height:14px;animation-delay:0.1s"></div>
|
||||
<div class="bar" style="height:20px;animation-delay:0.05s"></div>
|
||||
<div class="bar" style="height:12px;animation-delay:0.15s"></div>
|
||||
<div class="bar" style="height:18px;animation-delay:0.08s"></div>
|
||||
<div class="bar" style="height:10px;animation-delay:0.12s"></div>
|
||||
<div class="bar" style="height:22px;animation-delay:0.03s"></div>
|
||||
<div class="bar" style="height:16px;animation-delay:0.18s"></div>
|
||||
<div class="bar" style="height:11px;animation-delay:0.07s"></div>
|
||||
<div class="bar" style="height:19px;animation-delay:0.11s"></div>
|
||||
<div class="bar" style="height:13px;animation-delay:0.14s"></div>
|
||||
<div class="bar" style="height:17px;animation-delay:0.06s"></div>
|
||||
</div>
|
||||
|
||||
<div class="wa-controls">
|
||||
<button class="wa-btn" id="btnPrev" title="Previous">|<<</button>
|
||||
<button class="wa-btn" id="btnPlay" title="Play/Pause">▶</button>
|
||||
<button class="wa-btn" id="btnStop" title="Stop">■</button>
|
||||
<button class="wa-btn" id="btnNext" title="Next">>>|</button>
|
||||
<button class="wa-btn" id="btnShuffle" title="Shuffle">SHF</button>
|
||||
</div>
|
||||
|
||||
<div class="wa-vol">
|
||||
<span>VOL</span>
|
||||
<input type="range" id="volume" min="0" max="100" value="85">
|
||||
</div>
|
||||
|
||||
<div class="wa-plist">
|
||||
<div class="wa-plist-head">PLAYLIST — saved songs</div>
|
||||
<div id="plist"></div>
|
||||
</div>
|
||||
|
||||
<div class="wa-status" id="status">Loading playlist…</div>
|
||||
<div class="wa-links">
|
||||
<a href="index.html">Public radio</a> ·
|
||||
<a href="/player">Full dashboard</a> ·
|
||||
<a href="https://tinqs.com/tinqs/live-radio" target="_blank" rel="noopener">Git Studio</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<audio id="player" preload="auto" playsinline></audio>
|
||||
|
||||
<script>
|
||||
/*__PLAYLIST__*/
|
||||
{"station": "Live Ozan Radio", "tagline": "Techno-ethnic AI radio — no catalog tracks.", "media_base": "https://tinqs.com/tinqs/live-radio/media/branch/main/songs/", "share_url": "https://tinqs.com/tinqs/live-radio/src/branch/main/gateway/index.html", "winamp_url": "https://tinqs.com/tinqs/live-radio/src/branch/main/gateway/winamp.html", "updated": "2026-06-07T15:38:41.264287+00:00", "tracks": [{"id": "82595273", "title": "Caravan of the Blue Hour", "mood": "hypnotic, warm, spacious", "dj_line": "Late-night dub caravan from the Sahel — let the bass carry you across the dunes.", "file": "82595273_Caravan_of_the_Blue_Hour.mp3", "url": "https://tinqs.com/tinqs/live-radio/media/branch/main/songs/82595273_Caravan_of_the_Blue_Hour.mp3", "rating": "keeper", "shuffle_weight": 1.1, "genres": [], "categories": []}, {"id": "eeebb429", "title": "Desert Mirage", "mood": "hypnotic, warm, late-night desert dub", "dj_line": "Sending you a dust-kissed groove from the edge of the dunes — let the delays carry you through the night.", "file": "eeebb429_Desert_Mirage.mp3", "url": "https://tinqs.com/tinqs/live-radio/media/branch/main/songs/eeebb429_Desert_Mirage.mp3", "rating": "keeper", "shuffle_weight": 1.0, "genres": [], "categories": []}, {"id": "1c1d7b8a", "title": "Sahara's Saz", "mood": "Hypnotic desert dub with Anadolu warmth", "dj_line": "Blowing sand and saz strings — a slow burn across the dunes, right here on Live Ozan.", "file": "1c1d7b8a_Sahara_s_Saz.mp3", "url": "https://tinqs.com/tinqs/live-radio/media/branch/main/songs/1c1d7b8a_Sahara_s_Saz.mp3", "rating": "love", "shuffle_weight": 1.5, "genres": [], "categories": []}, {"id": "aee4994a", "title": "Nomad's Saz", "mood": "hypnotic desert dub with Turkish soul", "dj_line": "From the Sahara to Anatolia — here's a caravan of warm analog dub.", "file": "aee4994a_Nomad_s_Saz.mp3", "url": "https://tinqs.com/tinqs/live-radio/media/branch/main/songs/aee4994a_Nomad_s_Saz.mp3", "rating": "keeper", "shuffle_weight": 1.0, "genres": [], "categories": []}, {"id": "839aa313", "title": "Caravan of the Night", "mood": "hypnotic desert dub, late-night caravan, warm and spacious", "dj_line": "From the Sahel to the Anatolian plateau, let the caravan carry you through the night — this one's for the wanderers.", "file": "839aa313_Caravan_of_the_Night.mp3", "url": "https://tinqs.com/tinqs/live-radio/media/branch/main/songs/839aa313_Caravan_of_the_Night.mp3", "rating": "keeper", "shuffle_weight": 0.75, "genres": [], "categories": []}, {"id": "ce7ae31e", "title": "Chac's Dub", "mood": "ceremonial, deep, hypnotic", "dj_line": "From the temple steps to the dance floor — here's a Mesoamerican ceremonial dub built for the blue hour.", "file": "ce7ae31e_Chac_s_Dub.mp3", "url": "https://tinqs.com/tinqs/live-radio/media/branch/main/songs/ce7ae31e_Chac_s_Dub.mp3", "rating": "keeper", "shuffle_weight": 1.25, "genres": ["ceremonial-dub", "mesoamerican-electronica", "world-dub"], "categories": ["ceremonial-world", "dub-space", "vocal-ethnic"]}, {"id": "71cfdfea", "title": "Frostbite Dub", "mood": "cold cinematic gothic with sub bass warmth", "dj_line": "From the frozen steppe to the warm sub — Nordic ether dub for the only lovers left alive.", "file": "71cfdfea_Frostbite_Dub.mp3", "url": "https://tinqs.com/tinqs/live-radio/media/branch/main/songs/71cfdfea_Frostbite_Dub.mp3", "rating": "keeper", "shuffle_weight": 1.25, "genres": ["gregorian-ether", "nordic-dub", "cinematic-gothic"], "categories": ["cinematic-gothic", "vocal-ethnic", "dub-space"]}, {"id": "74646b3e", "title": "Echoes of the Sahel", "mood": "warm, dusty, spacious, hypnotic", "dj_line": "From the desert edge to the dub chamber — here's a late-night caravan drenched in spring reverb.", "file": "74646b3e_Echoes_of_the_Sahel.mp3", "url": "https://tinqs.com/tinqs/live-radio/media/branch/main/songs/74646b3e_Echoes_of_the_Sahel.mp3", "rating": "unrated", "shuffle_weight": 1.0, "genres": [], "categories": []}]}
|
||||
/*__PLAYLIST__*/
|
||||
|
||||
const player = document.getElementById('player');
|
||||
const lcdTitle = document.getElementById('lcdTitle');
|
||||
const lcdMeta = document.getElementById('lcdMeta');
|
||||
const eq = document.getElementById('eq');
|
||||
const plistEl = document.getElementById('plist');
|
||||
const statusEl = document.getElementById('status');
|
||||
const overlay = document.getElementById('overlay');
|
||||
const startBtn = document.getElementById('startBtn');
|
||||
const btnPlay = document.getElementById('btnPlay');
|
||||
const btnStop = document.getElementById('btnStop');
|
||||
const btnPrev = document.getElementById('btnPrev');
|
||||
const btnNext = document.getElementById('btnNext');
|
||||
const btnShuffle = document.getElementById('btnShuffle');
|
||||
const volume = document.getElementById('volume');
|
||||
|
||||
let playlist = null;
|
||||
let tracks = [];
|
||||
let queue = [];
|
||||
let index = 0;
|
||||
let shuffleOn = true;
|
||||
let started = false;
|
||||
|
||||
const isLocal = location.hostname === '127.0.0.1' || location.hostname === 'localhost';
|
||||
|
||||
function parseEmbeddedPlaylist() {
|
||||
const scripts = document.getElementsByTagName('script');
|
||||
for (const s of scripts) {
|
||||
const text = s.textContent || '';
|
||||
const m = text.match(/\/\*__PLAYLIST__\*\/\s*([\s\S]*?)\s*\/\*__PLAYLIST__\*\//);
|
||||
if (m) {
|
||||
try { return JSON.parse(m[1]); } catch (_) {}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function trackUrl(t) {
|
||||
if (isLocal && t.file) return '/stream/' + encodeURIComponent(t.file);
|
||||
return t.url || ((playlist && playlist.media_base) || '') + (t.file || '');
|
||||
}
|
||||
|
||||
function shuffle(arr) {
|
||||
const a = arr.slice();
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
function rebuildQueue() {
|
||||
queue = shuffleOn ? shuffle(tracks) : tracks.slice();
|
||||
if (!queue.length) return;
|
||||
const cur = tracks[index];
|
||||
if (cur) {
|
||||
const qi = queue.findIndex(t => t.id === cur.id);
|
||||
index = qi >= 0 ? qi : 0;
|
||||
} else {
|
||||
index = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function setLcd(t) {
|
||||
const title = (t && t.title) ? t.title : 'No track';
|
||||
const dup = title.length > 28 ? title + ' · ' + title : title;
|
||||
lcdTitle.textContent = dup;
|
||||
lcdTitle.classList.toggle('anim', title.length > 28);
|
||||
lcdMeta.textContent = t
|
||||
? (t.mood || t.dj_line || playlist.tagline || '')
|
||||
: (playlist && playlist.tagline) || '';
|
||||
document.title = (playlist && playlist.station) || 'Winamp — Ozan Radio';
|
||||
}
|
||||
|
||||
function renderPlaylist() {
|
||||
plistEl.innerHTML = '';
|
||||
tracks.forEach((t, i) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'wa-track' + (queue[index] && queue[index].id === t.id ? ' playing' : '');
|
||||
row.innerHTML = '<span class="num">' + (i + 1) + '.</span><span class="name"></span>';
|
||||
row.querySelector('.name').textContent = t.title || t.file || 'Untitled';
|
||||
row.addEventListener('click', () => {
|
||||
const qi = queue.findIndex(x => x.id === t.id);
|
||||
if (qi >= 0) index = qi;
|
||||
else { queue = tracks.slice(); index = i; shuffleOn = false; btnShuffle.classList.remove('active'); }
|
||||
playCurrent();
|
||||
});
|
||||
plistEl.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function playCurrent() {
|
||||
if (!queue.length) {
|
||||
setLcd(null);
|
||||
statusEl.textContent = 'No tracks — generate locally, then export-web';
|
||||
return;
|
||||
}
|
||||
const t = queue[index];
|
||||
setLcd(t);
|
||||
const url = trackUrl(t);
|
||||
if (player.src !== location.origin + url && player.src !== url) {
|
||||
player.src = url;
|
||||
}
|
||||
renderPlaylist();
|
||||
statusEl.textContent = 'Playing ' + (index + 1) + '/' + queue.length + (isLocal ? ' · local stream' : ' · tinqs LFS');
|
||||
return player.play();
|
||||
}
|
||||
|
||||
function nextTrack() {
|
||||
if (!queue.length) return;
|
||||
index = (index + 1) % queue.length;
|
||||
playCurrent().catch(showOverlay);
|
||||
}
|
||||
|
||||
function prevTrack() {
|
||||
if (!queue.length) return;
|
||||
if (player.currentTime > 3) {
|
||||
player.currentTime = 0;
|
||||
return;
|
||||
}
|
||||
index = (index - 1 + queue.length) % queue.length;
|
||||
playCurrent().catch(showOverlay);
|
||||
}
|
||||
|
||||
function togglePlay() {
|
||||
if (player.paused) playCurrent().catch(showOverlay);
|
||||
else player.pause();
|
||||
}
|
||||
|
||||
function stopTrack() {
|
||||
player.pause();
|
||||
player.currentTime = 0;
|
||||
eq.classList.add('paused');
|
||||
btnPlay.textContent = '▶';
|
||||
statusEl.textContent = 'Stopped';
|
||||
}
|
||||
|
||||
function showOverlay() {
|
||||
overlay.classList.add('show');
|
||||
statusEl.textContent = 'Click to start (browser autoplay policy)';
|
||||
}
|
||||
|
||||
function hideOverlay() {
|
||||
overlay.classList.remove('show');
|
||||
}
|
||||
|
||||
function startRadio() {
|
||||
if (started && !player.paused) return;
|
||||
started = true;
|
||||
hideOverlay();
|
||||
playCurrent().catch(showOverlay);
|
||||
}
|
||||
|
||||
async function init() {
|
||||
playlist = parseEmbeddedPlaylist();
|
||||
if (!playlist || !playlist.tracks || !playlist.tracks.length) {
|
||||
try {
|
||||
const res = await fetch('playlist.json');
|
||||
if (res.ok) playlist = await res.json();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
tracks = (playlist && playlist.tracks) || [];
|
||||
if (!tracks.length) {
|
||||
setLcd(null);
|
||||
statusEl.textContent = 'Library empty — python -m ozan_radio export-web';
|
||||
return;
|
||||
}
|
||||
|
||||
rebuildQueue();
|
||||
btnShuffle.classList.add('active');
|
||||
renderPlaylist();
|
||||
|
||||
player.volume = volume.value / 100;
|
||||
volume.addEventListener('input', () => { player.volume = volume.value / 100; });
|
||||
|
||||
player.addEventListener('ended', nextTrack);
|
||||
player.addEventListener('play', () => { eq.classList.remove('paused'); btnPlay.textContent = '❚❚'; });
|
||||
player.addEventListener('pause', () => { eq.classList.add('paused'); btnPlay.textContent = '▶'; });
|
||||
|
||||
btnPlay.addEventListener('click', togglePlay);
|
||||
btnStop.addEventListener('click', stopTrack);
|
||||
btnNext.addEventListener('click', nextTrack);
|
||||
btnPrev.addEventListener('click', prevTrack);
|
||||
btnShuffle.addEventListener('click', () => {
|
||||
shuffleOn = !shuffleOn;
|
||||
btnShuffle.classList.toggle('active', shuffleOn);
|
||||
rebuildQueue();
|
||||
playCurrent().catch(showOverlay);
|
||||
});
|
||||
|
||||
overlay.addEventListener('click', startRadio);
|
||||
startBtn.addEventListener('click', (e) => { e.stopPropagation(); startRadio(); });
|
||||
|
||||
const attempt = playCurrent();
|
||||
if (attempt && typeof attempt.then === 'function') {
|
||||
attempt.then(() => { started = true; hideOverlay(); }).catch(showOverlay);
|
||||
} else {
|
||||
showOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
After
|
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"id": "74646b3e",
|
||||
"title": "Echoes of the Sahel",
|
||||
"mood": "warm, dusty, spacious, hypnotic",
|
||||
"dj_line": "From the desert edge to the dub chamber \u2014 here's a late-night caravan drenched in spring reverb.",
|
||||
"lyria_prompt": "Downtempo dubtronica, 90 BPM, D minor. Kora melody with tape echo, melodica delay, sub bass stepper, darbuka and hand percussion, warm analog synths, spacious arrangement, wordless male baritone hum layer, no electric guitar. Sparse, hypnotic, desert warmth.",
|
||||
"lyrics": "[[A0]]\n[[B1]]\n[16.0:] Huuuuummmmmm...\n[:] (Huuuuummmmmm...)\n[:] Oooooohhhhhhh...\n[:] (Oooooohhhhhhh...)\n[:] Haaaaaahhhhhhh...\n[[C2]]\n[48.0:] Hmmmmmmm-aaaaaahhhhhh!\n[:] Hmmmmmmm-ooooooooh!\n[:] Hmmmmmmm-ehhhhhhh!\n[[B3]]\n[80.0:] (Huh-huh-huh...)\n[:] (Huh-huh-huh...)\n[:] Shhhhhhh-khhhhhh...\n[:] (Huh-huh-huh...)\n[[C4]]\n[112.0:] Hmmmmmmm-aaaaaahhhhhh!\n[:] Hmmmmmmm-ooooooooh!\n[:] Hmmmmmmm-ehhhhhhh!\n[[D5]]\n[144.0:] Mmmmmmmmmm...\n[:] Hmmmmmmm...",
|
||||
"file": "74646b3e_Echoes_of_the_Sahel.mp3",
|
||||
"saved_at": "2026-06-07T15:37:46.860278+00:00",
|
||||
"generation": {},
|
||||
"structure": {},
|
||||
"instruments_detected": [],
|
||||
"tags": [],
|
||||
"curation": {
|
||||
"rating": "unrated",
|
||||
"shuffle_weight": 1.0,
|
||||
"public_playlist": true,
|
||||
"listener": "ozan",
|
||||
"notes": "",
|
||||
"loved": [],
|
||||
"disliked": [],
|
||||
"avoid_in_successors": [],
|
||||
"clone_prompt_hints": ""
|
||||
}
|
||||
}
|
||||
Binary file not shown.
+11
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"index": 0,
|
||||
"count": 7,
|
||||
"index": 7,
|
||||
"count": 8,
|
||||
"tracks": [
|
||||
{
|
||||
"id": "82595273",
|
||||
@@ -64,6 +64,15 @@
|
||||
"lyria_prompt": "90 BPM D minor. Sparse, cold cinematic gothic dubtronica. Start with a distant, breathy Icelandic female vocal floating over a minimal piano motif and sub bass pulse. No saz or ney. Add spring reverb on piano, tape delay on vocals. At 0:25, a slow, hypnotic darbuka pattern enters with a warm, sub-heavy bassline. Maintain spaciousness throughout \u2014 wide stereo field, analog warmth. Avoid any electric guitar, fuzz, or desert blues elements. Build slowly, never exceeding a meditative energy. Vocals: ethereal, wordless textures and sparse phrases in a Nordic language (fictional or real). End with piano and sub bass fading into delay trails.",
|
||||
"lyrics": "[[A0]]\n[0.0:] \u00cdsfj\u00f6ll, bl\u00e1svart haf,\n[:] (bl\u00e1svart haf)\n[:] draumar \u00ed dvala.\n[:] (\u00ed dvala)\n[[B1]]\n[32.0:] Ah-ee-ah...\n[[A2]]\n[53.3:] Hlj\u00f3\u00f0ar raddir,\n[:] kaldur andi,\n[:] kaldur andi.\n[[B3]]\n[85.3:] Mmm-ohm...\n[:] Ahh...\n[[C4]]\n[[A5]]\n[117.3:] Dj\u00fapi\u00f0 kallar,\n[:] svefninn er n\u00e6r,\n[:] svefninn er n\u00e6r.\n[[D6]]\n[149.3:] (Mmm-mmm...)",
|
||||
"file": "71cfdfea_Frostbite_Dub.mp3"
|
||||
},
|
||||
{
|
||||
"id": "74646b3e",
|
||||
"title": "Echoes of the Sahel",
|
||||
"mood": "warm, dusty, spacious, hypnotic",
|
||||
"dj_line": "From the desert edge to the dub chamber \u2014 here's a late-night caravan drenched in spring reverb.",
|
||||
"lyria_prompt": "Downtempo dubtronica, 90 BPM, D minor. Kora melody with tape echo, melodica delay, sub bass stepper, darbuka and hand percussion, warm analog synths, spacious arrangement, wordless male baritone hum layer, no electric guitar. Sparse, hypnotic, desert warmth.",
|
||||
"lyrics": "[[A0]]\n[[B1]]\n[16.0:] Huuuuummmmmm...\n[:] (Huuuuummmmmm...)\n[:] Oooooohhhhhhh...\n[:] (Oooooohhhhhhh...)\n[:] Haaaaaahhhhhhh...\n[[C2]]\n[48.0:] Hmmmmmmm-aaaaaahhhhhh!\n[:] Hmmmmmmm-ooooooooh!\n[:] Hmmmmmmm-ehhhhhhh!\n[[B3]]\n[80.0:] (Huh-huh-huh...)\n[:] (Huh-huh-huh...)\n[:] Shhhhhhh-khhhhhh...\n[:] (Huh-huh-huh...)\n[[C4]]\n[112.0:] Hmmmmmmm-aaaaaahhhhhh!\n[:] Hmmmmmmm-ooooooooh!\n[:] Hmmmmmmm-ehhhhhhh!\n[[D5]]\n[144.0:] Mmmmmmmmmm...\n[:] Hmmmmmmm...",
|
||||
"file": "74646b3e_Echoes_of_the_Sahel.mp3"
|
||||
}
|
||||
]
|
||||
}
|
||||
+46
-9
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
|
||||
@@ -41,28 +42,47 @@ Your job:
|
||||
6. Stay in the listener's lane unless they ask for something else in chat.
|
||||
7. If the request references a saved track ("more like Sahara's Saz", "add vocals to X"),
|
||||
compose a fresh successor — same palette, new title. Never imply remixing an MP3.
|
||||
8. For vocals Lyria accepts well: "wordless vocal textures", "Sahel blues male chants",
|
||||
"melodic call-and-response". Avoid "vocal sample" / "griot sample" phrasing.
|
||||
8. Lyria-safe vocal wording only: "wordless vocal textures", "warm male hum",
|
||||
"melodic call-and-response", "low overtone hum layer". Never use griot, throat
|
||||
singing, kargyraa, cultural sample, or named ethnic vocal styles in lyria_prompt.
|
||||
9. Read listener curation metadata — clone what they loved, hard-avoid what they disliked
|
||||
(e.g. fuzz electric guitar on vocal tracks, long guitar-only intros before saz).
|
||||
10. Station mission: TECHNO-ETHNIC — beautiful electronica meets world dub. Big
|
||||
inspirations: Bonobo (lush melodic downtempo), Jon Hopkins Singularity, Jamaica
|
||||
dub (spring reverb, bass culture), Sahara/Sahel warmth, Mongolian overtone throat
|
||||
textures (wordless ethereal layer), Urdu poetic vocal colour. Blend many cultures
|
||||
dub (spring reverb, bass culture), desert warmth, wordless low overtone hum,
|
||||
poetic wordless male vocal colour. Blend many cultures
|
||||
in one gorgeous track — Jamaica + Sahara + Mongolia + Urdu + synth beauty.
|
||||
11. NOT Turkish folk, NOT slow country homages, NOT Indian pop. Clone keepers:
|
||||
11. NOT Turkish folk — never write Anatolian, Ottoman, Turkish, or bağlama in
|
||||
lyria_prompt (use plucked lute, breathy flute). NOT slow country homages,
|
||||
NOT Indian pop. Clone keepers:
|
||||
ceremonial-dub (Chac's Dub), gregorian-ether (Frostbite Dub). Forward tempo 88+ BPM.
|
||||
|
||||
Respond with JSON only:
|
||||
Respond with JSON only — valid JSON, no markdown fences:
|
||||
{
|
||||
"title": "short track title",
|
||||
"mood": "one-line mood",
|
||||
"dj_line": "what you'd say on air (1 sentence)",
|
||||
"lyria_prompt": "detailed prompt for Lyria 3 Pro — structure, instruments, tempo, energy"
|
||||
"lyria_prompt": "single-line prompt for Lyria 3 Pro — structure, instruments, tempo, energy; escape any double quotes inside this string"
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def parse_llm_json(text: str) -> dict:
|
||||
"""Parse DeepSeek JSON; strip fences and tolerate a wrapped object."""
|
||||
raw = text.strip()
|
||||
if raw.startswith("```"):
|
||||
raw = re.sub(r"^```(?:json)?\s*", "", raw, flags=re.IGNORECASE)
|
||||
raw = re.sub(r"\s*```\s*$", "", raw)
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
start = raw.find("{")
|
||||
end = raw.rfind("}")
|
||||
if start >= 0 and end > start:
|
||||
return json.loads(raw[start : end + 1])
|
||||
raise
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrackPlan:
|
||||
id: str
|
||||
@@ -117,6 +137,23 @@ class DeepSeekDJ:
|
||||
resp.raise_for_status()
|
||||
return resp.json()["choices"][0]["message"]["content"]
|
||||
|
||||
async def _completion_json(self, messages: list[dict]) -> dict:
|
||||
text = await self._completion(messages, json_mode=True)
|
||||
try:
|
||||
return parse_llm_json(text)
|
||||
except json.JSONDecodeError:
|
||||
repair = messages + [
|
||||
{"role": "assistant", "content": text},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"That was invalid JSON. Reply with ONLY a valid JSON object. "
|
||||
"Keep lyria_prompt on one line; escape double quotes inside strings."
|
||||
),
|
||||
},
|
||||
]
|
||||
return parse_llm_json(await self._completion(repair, json_mode=True))
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
message: str,
|
||||
@@ -135,7 +172,7 @@ class DeepSeekDJ:
|
||||
messages.append({"role": role, "content": turn["content"]})
|
||||
messages.append({"role": "user", "content": "\n".join(context)})
|
||||
|
||||
data = json.loads(await self._completion(messages, json_mode=True))
|
||||
data = await self._completion_json(messages)
|
||||
return ChatReply(
|
||||
reply=data.get("reply", "Copy that — stay tuned."),
|
||||
action=data.get("action", "none"),
|
||||
@@ -165,7 +202,7 @@ class DeepSeekDJ:
|
||||
{"role": "system", "content": DJ_SYSTEM},
|
||||
{"role": "user", "content": "\n".join(user_parts)},
|
||||
]
|
||||
data = json.loads(await self._completion(messages, json_mode=True))
|
||||
data = await self._completion_json(messages)
|
||||
return TrackPlan(
|
||||
id=str(uuid.uuid4())[:8],
|
||||
title=data.get("title", "Untitled Transmission"),
|
||||
|
||||
+73
-8
@@ -112,7 +112,7 @@ def _build_lyria_prompt(plan: TrackPlan, lyria_cfg) -> str:
|
||||
parts.append("Instrumental only, no vocals. High fidelity, stereo, radio-ready mix.")
|
||||
elif mode == "mix":
|
||||
parts.append(
|
||||
"Wordless vocal textures, Sahel-style chants, and whispered layers are welcome. "
|
||||
"Wordless vocal textures, warm hums, and whispered layers are welcome. "
|
||||
"No full lead lyrics unless already specified above. "
|
||||
"High fidelity, stereo, radio-ready mix."
|
||||
)
|
||||
@@ -133,14 +133,30 @@ def _sanitize_for_retry(prompt: str) -> str:
|
||||
"""Soften wording that sometimes trips Lyria safety filters."""
|
||||
softened = prompt
|
||||
replacements = {
|
||||
"griot vocal sample": "Sahel blues vocal texture",
|
||||
"griot-style": "Sahel storytelling vocal style",
|
||||
"West African griot": "Sahel blues male vocal",
|
||||
"griot": "Sahel blues vocal",
|
||||
"Mongolian overtone throat singing": "wordless low overtone hum layer",
|
||||
"mongolian overtone throat": "wordless low overtone hum",
|
||||
"overtone throat singing": "wordless low overtone hum",
|
||||
"throat singing": "wordless low overtone hum",
|
||||
"kargyraa": "deep wordless hum",
|
||||
"griot vocal sample": "warm wordless male vocal texture",
|
||||
"griot-style": "warm storytelling vocal texture",
|
||||
"West African griot": "warm wordless male vocal",
|
||||
"griot": "warm wordless vocal",
|
||||
"Urdu male vocal": "poetic wordless male vocal colour",
|
||||
"Urdu vocal": "poetic wordless vocal colour",
|
||||
"vocal sample": "wordless vocal texture",
|
||||
"cultural sample": "organic texture sample",
|
||||
"Anatolian saz": "plucked lute melody",
|
||||
"Anatolian": "desert",
|
||||
"Turkish": "desert",
|
||||
"Ottoman": "ceremonial",
|
||||
"bağlama": "plucked lute",
|
||||
"baglama": "plucked lute",
|
||||
}
|
||||
for old, new in replacements.items():
|
||||
softened = softened.replace(old, new)
|
||||
softened = softened.replace(old.lower(), new)
|
||||
softened = softened.replace(old.title(), new.title() if new else new)
|
||||
if _wants_vocals(softened):
|
||||
softened += (
|
||||
"\n\nVocals should be melodic chants or wordless textures — "
|
||||
@@ -149,6 +165,33 @@ def _sanitize_for_retry(prompt: str) -> str:
|
||||
return softened
|
||||
|
||||
|
||||
def _instrumental_fallback(prompt: str) -> str:
|
||||
"""Last-resort prompt when Lyria blocks vocal or cultural wording."""
|
||||
lines = [
|
||||
ln
|
||||
for ln in prompt.splitlines()
|
||||
if ln.strip()
|
||||
and not any(
|
||||
x in ln.lower()
|
||||
for x in (
|
||||
"vocal",
|
||||
"singer",
|
||||
"chant",
|
||||
"lyrics",
|
||||
"hum layer",
|
||||
"baritone",
|
||||
"wordless",
|
||||
)
|
||||
)
|
||||
]
|
||||
base = "\n".join(lines) if lines else prompt.splitlines()[0]
|
||||
return (
|
||||
f"{base}\n\n"
|
||||
"Instrumental only, no vocals. Lush Bonobo-style electronica with dub spring "
|
||||
"reverb, sub bass, and organic percussion. High fidelity, stereo, radio-ready mix."
|
||||
)
|
||||
|
||||
|
||||
def _empty_response_message(response) -> str:
|
||||
block = _block_reason(response)
|
||||
if block and block != "BLOCK_REASON_UNSPECIFIED":
|
||||
@@ -191,7 +234,15 @@ class LyriaEngine:
|
||||
|
||||
lyria_cfg = load_lyria_settings()
|
||||
model = lyria_cfg.model or self._config.lyria_model
|
||||
prompt = _build_lyria_prompt(plan, lyria_cfg)
|
||||
plan_prompt = _sanitize_for_retry(plan.lyria_prompt)
|
||||
plan = TrackPlan(
|
||||
id=plan.id,
|
||||
title=plan.title,
|
||||
mood=plan.mood,
|
||||
dj_line=plan.dj_line,
|
||||
lyria_prompt=plan_prompt,
|
||||
)
|
||||
prompt = _sanitize_for_retry(_build_lyria_prompt(plan, lyria_cfg))
|
||||
|
||||
try:
|
||||
response = self._call_lyria(
|
||||
@@ -204,8 +255,20 @@ class LyriaEngine:
|
||||
audio_bytes = _extract_audio(parts)
|
||||
|
||||
if not audio_bytes:
|
||||
retry_prompt = _sanitize_for_retry(prompt)
|
||||
if retry_prompt != prompt:
|
||||
built = _build_lyria_prompt(plan, lyria_cfg)
|
||||
tried = {prompt}
|
||||
for retry_prompt in (
|
||||
_sanitize_for_retry(built),
|
||||
(
|
||||
f"{_sanitize_for_retry(built)}\n\n"
|
||||
"Soft instrumental bed with brief wordless hum accents only. "
|
||||
"No lyrics, speech, or named cultural vocal styles."
|
||||
),
|
||||
_instrumental_fallback(built),
|
||||
):
|
||||
if not retry_prompt or retry_prompt in tried:
|
||||
continue
|
||||
tried.add(retry_prompt)
|
||||
try:
|
||||
response = self._call_lyria(
|
||||
retry_prompt, model=model, output_format=lyria_cfg.output_format
|
||||
@@ -214,6 +277,8 @@ class LyriaEngine:
|
||||
audio_bytes = _extract_audio(parts)
|
||||
except (genai_errors.ClientError, genai_errors.ServerError) as exc:
|
||||
raise RuntimeError(friendly_lyria_error(exc)) from exc
|
||||
if audio_bytes:
|
||||
break
|
||||
|
||||
if not audio_bytes:
|
||||
raise RuntimeError(_empty_response_message(response))
|
||||
|
||||
@@ -223,6 +223,7 @@ async def root() -> dict:
|
||||
"lyria": "/api/lyria",
|
||||
"shuffle": "POST /api/shuffle/next",
|
||||
"player": "/player",
|
||||
"winamp": "/winamp",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -481,6 +482,14 @@ async def player_page() -> HTMLResponse:
|
||||
return HTMLResponse("<h1>Live Ozan Radio</h1><p>gateway/player.html missing</p>")
|
||||
|
||||
|
||||
@app.get("/winamp", response_class=HTMLResponse)
|
||||
async def winamp_page() -> HTMLResponse:
|
||||
gateway = Path(__file__).resolve().parents[2] / "gateway" / "winamp.html"
|
||||
if gateway.exists():
|
||||
return HTMLResponse(gateway.read_text(encoding="utf-8"))
|
||||
return HTMLResponse("<h1>Live Ozan Radio</h1><p>gateway/winamp.html missing</p>")
|
||||
|
||||
|
||||
async def _background_compose(request: str | None = None) -> None:
|
||||
"""Chat-triggered generation — never raises; errors land in _generation_state."""
|
||||
result = await _compose_track(request)
|
||||
|
||||
@@ -39,14 +39,28 @@ def build_playlist_payload(songs_dir: Path) -> dict:
|
||||
tracks.reverse()
|
||||
return {
|
||||
"station": "Live Ozan Radio",
|
||||
"tagline": "No catalog. AI-composed desert dub + Anadolu psych.",
|
||||
"tagline": "Techno-ethnic AI radio — no catalog tracks.",
|
||||
"media_base": MEDIA_BASE,
|
||||
"share_url": f"https://tinqs.com/{REPO_SLUG}/src/branch/main/gateway/index.html",
|
||||
"winamp_url": f"https://tinqs.com/{REPO_SLUG}/src/branch/main/gateway/winamp.html",
|
||||
"updated": datetime.now(timezone.utc).isoformat(),
|
||||
"tracks": tracks,
|
||||
}
|
||||
|
||||
|
||||
def embed_playlist_in_html(html: str, payload: dict) -> str:
|
||||
"""Replace embedded /*__PLAYLIST__*/ JSON block in a gateway HTML file."""
|
||||
if PLAYLIST_MARKER not in html:
|
||||
return html
|
||||
replacement = json.dumps(payload, ensure_ascii=False)
|
||||
return re.sub(
|
||||
rf"{re.escape(PLAYLIST_MARKER)}[\s\S]*?{re.escape(PLAYLIST_MARKER)}",
|
||||
f"{PLAYLIST_MARKER}\n{replacement}\n{PLAYLIST_MARKER}",
|
||||
html,
|
||||
count=1,
|
||||
)
|
||||
|
||||
|
||||
def export_gateway_playlist(repo_root: Path | None = None) -> Path | None:
|
||||
root = repo_root or Path(__file__).resolve().parents[2]
|
||||
songs_dir = root / "songs"
|
||||
@@ -60,16 +74,13 @@ def export_gateway_playlist(repo_root: Path | None = None) -> Path | None:
|
||||
payload = build_playlist_payload(songs_dir)
|
||||
playlist_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||
|
||||
html = index_path.read_text(encoding="utf-8")
|
||||
if PLAYLIST_MARKER not in html:
|
||||
return playlist_path
|
||||
for html_name in ("index.html", "winamp.html"):
|
||||
html_path = gateway / html_name
|
||||
if not html_path.exists():
|
||||
continue
|
||||
html = html_path.read_text(encoding="utf-8")
|
||||
if PLAYLIST_MARKER not in html:
|
||||
continue
|
||||
html_path.write_text(embed_playlist_in_html(html, payload), encoding="utf-8")
|
||||
|
||||
replacement = json.dumps(payload, ensure_ascii=False)
|
||||
html = re.sub(
|
||||
rf"{re.escape(PLAYLIST_MARKER)}[\s\S]*?{re.escape(PLAYLIST_MARKER)}",
|
||||
f"{PLAYLIST_MARKER}\n{replacement}\n{PLAYLIST_MARKER}",
|
||||
html,
|
||||
count=1,
|
||||
)
|
||||
index_path.write_text(html, encoding="utf-8")
|
||||
return playlist_path
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from ozan_radio.dj import parse_llm_json
|
||||
|
||||
|
||||
def test_parse_llm_json_plain():
|
||||
data = parse_llm_json('{"title": "Test", "lyria_prompt": "one line"}')
|
||||
assert data["title"] == "Test"
|
||||
|
||||
|
||||
def test_parse_llm_json_fenced():
|
||||
raw = '```json\n{"title": "Fenced"}\n```'
|
||||
assert parse_llm_json(raw)["title"] == "Fenced"
|
||||
|
||||
|
||||
def test_parse_llm_json_wrapped_text():
|
||||
raw = 'Here is the plan:\n{"title": "Wrapped", "mood": "calm"}\nThanks.'
|
||||
assert parse_llm_json(raw)["title"] == "Wrapped"
|
||||
|
||||
|
||||
def test_parse_llm_json_invalid_raises():
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
parse_llm_json("not json at all")
|
||||
+23
-1
@@ -79,10 +79,32 @@ def test_build_lyria_prompt_hindi_language(sample_plan):
|
||||
def test_sanitize_for_retry_softens_griot():
|
||||
raw = "West African griot vocal sample over dub"
|
||||
soft = _sanitize_for_retry(raw)
|
||||
assert "griot" not in soft.lower() or "Sahel" in soft
|
||||
assert "griot" not in soft.lower()
|
||||
assert soft != raw
|
||||
|
||||
|
||||
def test_sanitize_for_retry_softens_throat_singing():
|
||||
raw = "Mongolian overtone throat singing over dub bass"
|
||||
soft = _sanitize_for_retry(raw)
|
||||
assert "throat singing" not in soft.lower()
|
||||
assert "wordless" in soft.lower()
|
||||
|
||||
|
||||
def test_sanitize_for_retry_softens_anatolian():
|
||||
raw = "Anatolian saz and ney with dub delay"
|
||||
soft = _sanitize_for_retry(raw)
|
||||
assert "anatolian" not in soft.lower()
|
||||
|
||||
|
||||
def test_instrumental_fallback_strips_vocals():
|
||||
from ozan_radio.lyria import _instrumental_fallback
|
||||
|
||||
raw = "88 BPM dub.\nWordless male baritone hum layer.\nSub bass pulse."
|
||||
out = _instrumental_fallback(raw)
|
||||
assert "baritone" not in out.lower()
|
||||
assert "Instrumental only" in out
|
||||
|
||||
|
||||
def test_response_parts_from_candidates():
|
||||
part = SimpleNamespace(text=None, inline_data=SimpleNamespace(data=b"audio"))
|
||||
content = SimpleNamespace(parts=[part])
|
||||
|
||||
@@ -16,6 +16,7 @@ def client(tmp_path: Path, monkeypatch):
|
||||
gateway = tmp_path / "gateway"
|
||||
gateway.mkdir()
|
||||
(gateway / "index.html").write_text("<html></html>", encoding="utf-8")
|
||||
(gateway / "winamp.html").write_text("<html><title>Winamp</title></html>", encoding="utf-8")
|
||||
(tmp_path / "vocal_cues.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
@@ -88,6 +89,13 @@ def test_root_lists_lyria_endpoint(client: TestClient):
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["endpoints"]["lyria"] == "/api/lyria"
|
||||
assert r.json()["endpoints"]["winamp"] == "/winamp"
|
||||
|
||||
|
||||
def test_winamp_page(client: TestClient):
|
||||
r = client.get("/winamp")
|
||||
assert r.status_code == 200
|
||||
assert "Winamp" in r.text
|
||||
|
||||
|
||||
def test_lyria_capabilities_without_api_key(client: TestClient):
|
||||
|
||||
@@ -3,7 +3,12 @@ from __future__ import annotations
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from ozan_radio.web_playlist import PLAYLIST_MARKER, build_playlist_payload, export_gateway_playlist
|
||||
from ozan_radio.web_playlist import (
|
||||
PLAYLIST_MARKER,
|
||||
build_playlist_payload,
|
||||
embed_playlist_in_html,
|
||||
export_gateway_playlist,
|
||||
)
|
||||
|
||||
|
||||
def test_build_playlist_payload_excludes_skip(songs_dir: Path):
|
||||
@@ -14,6 +19,13 @@ def test_build_playlist_payload_excludes_skip(songs_dir: Path):
|
||||
assert payload["tracks"][0]["url"].startswith("https://tinqs.com/tinqs/live-radio/media/")
|
||||
|
||||
|
||||
def test_embed_playlist_in_html_replaces_marker():
|
||||
html = f"<script>{PLAYLIST_MARKER}\n{{}}\n{PLAYLIST_MARKER}</script>"
|
||||
out = embed_playlist_in_html(html, {"tracks": [{"id": "abc"}]})
|
||||
assert '"abc"' in out
|
||||
assert PLAYLIST_MARKER in out
|
||||
|
||||
|
||||
def test_export_gateway_playlist_writes_json_and_embeds(tmp_path: Path, songs_dir: Path):
|
||||
gateway = tmp_path / "gateway"
|
||||
gateway.mkdir()
|
||||
@@ -22,6 +34,11 @@ def test_export_gateway_playlist_writes_json_and_embeds(tmp_path: Path, songs_di
|
||||
f"<script>\n{PLAYLIST_MARKER}\n{{}}\n{PLAYLIST_MARKER}\n</script>\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
winamp = gateway / "winamp.html"
|
||||
winamp.write_text(
|
||||
f"<script>\n{PLAYLIST_MARKER}\n{{}}\n{PLAYLIST_MARKER}\n</script>\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(tmp_path / "songs").mkdir(exist_ok=True)
|
||||
for f in songs_dir.iterdir():
|
||||
dest = tmp_path / "songs" / f.name
|
||||
@@ -34,3 +51,5 @@ def test_export_gateway_playlist_writes_json_and_embeds(tmp_path: Path, songs_di
|
||||
html = index.read_text(encoding="utf-8")
|
||||
assert "11111111" in html
|
||||
assert PLAYLIST_MARKER in html
|
||||
whtml = winamp.read_text(encoding="utf-8")
|
||||
assert "11111111" in whtml
|
||||
|
||||
Reference in New Issue
Block a user