Add DJ curation metadata, public auto-play radio, and Lyria web controls.
Extensive per-track meta feeds DeepSeek planning. Caravan of the Night kept with electric guitar marked disliked. Sahara Saz remains gold standard. Gateway index.html auto-plays on tinqs.com. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -160,7 +160,26 @@ cd live-radio
|
||||
git lfs install && git lfs pull
|
||||
```
|
||||
|
||||
Static player preview: `https://tinqs.com/tinqs/live-radio/src/branch/main/gateway/player.html` (shell only — audio streams from your running server).
|
||||
### Public auto-play (Git Studio / tinqs.com)
|
||||
|
||||
**Share this link** — opens in browser and starts shuffling saved tracks from Git LFS (no local server):
|
||||
|
||||
**https://tinqs.com/tinqs/live-radio/src/branch/main/gateway/index.html**
|
||||
|
||||
Click **Preview** or the expand icon on that file in Git Studio. If the browser blocks autoplay, tap **Start Live Ozan Radio** once.
|
||||
|
||||
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 commit -m "Update public radio playlist"
|
||||
git push
|
||||
```
|
||||
|
||||
New generations auto-update `gateway/index.html` when the local server saves a track (`queue.add`).
|
||||
|
||||
Full dashboard (`gateway/player.html` or `http://127.0.0.1:8787/player`) still needs the FastAPI server for DJ chat, Lyria compose, and settings.
|
||||
|
||||
## Agent usage
|
||||
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
<!--
|
||||
title: Live Ozan Radio
|
||||
date: 2026-06-07
|
||||
author: Ozan
|
||||
source: live-radio
|
||||
description: Auto-play AI radio — desert dub and Anadolu psych. Streams saved Lyria tracks from Git LFS on tinqs.com.
|
||||
-->
|
||||
<!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</title>
|
||||
<meta name="description" content="Personal AI radio — auto-play desert dub and Anadolu psych from tinqs.com">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0a0f;
|
||||
--panel: #14141f;
|
||||
--accent: #ff6b35;
|
||||
--accent2: #7b5cff;
|
||||
--text: #f0f0f5;
|
||||
--muted: #8888a0;
|
||||
--glow: rgba(255, 107, 53, 0.35);
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
font-family: "Segoe UI", system-ui, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-image:
|
||||
radial-gradient(ellipse 80% 50% at 50% -10%, var(--glow), transparent),
|
||||
radial-gradient(ellipse 60% 40% at 100% 100%, rgba(123,92,255,0.15), transparent);
|
||||
}
|
||||
.shell {
|
||||
width: min(440px, 92vw);
|
||||
background: var(--panel);
|
||||
border: 1px solid #2a2a3a;
|
||||
border-radius: 20px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 24px 80px rgba(0,0,0,0.5);
|
||||
text-align: center;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 4px;
|
||||
padding: 0.2rem 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
h1 { font-size: 1.6rem; margin-bottom: 0.35rem; }
|
||||
.tagline { color: var(--muted); font-size: 0.85rem; line-height: 1.45; margin-bottom: 1.25rem; }
|
||||
.viz {
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.bar {
|
||||
width: 6px;
|
||||
background: linear-gradient(to top, var(--accent), var(--accent2));
|
||||
border-radius: 3px;
|
||||
animation: bounce 0.8s ease-in-out infinite alternate;
|
||||
}
|
||||
.bar:nth-child(1) { height: 20px; animation-delay: 0s; }
|
||||
.bar:nth-child(2) { height: 36px; animation-delay: 0.1s; }
|
||||
.bar:nth-child(3) { height: 52px; animation-delay: 0.2s; }
|
||||
.bar:nth-child(4) { height: 40px; animation-delay: 0.15s; }
|
||||
.bar:nth-child(5) { height: 28px; animation-delay: 0.05s; }
|
||||
.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; }
|
||||
}
|
||||
.title { font-size: 1.15rem; font-weight: 600; margin-bottom: 0.35rem; min-height: 1.4em; }
|
||||
.mood, .dj { font-size: 0.85rem; color: var(--muted); margin-bottom: 0.45rem; min-height: 1.2em; }
|
||||
.dj { font-style: italic; color: #b0b0c8; }
|
||||
audio { width: 100%; margin: 0.75rem 0; border-radius: 8px; }
|
||||
.status { font-size: 0.75rem; color: var(--muted); min-height: 1.2em; }
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(10, 10, 15, 0.92);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
cursor: pointer;
|
||||
}
|
||||
.overlay.show { display: flex; }
|
||||
.overlay button {
|
||||
background: linear-gradient(135deg, var(--accent), #ff8f65);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
padding: 1rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.links { margin-top: 1.25rem; font-size: 0.72rem; color: #555568; line-height: 1.6; }
|
||||
.links a { color: var(--accent); text-decoration: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="overlay" id="overlay" aria-hidden="true">
|
||||
<button type="button" id="startBtn">▶ Start Live Ozan Radio</button>
|
||||
</div>
|
||||
|
||||
<div class="shell">
|
||||
<div class="badge" id="badge">● Live</div>
|
||||
<h1>Ozan Radio</h1>
|
||||
<p class="tagline">No catalog tracks — AI-composed desert dub & Anadolu psych, hosted on tinqs.com.</p>
|
||||
|
||||
<div class="viz" id="viz">
|
||||
<div class="bar"></div><div class="bar"></div><div class="bar"></div>
|
||||
<div class="bar"></div><div class="bar"></div>
|
||||
</div>
|
||||
|
||||
<div class="title" id="trackTitle">Loading…</div>
|
||||
<div class="mood" id="trackMood"></div>
|
||||
<div class="dj" id="trackDj"></div>
|
||||
|
||||
<audio id="player" preload="auto" autoplay playsinline></audio>
|
||||
<div class="status" id="status">Connecting…</div>
|
||||
|
||||
<div class="links">
|
||||
Full DJ + Lyria dashboard (local): <code>python -m ozan_radio serve</code><br>
|
||||
<a href="https://tinqs.com/tinqs/live-radio" target="_blank" rel="noopener">Repo on Git Studio</a>
|
||||
· <a href="player.html">Player shell</a> (needs API)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-07T14:19:56.721968+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}, {"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}, {"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}, {"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}, {"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}]}
|
||||
/*__PLAYLIST__*/
|
||||
|
||||
const player = document.getElementById('player');
|
||||
const viz = document.getElementById('viz');
|
||||
const trackTitle = document.getElementById('trackTitle');
|
||||
const trackMood = document.getElementById('trackMood');
|
||||
const trackDj = document.getElementById('trackDj');
|
||||
const statusEl = document.getElementById('status');
|
||||
const overlay = document.getElementById('overlay');
|
||||
const startBtn = document.getElementById('startBtn');
|
||||
|
||||
let playlist = null;
|
||||
let queue = [];
|
||||
let index = 0;
|
||||
let started = false;
|
||||
|
||||
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 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 showTrack(t) {
|
||||
trackTitle.textContent = t.title || 'Untitled';
|
||||
trackMood.textContent = t.mood ? `Mood: ${t.mood}` : '';
|
||||
trackDj.textContent = t.dj_line ? `"${t.dj_line}"` : '';
|
||||
statusEl.textContent = `Playing · ${t.id || ''}`;
|
||||
}
|
||||
|
||||
function playCurrent() {
|
||||
if (!queue.length) {
|
||||
trackTitle.textContent = 'No tracks yet';
|
||||
statusEl.textContent = 'Push songs to main — see README';
|
||||
return;
|
||||
}
|
||||
const t = queue[index];
|
||||
showTrack(t);
|
||||
const url = t.url || (playlist.media_base + t.file);
|
||||
if (player.src !== url) player.src = url;
|
||||
return player.play();
|
||||
}
|
||||
|
||||
function nextTrack() {
|
||||
if (!queue.length) return;
|
||||
index = (index + 1) % queue.length;
|
||||
playCurrent().catch(showOverlay);
|
||||
}
|
||||
|
||||
function showOverlay() {
|
||||
overlay.classList.add('show');
|
||||
overlay.setAttribute('aria-hidden', 'false');
|
||||
statusEl.textContent = 'Tap to start audio (browser policy)';
|
||||
}
|
||||
|
||||
function hideOverlay() {
|
||||
overlay.classList.remove('show');
|
||||
overlay.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
|
||||
function startRadio() {
|
||||
if (started) return;
|
||||
started = true;
|
||||
hideOverlay();
|
||||
playCurrent().catch(showOverlay);
|
||||
}
|
||||
|
||||
async function init() {
|
||||
playlist = playlist || parseEmbeddedPlaylist();
|
||||
if (!playlist || !playlist.tracks || !playlist.tracks.length) {
|
||||
try {
|
||||
const res = await fetch('playlist.json');
|
||||
if (res.ok) playlist = await res.json();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (!playlist || !playlist.tracks || !playlist.tracks.length) {
|
||||
trackTitle.textContent = 'Library empty';
|
||||
statusEl.textContent = 'Generate tracks locally, export, push to main';
|
||||
return;
|
||||
}
|
||||
|
||||
queue = shuffle(playlist.tracks);
|
||||
index = 0;
|
||||
document.title = playlist.station || 'Live Ozan Radio';
|
||||
|
||||
player.addEventListener('ended', nextTrack);
|
||||
player.addEventListener('play', () => viz.classList.remove('paused'));
|
||||
player.addEventListener('pause', () => viz.classList.add('paused'));
|
||||
|
||||
const attempt = playCurrent();
|
||||
if (attempt && typeof attempt.then === 'function') {
|
||||
attempt.then(() => { started = true; hideOverlay(); }).catch(showOverlay);
|
||||
} else {
|
||||
showOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
overlay.addEventListener('click', startRadio);
|
||||
startBtn.addEventListener('click', (e) => { e.stopPropagation(); startRadio(); });
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === ' ' || e.key === 'Enter') startRadio();
|
||||
});
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
After
|
+415
-5
@@ -28,7 +28,7 @@
|
||||
radial-gradient(ellipse 60% 40% at 100% 100%, rgba(123,92,255,0.15), transparent);
|
||||
}
|
||||
.radio {
|
||||
width: min(440px, 92vw);
|
||||
width: min(480px, 92vw);
|
||||
background: var(--panel);
|
||||
border: 1px solid #2a2a3a;
|
||||
border-radius: 20px;
|
||||
@@ -188,6 +188,35 @@
|
||||
text-align: center;
|
||||
}
|
||||
.num-input:focus { outline: none; border-color: var(--accent); }
|
||||
.select-input {
|
||||
min-width: 140px;
|
||||
max-width: 180px;
|
||||
background: #14141f;
|
||||
border: 1px solid #3a3a50;
|
||||
border-radius: 8px;
|
||||
padding: 0.35rem 0.5rem;
|
||||
color: var(--text);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.select-input:focus { outline: none; border-color: var(--accent); }
|
||||
.lyria-status {
|
||||
font-size: 0.72rem;
|
||||
padding: 0.45rem 0.55rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 0.65rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.lyria-status.ok { background: rgba(107, 207, 142, 0.12); color: #6bcf8e; border: 1px solid rgba(107, 207, 142, 0.25); }
|
||||
.lyria-status.err { background: rgba(255, 92, 122, 0.1); color: #ff5c7a; border: 1px solid rgba(255, 92, 122, 0.25); }
|
||||
.settings-divider {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
padding: 0.65rem 0 0.35rem;
|
||||
border-top: 1px solid #2a2a3a;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
.range-input {
|
||||
width: 100px;
|
||||
accent-color: var(--accent);
|
||||
@@ -381,6 +410,99 @@
|
||||
.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; }
|
||||
.vocal-booth {
|
||||
margin-top: 1.25rem;
|
||||
border-top: 1px solid #2a2a3a;
|
||||
padding-top: 1rem;
|
||||
display: none;
|
||||
}
|
||||
.vocal-booth.open { display: block; }
|
||||
.vocal-booth h2 {
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.vocal-booth .booth-note {
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
.vocal-booth .booth-style {
|
||||
font-size: 0.75rem;
|
||||
color: #b0b0c8;
|
||||
font-style: italic;
|
||||
margin-bottom: 0.75rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.cue-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.cue-line {
|
||||
padding: 0.5rem 0.65rem;
|
||||
background: #1a1a28;
|
||||
border: 1px solid #2a2a3a;
|
||||
border-radius: 10px;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.4;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
.cue-line.active {
|
||||
border-color: var(--accent);
|
||||
background: rgba(255, 107, 53, 0.1);
|
||||
}
|
||||
.cue-time {
|
||||
font-size: 0.68rem;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
.cue-text {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.cue-hint {
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.booth-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.booth-controls button { flex: 1; min-width: 120px; }
|
||||
.booth-status {
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
min-height: 1.2rem;
|
||||
}
|
||||
.booth-status.recording { color: #ff5c7a; }
|
||||
.booth-status.ready { color: #6bcf8e; }
|
||||
.take-player {
|
||||
width: 100%;
|
||||
margin-top: 0.5rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.booth-toggle {
|
||||
width: 100%;
|
||||
margin-top: 0.75rem;
|
||||
background: transparent;
|
||||
border: 1px dashed #3a3a50;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.booth-toggle:hover { border-color: var(--accent); color: var(--text); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -390,7 +512,8 @@
|
||||
<h1>Ozan Radio</h1>
|
||||
<button type="button" class="icon-btn" id="settingsBtn" title="Settings" aria-label="Settings">⚙</button>
|
||||
</div>
|
||||
<p class="tagline">No catalog tracks. Taste from settings.json — set yours via Cursor + Spotify screenshots.</p>
|
||||
<p class="tagline">No catalog tracks. Taste from settings.json — set yours via Cursor + Spotify screenshots.
|
||||
<a href="index.html" style="color:var(--accent)">Public auto-play</a> (tinqs.com, no server).</p>
|
||||
|
||||
<section class="dashboard" id="dashboard">
|
||||
<div class="stat-card">
|
||||
@@ -446,6 +569,43 @@
|
||||
</label>
|
||||
<input type="number" class="num-input" id="setMaxPerDay" min="1" max="100" value="10">
|
||||
</div>
|
||||
<div class="settings-divider">Lyria 3 (from API)</div>
|
||||
<div class="lyria-status ok" id="lyriaApiStatus">Checking Gemini / Lyria…</div>
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
Model
|
||||
<span class="hint" id="lyriaModelHint">Pro = full songs · Clip = 30s previews</span>
|
||||
</label>
|
||||
<select class="select-input" id="setLyriaModel"></select>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
Vocal mode
|
||||
<span class="hint" id="lyriaVocalHint">How DJ + Lyria treat vocals</span>
|
||||
</label>
|
||||
<select class="select-input" id="setVocalMode"></select>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
Lyric language
|
||||
<span class="hint">Lyria sings in the language you steer</span>
|
||||
</label>
|
||||
<select class="select-input" id="setLanguage"></select>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
Singer profile
|
||||
<span class="hint">Vocal delivery when mode is Full vocals</span>
|
||||
</label>
|
||||
<select class="select-input" id="setSinger"></select>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
Output format
|
||||
<span class="hint">WAV = larger files, Pro only</span>
|
||||
</label>
|
||||
<select class="select-input" id="setOutputFormat"></select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="viz" id="viz">
|
||||
@@ -467,6 +627,23 @@
|
||||
|
||||
<div class="status" id="status">Connecting…</div>
|
||||
|
||||
<button type="button" class="booth-toggle" id="boothToggle">🎙 Vocal booth — record your layer (Chrome)</button>
|
||||
|
||||
<section class="vocal-booth" id="vocalBooth">
|
||||
<h2>Vocal booth</h2>
|
||||
<p class="booth-note" id="boothIntro"></p>
|
||||
<p class="booth-style" id="boothStyle"></p>
|
||||
<div class="cue-list" id="cueList"></div>
|
||||
<div class="booth-controls">
|
||||
<button type="button" class="secondary" id="skipIntroBtn">Skip to saz</button>
|
||||
<button type="button" class="primary" id="recordBtn">Record take</button>
|
||||
<button type="button" class="secondary" id="stopRecordBtn" disabled>Stop</button>
|
||||
<button type="button" class="secondary" id="downloadTakeBtn" disabled>Download take</button>
|
||||
</div>
|
||||
<div class="booth-status" id="boothStatus">Mic uses Chrome — wear headphones so only your voice is captured.</div>
|
||||
<audio id="takePlayer" class="take-player" controls hidden></audio>
|
||||
</section>
|
||||
|
||||
<section class="library">
|
||||
<h2>Saved songs</h2>
|
||||
<div class="library-list" id="libraryList">
|
||||
@@ -484,7 +661,7 @@
|
||||
</section>
|
||||
|
||||
<div class="stack">
|
||||
DJ: DeepSeek · Music: Lyria 3 · Taste: screenshots → Cursor → settings.json<br>
|
||||
DJ: DeepSeek · Music: Lyria 3 · Vocal booth: Chrome mic + cue sheet<br>
|
||||
Optional live layer: Magenta RealTime 2 on Apple Silicon
|
||||
</div>
|
||||
</div>
|
||||
@@ -512,21 +689,243 @@
|
||||
const setMix = document.getElementById('setMix');
|
||||
const setChance = document.getElementById('setChance');
|
||||
const setMaxPerDay = document.getElementById('setMaxPerDay');
|
||||
const lyriaApiStatus = document.getElementById('lyriaApiStatus');
|
||||
const setLyriaModel = document.getElementById('setLyriaModel');
|
||||
const setVocalMode = document.getElementById('setVocalMode');
|
||||
const setLanguage = document.getElementById('setLanguage');
|
||||
const setSinger = document.getElementById('setSinger');
|
||||
const setOutputFormat = document.getElementById('setOutputFormat');
|
||||
const lyriaModelHint = document.getElementById('lyriaModelHint');
|
||||
const lyriaVocalHint = document.getElementById('lyriaVocalHint');
|
||||
const statSpent = document.getElementById('statSpent');
|
||||
const statPerTrack = document.getElementById('statPerTrack');
|
||||
const statGenerated = document.getElementById('statGenerated');
|
||||
const statRemaining = document.getElementById('statRemaining');
|
||||
const statMaxBudget = document.getElementById('statMaxBudget');
|
||||
const boothToggle = document.getElementById('boothToggle');
|
||||
const vocalBooth = document.getElementById('vocalBooth');
|
||||
const boothIntro = document.getElementById('boothIntro');
|
||||
const boothStyle = document.getElementById('boothStyle');
|
||||
const cueList = document.getElementById('cueList');
|
||||
const skipIntroBtn = document.getElementById('skipIntroBtn');
|
||||
const recordBtn = document.getElementById('recordBtn');
|
||||
const stopRecordBtn = document.getElementById('stopRecordBtn');
|
||||
const downloadTakeBtn = document.getElementById('downloadTakeBtn');
|
||||
const boothStatus = document.getElementById('boothStatus');
|
||||
const takePlayer = document.getElementById('takePlayer');
|
||||
|
||||
let currentTrackId = null;
|
||||
let radioSettings = { playback: { shuffle: true, mix_existing_and_new: true, new_song_chance: 0.35 } };
|
||||
let savingSettings = false;
|
||||
let lastGenError = null;
|
||||
let activeVocalCues = null;
|
||||
let mediaRecorder = null;
|
||||
let recordedChunks = [];
|
||||
let micStream = null;
|
||||
let lastTakeBlob = null;
|
||||
let lastTakeUrl = null;
|
||||
let lyriaCaps = null;
|
||||
|
||||
function fillSelect(sel, options, valueKey = 'id', labelKey = 'label') {
|
||||
sel.innerHTML = '';
|
||||
for (const opt of options) {
|
||||
const o = document.createElement('option');
|
||||
o.value = opt[valueKey];
|
||||
o.textContent = opt[labelKey];
|
||||
if (opt.hint) o.title = opt.hint;
|
||||
sel.appendChild(o);
|
||||
}
|
||||
}
|
||||
|
||||
function syncLyriaUI(active) {
|
||||
if (!active || savingSettings) return;
|
||||
if (active.model) setLyriaModel.value = active.model;
|
||||
if (active.vocal_mode) setVocalMode.value = active.vocal_mode;
|
||||
if (active.language) setLanguage.value = active.language;
|
||||
if (active.singer_profile != null) setSinger.value = active.singer_profile;
|
||||
if (active.output_format) setOutputFormat.value = active.output_format;
|
||||
updateLyriaHints();
|
||||
}
|
||||
|
||||
function updateLyriaHints() {
|
||||
const mode = lyriaCaps?.capabilities?.vocal_modes?.find(m => m.id === setVocalMode.value);
|
||||
if (mode) lyriaVocalHint.textContent = mode.hint;
|
||||
const model = lyriaCaps?.capabilities?.models?.find(m => m.id === setLyriaModel.value);
|
||||
if (model) lyriaModelHint.textContent = `${model.label} · ${model.duration}`;
|
||||
if (setLyriaModel.value?.includes('clip')) {
|
||||
setOutputFormat.value = 'mp3';
|
||||
setOutputFormat.disabled = true;
|
||||
} else {
|
||||
setOutputFormat.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLyriaCapabilities() {
|
||||
try {
|
||||
const res = await fetch(`${API}/api/lyria`);
|
||||
const data = await res.json();
|
||||
lyriaCaps = data;
|
||||
const caps = data.capabilities || {};
|
||||
fillSelect(setLyriaModel, caps.models || []);
|
||||
fillSelect(setVocalMode, caps.vocal_modes || []);
|
||||
fillSelect(setLanguage, caps.languages || []);
|
||||
fillSelect(setSinger, caps.singer_profiles || []);
|
||||
fillSelect(setOutputFormat, caps.output_formats || []);
|
||||
syncLyriaUI(data.active || {});
|
||||
const api = data.api || {};
|
||||
lyriaApiStatus.textContent = api.message || 'Lyria status unknown';
|
||||
lyriaApiStatus.className = 'lyria-status ' + (api.ok ? 'ok' : 'err');
|
||||
} catch (_) {
|
||||
lyriaApiStatus.textContent = 'Could not reach /api/lyria';
|
||||
lyriaApiStatus.className = 'lyria-status err';
|
||||
}
|
||||
}
|
||||
|
||||
function fmtUsd(n) {
|
||||
return '$' + Number(n || 0).toFixed(2);
|
||||
}
|
||||
|
||||
function fmtTime(sec) {
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = Math.floor(sec % 60);
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
async function loadVocalBooth(trackId) {
|
||||
activeVocalCues = null;
|
||||
cueList.innerHTML = '';
|
||||
boothIntro.textContent = '';
|
||||
boothStyle.textContent = '';
|
||||
skipIntroBtn.disabled = true;
|
||||
if (!trackId) return;
|
||||
try {
|
||||
const res = await fetch(`${API}/api/vocal-cues/${trackId}`);
|
||||
if (!res.ok) return;
|
||||
activeVocalCues = await res.json();
|
||||
boothIntro.textContent = activeVocalCues.intro_note || '';
|
||||
boothStyle.textContent = activeVocalCues.style || '';
|
||||
skipIntroBtn.disabled = !activeVocalCues.skip_intro_sec;
|
||||
for (const phrase of activeVocalCues.phrases || []) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'cue-line';
|
||||
el.dataset.start = phrase.start_sec;
|
||||
el.dataset.end = phrase.end_sec;
|
||||
el.innerHTML = `
|
||||
<div class="cue-time">${fmtTime(phrase.start_sec)} — ${phrase.label || ''}</div>
|
||||
<div class="cue-text">${phrase.text || ''}</div>
|
||||
<div class="cue-hint">${phrase.hint || ''}</div>`;
|
||||
cueList.appendChild(el);
|
||||
}
|
||||
if (vocalBooth.classList.contains('open')) {
|
||||
boothToggle.textContent = `🎙 Vocal booth — ${activeVocalCues.title || 'cues loaded'}`;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function highlightCueAtTime(t) {
|
||||
if (!activeVocalCues) return;
|
||||
for (const el of cueList.querySelectorAll('.cue-line')) {
|
||||
const start = parseFloat(el.dataset.start);
|
||||
const end = parseFloat(el.dataset.end);
|
||||
el.classList.toggle('active', t >= start && t < end);
|
||||
}
|
||||
}
|
||||
|
||||
function resetTake() {
|
||||
if (lastTakeUrl) URL.revokeObjectURL(lastTakeUrl);
|
||||
lastTakeUrl = null;
|
||||
lastTakeBlob = null;
|
||||
takePlayer.hidden = true;
|
||||
takePlayer.removeAttribute('src');
|
||||
downloadTakeBtn.disabled = true;
|
||||
}
|
||||
|
||||
async function ensureMic() {
|
||||
if (micStream) return micStream;
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
throw new Error('Mic not supported — use Chrome on desktop.');
|
||||
}
|
||||
micStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: { echoCancellation: true, noiseSuppression: false, autoGainControl: false },
|
||||
});
|
||||
return micStream;
|
||||
}
|
||||
|
||||
async function startRecording() {
|
||||
resetTake();
|
||||
boothStatus.textContent = 'Requesting mic…';
|
||||
boothStatus.className = 'booth-status';
|
||||
try {
|
||||
const stream = await ensureMic();
|
||||
recordedChunks = [];
|
||||
const mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
||||
? 'audio/webm;codecs=opus'
|
||||
: 'audio/webm';
|
||||
mediaRecorder = new MediaRecorder(stream, { mimeType: mime });
|
||||
mediaRecorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) recordedChunks.push(e.data);
|
||||
};
|
||||
mediaRecorder.onstop = () => {
|
||||
lastTakeBlob = new Blob(recordedChunks, { type: mime });
|
||||
lastTakeUrl = URL.createObjectURL(lastTakeBlob);
|
||||
takePlayer.src = lastTakeUrl;
|
||||
takePlayer.hidden = false;
|
||||
downloadTakeBtn.disabled = false;
|
||||
boothStatus.textContent = 'Take ready — preview below or download.';
|
||||
boothStatus.className = 'booth-status ready';
|
||||
recordBtn.disabled = false;
|
||||
stopRecordBtn.disabled = true;
|
||||
};
|
||||
if (activeVocalCues?.skip_intro_sec != null) {
|
||||
player.currentTime = activeVocalCues.skip_intro_sec;
|
||||
}
|
||||
await player.play().catch(() => {});
|
||||
mediaRecorder.start(250);
|
||||
recordBtn.disabled = true;
|
||||
stopRecordBtn.disabled = false;
|
||||
boothStatus.textContent = 'Recording… sing to the highlighted cue lines.';
|
||||
boothStatus.className = 'booth-status recording';
|
||||
} catch (err) {
|
||||
boothStatus.textContent = err.message || 'Mic permission denied.';
|
||||
boothStatus.className = 'booth-status';
|
||||
recordBtn.disabled = false;
|
||||
stopRecordBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||||
mediaRecorder.stop();
|
||||
}
|
||||
}
|
||||
|
||||
function downloadTake() {
|
||||
if (!lastTakeBlob || !currentTrackId) return;
|
||||
const a = document.createElement('a');
|
||||
a.href = lastTakeUrl;
|
||||
a.download = `${currentTrackId}_vocal_take.webm`;
|
||||
a.click();
|
||||
}
|
||||
|
||||
boothToggle.addEventListener('click', () => {
|
||||
vocalBooth.classList.toggle('open');
|
||||
const open = vocalBooth.classList.contains('open');
|
||||
boothToggle.textContent = open
|
||||
? (activeVocalCues ? `🎙 Vocal booth — ${activeVocalCues.title}` : '🎙 Vocal booth (open a cued track)')
|
||||
: '🎙 Vocal booth — record your layer (Chrome)';
|
||||
if (open && currentTrackId) loadVocalBooth(currentTrackId);
|
||||
});
|
||||
skipIntroBtn.addEventListener('click', () => {
|
||||
if (activeVocalCues?.skip_intro_sec != null) {
|
||||
player.currentTime = activeVocalCues.skip_intro_sec;
|
||||
player.play().catch(() => {});
|
||||
}
|
||||
});
|
||||
recordBtn.addEventListener('click', startRecording);
|
||||
stopRecordBtn.addEventListener('click', stopRecording);
|
||||
downloadTakeBtn.addEventListener('click', downloadTake);
|
||||
player.addEventListener('timeupdate', () => highlightCueAtTime(player.currentTime));
|
||||
|
||||
function applyTrack(t, source) {
|
||||
if (!t) return;
|
||||
currentTrackId = t.track_id;
|
||||
@@ -541,6 +940,8 @@
|
||||
const srcLabel = source === 'generated' ? 'New · ' : source === 'library' ? 'Library · ' : '';
|
||||
statusEl.textContent = `${srcLabel}Playing · ${t.track_id}`;
|
||||
loadLibrary();
|
||||
loadVocalBooth(t.track_id);
|
||||
resetTake();
|
||||
}
|
||||
|
||||
function applyGenerationState(gen) {
|
||||
@@ -623,6 +1024,7 @@
|
||||
if (data.budget) {
|
||||
setMaxPerDay.value = data.budget.max_per_day || 10;
|
||||
}
|
||||
if (data.lyria) syncLyriaUI(data.lyria);
|
||||
updateModeBadge();
|
||||
} catch (_) {}
|
||||
}
|
||||
@@ -644,6 +1046,13 @@
|
||||
limits: {
|
||||
max_new_songs_per_day: parseInt(setMaxPerDay.value, 10) || 10,
|
||||
},
|
||||
lyria: {
|
||||
model: setLyriaModel.value,
|
||||
vocal_mode: setVocalMode.value,
|
||||
language: setLanguage.value,
|
||||
singer_profile: setSinger.value,
|
||||
output_format: setOutputFormat.value,
|
||||
},
|
||||
};
|
||||
try {
|
||||
const res = await fetch(`${API}/api/settings`, {
|
||||
@@ -668,8 +1077,8 @@
|
||||
settingsPanel.classList.toggle('open');
|
||||
settingsBtn.classList.toggle('active', settingsPanel.classList.contains('open'));
|
||||
});
|
||||
[setShuffle, setMix, setChance, setMaxPerDay].forEach(el => {
|
||||
el.addEventListener('change', scheduleSaveSettings);
|
||||
[setShuffle, setMix, setChance, setMaxPerDay, setLyriaModel, setVocalMode, setLanguage, setSinger, setOutputFormat].forEach(el => {
|
||||
el.addEventListener('change', () => { updateLyriaHints(); scheduleSaveSettings(); });
|
||||
el.addEventListener('input', scheduleSaveSettings);
|
||||
});
|
||||
|
||||
@@ -863,6 +1272,7 @@
|
||||
loadLibrary();
|
||||
loadChat();
|
||||
loadSettings();
|
||||
loadLyriaCapabilities();
|
||||
loadStats();
|
||||
setInterval(() => { refreshNow(); loadStats(); }, 15000);
|
||||
</script>
|
||||
|
||||
|
Before
After
|
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"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-07T14:19:56.721968+00:00",
|
||||
"tracks": [
|
||||
{
|
||||
"id": "82595273",
|
||||
"title": "Caravan of the Blue Hour",
|
||||
"mood": "hypnotic, warm, spacious",
|
||||
"dj_line": "Late-night dub caravan from the Sahel \u2014 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
|
||||
},
|
||||
{
|
||||
"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 \u2014 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
|
||||
},
|
||||
{
|
||||
"id": "1c1d7b8a",
|
||||
"title": "Sahara's Saz",
|
||||
"mood": "Hypnotic desert dub with Anadolu warmth",
|
||||
"dj_line": "Blowing sand and saz strings \u2014 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
|
||||
},
|
||||
{
|
||||
"id": "aee4994a",
|
||||
"title": "Nomad's Saz",
|
||||
"mood": "hypnotic desert dub with Turkish soul",
|
||||
"dj_line": "From the Sahara to Anatolia \u2014 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
|
||||
},
|
||||
{
|
||||
"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 \u2014 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
|
||||
}
|
||||
]
|
||||
}
|
||||
+10
-3
@@ -46,7 +46,10 @@
|
||||
"big-room EDM drops",
|
||||
"four-on-the-floor house",
|
||||
"generic corporate lounge",
|
||||
"overcompressed pop EDM"
|
||||
"overcompressed pop EDM",
|
||||
"fuzz electric guitar on griot or lead vocal tracks",
|
||||
"long guitar-only intro before saz or ney enters",
|
||||
"Tinariwen-style electric guitar when listener wants saz-forward Anadolu dub"
|
||||
]
|
||||
},
|
||||
"dj": {
|
||||
@@ -67,7 +70,11 @@
|
||||
"deepseek_per_track_usd": 0.002
|
||||
},
|
||||
"lyria": {
|
||||
"prefer_instrumental": true,
|
||||
"model": "lyria-3-pro-preview"
|
||||
"model": "lyria-3-pro-preview",
|
||||
"vocal_mode": "mix",
|
||||
"language": "auto",
|
||||
"singer_profile": "male_baritone",
|
||||
"output_format": "mp3",
|
||||
"prefer_instrumental": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,56 @@
|
||||
"id": "1c1d7b8a",
|
||||
"title": "Sahara's Saz",
|
||||
"mood": "Hypnotic desert dub with Anadolu warmth",
|
||||
"dj_line": "Blowing sand and saz strings \u2014 a slow burn across the dunes, right here on Live Ozan.",
|
||||
"lyria_prompt": "Instrumental. Tempo 85 BPM, key of D minor. Structure: Intro (0-20s: dusty fuzz guitar playing a hypnotic Tinariwen-style riff, darbuka and hand percussion enter softly), Verse (20-50s: sub bass pulse, ney flute melody floating over ba\u011flama/saz arpeggios, dub delay on the flute), Build (50-70s: percussion intensifies, spring reverb swells, whispered vocal textures drift in and out), Drop (70-90s: stripped back to bass and percussion, then re-enter all elements with heavy dub echo), Outro (90-110s: fade out with ney and saz, reverb tail). Instruments: electric guitar (fuzz, middle eastern scales), darbuka, djembe, ney flute, ba\u011flama, sub bass, dub effects (spring reverb, tape delay). Production: warm analog saturation, wide stereo field, late-night intimate feel.",
|
||||
"dj_line": "Blowing sand and saz strings — a slow burn across the dunes, right here on Live Ozan.",
|
||||
"lyria_prompt": "Instrumental. Tempo 85 BPM, key of D minor. Structure: Intro (0-20s: dusty fuzz guitar playing a hypnotic Tinariwen-style riff, darbuka and hand percussion enter softly), Verse (20-50s: sub bass pulse, ney flute melody floating over bağlama/saz arpeggios, dub delay on the flute), Build (50-70s: percussion intensifies, spring reverb swells, whispered vocal textures drift in and out), Drop (70-90s: stripped back to bass and percussion, then re-enter all elements with heavy dub echo), Outro (90-110s: fade out with ney and saz, reverb tail). Instruments: electric guitar (fuzz, middle eastern scales), darbuka, djembe, ney flute, bağlama, sub bass, dub effects (spring reverb, tape delay). Production: warm analog saturation, wide stereo field, late-night intimate feel.",
|
||||
"lyrics": "[[A0]]\n[[B1]]\n[[C2]]\n[[D3]]\n[[E4]]",
|
||||
"file": "1c1d7b8a_Sahara_s_Saz.mp3",
|
||||
"saved_at": "2026-06-07T13:53:55.442779+00:00"
|
||||
}
|
||||
"saved_at": "2026-06-07T13:53:55.442779+00:00",
|
||||
"generation": {
|
||||
"model": "lyria-3-pro-preview",
|
||||
"vocal_mode": "instrumental",
|
||||
"language": "auto"
|
||||
},
|
||||
"structure": {
|
||||
"bpm": 85,
|
||||
"key": "D minor",
|
||||
"duration_sec_est": 110,
|
||||
"skip_intro_sec": 20,
|
||||
"sections": [
|
||||
{ "at_sec": 0, "label": "Intro", "notes": "Tinariwen guitar — tolerable because saz follows quickly" },
|
||||
{ "at_sec": 20, "label": "Verse", "notes": "GOLD — saz arpeggios + ney + sub bass" },
|
||||
{ "at_sec": 50, "label": "Build", "notes": "Whispered vocal textures — perfect" },
|
||||
{ "at_sec": 70, "label": "Drop", "notes": "Dub echo return — hypnotic" }
|
||||
]
|
||||
},
|
||||
"instruments_detected": [
|
||||
"bağlama/saz",
|
||||
"ney flute",
|
||||
"sub bass",
|
||||
"fuzz electric guitar",
|
||||
"darbuka",
|
||||
"djembe",
|
||||
"whispered vocal textures"
|
||||
],
|
||||
"tags": ["desert-dub", "anadolu", "instrumental", "gold-standard", "saz-forward"],
|
||||
"curation": {
|
||||
"rating": "love",
|
||||
"rating_label": "amazing — gold standard",
|
||||
"shuffle_weight": 1.5,
|
||||
"public_playlist": true,
|
||||
"listener": "ozan",
|
||||
"rated_at": "2026-06-07",
|
||||
"notes": "Sahara's Saz was amazing. The saz+ney desert dub blend is the template for the station. Listener said 'good work' and asked for vocal successors from this palette.",
|
||||
"loved": [
|
||||
"saz/baglama arpeggios",
|
||||
"ney with dub delay",
|
||||
"sub bass pulse",
|
||||
"whispered vocal textures",
|
||||
"hypnotic slow burn",
|
||||
"Anadolu warmth over desert dub"
|
||||
],
|
||||
"disliked": [],
|
||||
"avoid_in_successors": [],
|
||||
"clone_prompt_hints": "Clone this: 85 BPM D minor, saz+ney+sub bass+darbuka, whispered textures, spacious dub — saz must enter by 0:20."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,41 @@
|
||||
"id": "82595273",
|
||||
"title": "Caravan of the Blue Hour",
|
||||
"mood": "hypnotic, warm, spacious",
|
||||
"dj_line": "Late-night dub caravan from the Sahel \u2014 let the bass carry you across the dunes.",
|
||||
"dj_line": "Late-night dub caravan from the Sahel — let the bass carry you across the dunes.",
|
||||
"lyria_prompt": "Create a dubtronica track at 90 BPM in A minor. Start with a deep sub bass and a slow, hypnotic stepper beat with rimshot and kick. Add warm analog spring reverb and delay on a muted guitar skank playing offbeat chords. Layer a melancholic kora melody over a djembe pattern with shakers. Introduce a melodica phrase with heavy delay, floating in the stereo field. Keep the arrangement spacious, with filtered breakdowns and echo drops. No vocals. Reference: Thievery Corporation meets Baaba Maal desert warmth.",
|
||||
"lyrics": "[[A0]]\n[[A1]]\n[[A2]]\n[[A3]]\n[[A4]]\n[[A5]]",
|
||||
"file": "82595273_Caravan_of_the_Blue_Hour.mp3",
|
||||
"saved_at": "2026-06-07T13:28:47.487693+00:00"
|
||||
}
|
||||
"saved_at": "2026-06-07T13:28:47.488227+00:00",
|
||||
"generation": {
|
||||
"model": "lyria-3-pro-preview",
|
||||
"vocal_mode": "instrumental",
|
||||
"language": "auto",
|
||||
"chat_request": "more africa more tinariwen — successor vibe"
|
||||
},
|
||||
"structure": {
|
||||
"bpm": 90,
|
||||
"key": "A minor",
|
||||
"duration_sec_est": 105
|
||||
},
|
||||
"instruments_detected": ["sub bass", "kora", "djembe", "shakers", "muted guitar skank", "melodica"],
|
||||
"tags": ["dubtronica", "sahel", "instrumental", "keeper"],
|
||||
"curation": {
|
||||
"rating": "keeper",
|
||||
"rating_label": "good — more africa direction worked",
|
||||
"shuffle_weight": 1.1,
|
||||
"public_playlist": true,
|
||||
"listener": "ozan",
|
||||
"rated_at": "2026-06-07",
|
||||
"notes": "Listener said 'caravan of the blue hour is good' and asked for more Africa/Tinariwen. Kora + Sahel warmth lands well.",
|
||||
"loved": [
|
||||
"kora melody",
|
||||
"Sahel desert warmth",
|
||||
"sub bass stepper",
|
||||
"spacious dubtronica",
|
||||
"melodica delay"
|
||||
],
|
||||
"disliked": [],
|
||||
"avoid_in_successors": [],
|
||||
"clone_prompt_hints": "More Africa/Tinariwen energy but prefer saz/ney over muted guitar skank when Anadolu palette is needed."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"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.",
|
||||
"lyria_prompt": "West African griot-style male vocal with melodic, storytelling delivery, warm and reverberant, over a hypnotic desert dub at 85 BPM. Instrumentation: baglama/saz playing a haunting, repetitive Anatolian melody, ney flute weaving through, deep sub bass pulse, hand percussion (darbuka, shakers), dub delay and spring reverb on vocals and instruments. Subtle fuzz guitar with middle eastern scales, desert blues guitar lines. No Western pop production. Long, evolving structure with build and release. Mood: meditative but danceable, late-night caravan. Raw, organic, analog warmth.",
|
||||
"lyrics": "[[A0]]\n[[B1]]\n[11.3:] Hhhhh-haaaaa-shhh...\n[:] (Hhhhh-haaaaa-shhh...)\n[:] Ruh...\n[:] (Ruh...)\n[:] Shhhhhhh-khhhhhh-sssss...\n[:] (Shhhhhhh-khhhhhh-sssss...)\n[[C2]]\n[45.2:] Aaaaaah-maaaa-lay-ah!\n[:] Aaaaaah-maaaa-lay-ah!\n[:] Ohhh-maaa-lo-way!\n[:] Ohhh-maaa-lo-way!\n[:] Aaaaah-loooo-meh-yah!\n[:] Aaaaah-loooo-meh-yah!\n[[B3]]\n[79.1:] Hohm...\n[:] (Hohm...)\n[:] Shhhhhhh-aaaaah-haaaaaa...\n[:] (Shhhhhhh-aaaaah-haaaaaa...)\n[:] Haaaaaah-ohhhhh-shhh...\n[:] (Haaaaaah-ohhhhh-shhh...)\n[[C4]]\n[113.0:] Ya-la-la-ya-oh!\n[:] Ya-la-la-ya-eh!\n[:] Ya-la-la-ya-ah!\n[:] Ya-la-la-ya-oh!\n[:] Ya-la-la-ya-eh!\n[:] Ya-la-la-ya-ah!\n[:] Ya-la-la-ya-ah!\n[[D5]]\n[[E6]]\n[169.6:] Mmmmmmm-shhhhhhh...\n[:] Haaaaaaaaaaah...\n[:] Ruh.",
|
||||
"file": "839aa313_Caravan_of_the_Night.mp3",
|
||||
"saved_at": "2026-06-07T14:10:40.501450+00:00",
|
||||
"generation": {
|
||||
"model": "lyria-3-pro-preview",
|
||||
"vocal_mode": "vocals",
|
||||
"language": "auto",
|
||||
"chat_request": "West African griot vocals over Sahara's Saz — late night extra drops",
|
||||
"successor_of": "1c1d7b8a",
|
||||
"successor_title": "Sahara's Saz",
|
||||
"first_attempt": "blocked PROHIBITED_CONTENT, retry succeeded"
|
||||
},
|
||||
"structure": {
|
||||
"bpm": 85,
|
||||
"duration_sec_est": 110,
|
||||
"skip_intro_sec": null,
|
||||
"sections": [
|
||||
{ "at_sec": 0, "label": "Vocal + bed", "notes": "Griot texture enters early" },
|
||||
{ "at_sec": 45, "label": "Chant build", "notes": "Call-and-response peaks — strong" },
|
||||
{ "at_sec": 79, "label": "Guitar present", "notes": "Listener disliked this electric/fuzz guitar layer" }
|
||||
]
|
||||
},
|
||||
"instruments_detected": [
|
||||
"baglama/saz",
|
||||
"ney flute",
|
||||
"griot male vocal",
|
||||
"sub bass",
|
||||
"darbuka",
|
||||
"shakers",
|
||||
"fuzz electric guitar",
|
||||
"desert blues guitar"
|
||||
],
|
||||
"tags": ["desert-dub", "anadolu", "griot-vocals", "vocal-track", "successor-track"],
|
||||
"curation": {
|
||||
"rating": "keeper",
|
||||
"rating_label": "not bad — rally keeper, hated the guitar",
|
||||
"shuffle_weight": 0.75,
|
||||
"public_playlist": true,
|
||||
"listener": "ozan",
|
||||
"rated_at": "2026-06-07",
|
||||
"notes": "Overall not bad. Griot vocals and saz/ney caravan mood work. Hated the electric guitar bit — fuzz/desert-blues guitar fights the vocal track.",
|
||||
"loved": [
|
||||
"griot-style male vocal chants",
|
||||
"saz/baglama melody",
|
||||
"ney weaving",
|
||||
"sub bass pulse",
|
||||
"late-night caravan mood",
|
||||
"dub delay on vocals"
|
||||
],
|
||||
"disliked": [
|
||||
"electric guitar",
|
||||
"fuzz guitar",
|
||||
"desert blues guitar lines",
|
||||
"Tinariwen-style guitar on a vocal track"
|
||||
],
|
||||
"avoid_in_successors": [
|
||||
"electric guitar",
|
||||
"fuzz guitar",
|
||||
"desert blues guitar unless instrumental only"
|
||||
],
|
||||
"clone_prompt_hints": "Successor to Sahara's Saz with griot/Sahel male chants, saz+ney+dub only — NO electric or fuzz guitar. Start with saz not guitar."
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -2,9 +2,57 @@
|
||||
"id": "aee4994a",
|
||||
"title": "Nomad's Saz",
|
||||
"mood": "hypnotic desert dub with Turkish soul",
|
||||
"dj_line": "From the Sahara to Anatolia \u2014 here's a caravan of warm analog dub.",
|
||||
"dj_line": "From the Sahara to Anatolia — here's a caravan of warm analog dub.",
|
||||
"lyria_prompt": "Instrumental track at 85 BPM, desert blues meets Anadolu psych dub. Tinariwen-style electric guitar playing a slow, hypnotic riff with heavy spring reverb and dub delay. Layered with a darbuka playing a driving but relaxed rhythm, shakers, and hand percussion. A ney flute enters with a melancholic melody. Deep sub bass pulses underneath. A baglama/saz plays a sparse, atmospheric figure. Use warm analog tape saturation, spacious reverb, and subtle echo effects. No vocals. Build slowly, with a sense of nocturnal travel across dunes.",
|
||||
"lyrics": "[[A0]]\n[[B1]]\n[[C2]]\n[[B3]]\n[[D4]]\n[[E5]]",
|
||||
"file": "aee4994a_Nomad_s_Saz.mp3",
|
||||
"saved_at": "2026-06-07T13:56:19.182738+00:00"
|
||||
}
|
||||
"saved_at": "2026-06-07T13:56:19.182738+00:00",
|
||||
"generation": {
|
||||
"model": "lyria-3-pro-preview",
|
||||
"vocal_mode": "instrumental",
|
||||
"language": "auto"
|
||||
},
|
||||
"structure": {
|
||||
"bpm": 85,
|
||||
"duration_sec_est": 97,
|
||||
"skip_intro_sec": 30,
|
||||
"sections": [
|
||||
{ "at_sec": 0, "label": "Intro", "notes": "BAD — thin guitar/perc loop, skip this" },
|
||||
{ "at_sec": 30, "label": "Saz enters", "notes": "AMAZING — when saz kicks in the track opens up" }
|
||||
]
|
||||
},
|
||||
"instruments_detected": [
|
||||
"baglama/saz",
|
||||
"ney flute",
|
||||
"electric guitar",
|
||||
"sub bass",
|
||||
"darbuka",
|
||||
"shakers"
|
||||
],
|
||||
"tags": ["desert-dub", "anadolu", "instrumental", "vocal-booth-cued"],
|
||||
"curation": {
|
||||
"rating": "keeper",
|
||||
"rating_label": "saz amazing, intro bad",
|
||||
"shuffle_weight": 1.0,
|
||||
"public_playlist": true,
|
||||
"listener": "ozan",
|
||||
"rated_at": "2026-06-07",
|
||||
"notes": "Intro is a bit bad — guitar/perc only too long. When saz kicks in it's amazing. User wants deep throat/chest vocal layer over the saz section (vocal booth cues at 0:30).",
|
||||
"loved": [
|
||||
"saz when it enters",
|
||||
"ney melody",
|
||||
"sub bass",
|
||||
"warm analog dub"
|
||||
],
|
||||
"disliked": [
|
||||
"thin intro",
|
||||
"guitar-only opening",
|
||||
"slow saz entry"
|
||||
],
|
||||
"avoid_in_successors": [
|
||||
"long guitar-only intro before saz",
|
||||
"more than 15s without saz or ney"
|
||||
],
|
||||
"clone_prompt_hints": "Like Nomad's Saz from 0:30 onward — saz+ney first, skip thin guitar intro. Or start with saz immediately."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,39 @@
|
||||
"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 \u2014 let the delays carry you through the night.",
|
||||
"dj_line": "Sending you a dust-kissed groove from the edge of the dunes — let the delays carry you through the night.",
|
||||
"lyria_prompt": "Instrumental dubtronica track at 92 BPM in D minor. Start with a deep, sub-heavy bassline in a slow stepper rhythm. Layer a clean, plucked kora melody with long, spacey reverb and tape delay. Add a muted guitar skank playing offbeat chords with spring reverb. Hand percussion: djembe and shakers playing a relaxed, syncopated pattern. Introduce a melodica line with heavy echo, floating in the mix. Use analog warmth and subtle vinyl crackle. Build slowly, keeping the arrangement open and spacious. No vocals. Structure: intro with bass and percussion, add kora, then guitar, then melodica, then drop to just bass and percussion before returning with all elements. Fade out with delays.",
|
||||
"lyrics": "[[A0]]\n[[A1]]\n[[A2]]\n[[A3]]\n[[A4]]\n[[A5]]\n[[B6]]",
|
||||
"file": "eeebb429_Desert_Mirage.mp3",
|
||||
"saved_at": "2026-06-07T13:30:41.265970+00:00"
|
||||
}
|
||||
"saved_at": "2026-06-07T13:30:41.265980+00:00",
|
||||
"generation": {
|
||||
"model": "lyria-3-pro-preview",
|
||||
"vocal_mode": "instrumental",
|
||||
"language": "auto"
|
||||
},
|
||||
"structure": {
|
||||
"bpm": 92,
|
||||
"key": "D minor",
|
||||
"duration_sec_est": 100
|
||||
},
|
||||
"instruments_detected": ["kora", "sub bass", "djembe", "shakers", "muted guitar", "melodica"],
|
||||
"tags": ["dubtronica", "desert-dub", "instrumental", "keeper"],
|
||||
"curation": {
|
||||
"rating": "keeper",
|
||||
"rating_label": "solid keeper",
|
||||
"shuffle_weight": 1.0,
|
||||
"public_playlist": true,
|
||||
"listener": "ozan",
|
||||
"rated_at": "2026-06-07",
|
||||
"notes": "Kept in curated library. Dust-kissed late-night dub — reliable shuffle track.",
|
||||
"loved": [
|
||||
"kora with tape delay",
|
||||
"spacious arrangement",
|
||||
"analog warmth",
|
||||
"late-night desert dub"
|
||||
],
|
||||
"disliked": [],
|
||||
"avoid_in_successors": [],
|
||||
"clone_prompt_hints": "Same dust-kissed dubtronica palette — kora, melodica echo, sub-heavy stepper."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"updated": "2026-06-07",
|
||||
"listener": "ozan",
|
||||
"summary": "Five keepers. Sahara's Saz is the gold standard. Saz+ney+dub wins; thin guitar intros and fuzz electric guitar on vocal tracks lose.",
|
||||
"global_avoid": [
|
||||
"fuzz electric guitar on griot/vocal tracks",
|
||||
"Tinariwen-style guitar intro without saz present",
|
||||
"thin guitar-only openings — get to saz/ney fast"
|
||||
],
|
||||
"global_love": [
|
||||
"baglama/saz arpeggios",
|
||||
"ney flute with dub delay",
|
||||
"sub bass pulse",
|
||||
"Sahel griot-style male vocal chants",
|
||||
"whispered vocal textures",
|
||||
"darbuka and hand percussion",
|
||||
"warm analog dub space"
|
||||
],
|
||||
"gold_standard_id": "1c1d7b8a",
|
||||
"tracks": [
|
||||
{ "id": "1c1d7b8a", "title": "Sahara's Saz", "rating": "love" },
|
||||
{ "id": "82595273", "title": "Caravan of the Blue Hour", "rating": "keeper" },
|
||||
{ "id": "eeebb429", "title": "Desert Mirage", "rating": "keeper" },
|
||||
{ "id": "aee4994a", "title": "Nomad's Saz", "rating": "keeper" },
|
||||
{ "id": "839aa313", "title": "Caravan of the Night", "rating": "keeper" }
|
||||
]
|
||||
}
|
||||
+17
-8
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"index": 3,
|
||||
"count": 4,
|
||||
"index": 2,
|
||||
"count": 5,
|
||||
"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.",
|
||||
"dj_line": "Late-night dub caravan from the Sahel \u2014 let the bass carry you across the dunes.",
|
||||
"lyria_prompt": "Create a dubtronica track at 90 BPM in A minor. Start with a deep sub bass and a slow, hypnotic stepper beat with rimshot and kick. Add warm analog spring reverb and delay on a muted guitar skank playing offbeat chords. Layer a melancholic kora melody over a djembe pattern with shakers. Introduce a melodica phrase with heavy delay, floating in the stereo field. Keep the arrangement spacious, with filtered breakdowns and echo drops. No vocals. Reference: Thievery Corporation meets Baaba Maal desert warmth.",
|
||||
"lyrics": "[[A0]]\n[[A1]]\n[[A2]]\n[[A3]]\n[[A4]]\n[[A5]]",
|
||||
"file": "82595273_Caravan_of_the_Blue_Hour.mp3"
|
||||
@@ -15,7 +15,7 @@
|
||||
"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.",
|
||||
"dj_line": "Sending you a dust-kissed groove from the edge of the dunes \u2014 let the delays carry you through the night.",
|
||||
"lyria_prompt": "Instrumental dubtronica track at 92 BPM in D minor. Start with a deep, sub-heavy bassline in a slow stepper rhythm. Layer a clean, plucked kora melody with long, spacey reverb and tape delay. Add a muted guitar skank playing offbeat chords with spring reverb. Hand percussion: djembe and shakers playing a relaxed, syncopated pattern. Introduce a melodica line with heavy echo, floating in the mix. Use analog warmth and subtle vinyl crackle. Build slowly, keeping the arrangement open and spacious. No vocals. Structure: intro with bass and percussion, add kora, then guitar, then melodica, then drop to just bass and percussion before returning with all elements. Fade out with delays.",
|
||||
"lyrics": "[[A0]]\n[[A1]]\n[[A2]]\n[[A3]]\n[[A4]]\n[[A5]]\n[[B6]]",
|
||||
"file": "eeebb429_Desert_Mirage.mp3"
|
||||
@@ -24,8 +24,8 @@
|
||||
"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.",
|
||||
"lyria_prompt": "Instrumental. Tempo 85 BPM, key of D minor. Structure: Intro (0-20s: dusty fuzz guitar playing a hypnotic Tinariwen-style riff, darbuka and hand percussion enter softly), Verse (20-50s: sub bass pulse, ney flute melody floating over bağlama/saz arpeggios, dub delay on the flute), Build (50-70s: percussion intensifies, spring reverb swells, whispered vocal textures drift in and out), Drop (70-90s: stripped back to bass and percussion, then re-enter all elements with heavy dub echo), Outro (90-110s: fade out with ney and saz, reverb tail). Instruments: electric guitar (fuzz, middle eastern scales), darbuka, djembe, ney flute, bağlama, sub bass, dub effects (spring reverb, tape delay). Production: warm analog saturation, wide stereo field, late-night intimate feel.",
|
||||
"dj_line": "Blowing sand and saz strings \u2014 a slow burn across the dunes, right here on Live Ozan.",
|
||||
"lyria_prompt": "Instrumental. Tempo 85 BPM, key of D minor. Structure: Intro (0-20s: dusty fuzz guitar playing a hypnotic Tinariwen-style riff, darbuka and hand percussion enter softly), Verse (20-50s: sub bass pulse, ney flute melody floating over ba\u011flama/saz arpeggios, dub delay on the flute), Build (50-70s: percussion intensifies, spring reverb swells, whispered vocal textures drift in and out), Drop (70-90s: stripped back to bass and percussion, then re-enter all elements with heavy dub echo), Outro (90-110s: fade out with ney and saz, reverb tail). Instruments: electric guitar (fuzz, middle eastern scales), darbuka, djembe, ney flute, ba\u011flama, sub bass, dub effects (spring reverb, tape delay). Production: warm analog saturation, wide stereo field, late-night intimate feel.",
|
||||
"lyrics": "[[A0]]\n[[B1]]\n[[C2]]\n[[D3]]\n[[E4]]",
|
||||
"file": "1c1d7b8a_Sahara_s_Saz.mp3"
|
||||
},
|
||||
@@ -33,10 +33,19 @@
|
||||
"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.",
|
||||
"dj_line": "From the Sahara to Anatolia \u2014 here's a caravan of warm analog dub.",
|
||||
"lyria_prompt": "Instrumental track at 85 BPM, desert blues meets Anadolu psych dub. Tinariwen-style electric guitar playing a slow, hypnotic riff with heavy spring reverb and dub delay. Layered with a darbuka playing a driving but relaxed rhythm, shakers, and hand percussion. A ney flute enters with a melancholic melody. Deep sub bass pulses underneath. A baglama/saz plays a sparse, atmospheric figure. Use warm analog tape saturation, spacious reverb, and subtle echo effects. No vocals. Build slowly, with a sense of nocturnal travel across dunes.",
|
||||
"lyrics": "[[A0]]\n[[B1]]\n[[C2]]\n[[B3]]\n[[D4]]\n[[E5]]",
|
||||
"file": "aee4994a_Nomad_s_Saz.mp3"
|
||||
},
|
||||
{
|
||||
"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 \u2014 this one's for the wanderers.",
|
||||
"lyria_prompt": "West African griot-style male vocal with melodic, storytelling delivery, warm and reverberant, over a hypnotic desert dub at 85 BPM. Instrumentation: baglama/saz playing a haunting, repetitive Anatolian melody, ney flute weaving through, deep sub bass pulse, hand percussion (darbuka, shakers), dub delay and spring reverb on vocals and instruments. Subtle fuzz guitar with middle eastern scales, desert blues guitar lines. No Western pop production. Long, evolving structure with build and release. Mood: meditative but danceable, late-night caravan. Raw, organic, analog warmth.",
|
||||
"lyrics": "[[A0]]\n[[B1]]\n[11.3:] Hhhhh-haaaaa-shhh...\n[:] (Hhhhh-haaaaa-shhh...)\n[:] Ruh...\n[:] (Ruh...)\n[:] Shhhhhhh-khhhhhh-sssss...\n[:] (Shhhhhhh-khhhhhh-sssss...)\n[[C2]]\n[45.2:] Aaaaaah-maaaa-lay-ah!\n[:] Aaaaaah-maaaa-lay-ah!\n[:] Ohhh-maaa-lo-way!\n[:] Ohhh-maaa-lo-way!\n[:] Aaaaah-loooo-meh-yah!\n[:] Aaaaah-loooo-meh-yah!\n[[B3]]\n[79.1:] Hohm...\n[:] (Hohm...)\n[:] Shhhhhhh-aaaaah-haaaaaa...\n[:] (Shhhhhhh-aaaaah-haaaaaa...)\n[:] Haaaaaah-ohhhhh-shhh...\n[:] (Haaaaaah-ohhhhh-shhh...)\n[[C4]]\n[113.0:] Ya-la-la-ya-oh!\n[:] Ya-la-la-ya-eh!\n[:] Ya-la-la-ya-ah!\n[:] Ya-la-la-ya-oh!\n[:] Ya-la-la-ya-eh!\n[:] Ya-la-la-ya-ah!\n[:] Ya-la-la-ya-ah!\n[[D5]]\n[[E6]]\n[169.6:] Mmmmmmm-shhhhhhh...\n[:] Haaaaaaaaaaah...\n[:] Ruh.",
|
||||
"file": "839aa313_Caravan_of_the_Night.mp3"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ from ozan_radio.lyria import LyriaEngine
|
||||
from ozan_radio.queue import RadioQueue
|
||||
from ozan_radio.server import app
|
||||
from ozan_radio.taste import load_taste_seeds
|
||||
from ozan_radio.web_playlist import export_gateway_playlist
|
||||
|
||||
|
||||
async def generate_one() -> None:
|
||||
@@ -40,8 +41,8 @@ def main() -> None:
|
||||
"command",
|
||||
nargs="?",
|
||||
default="serve",
|
||||
choices=["serve", "generate"],
|
||||
help="serve = start radio server, generate = one-shot track",
|
||||
choices=["serve", "generate", "export-web"],
|
||||
help="serve = API server, generate = one-shot track, export-web = refresh gateway/index.html playlist",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -49,6 +50,14 @@ def main() -> None:
|
||||
asyncio.run(generate_one())
|
||||
return
|
||||
|
||||
if args.command == "export-web":
|
||||
path = export_gateway_playlist()
|
||||
if path:
|
||||
print(f"Updated gateway playlist -> {path}")
|
||||
else:
|
||||
print("gateway/index.html missing — nothing exported")
|
||||
return
|
||||
|
||||
cfg = Config.from_env()
|
||||
uvicorn.run(app, host=cfg.radio_host, port=cfg.radio_port, log_level="info")
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"""DJ curation — extensive per-track metadata for planning the next generation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
RATING_ORDER = {"love": 4, "keeper": 3, "meh": 2, "skip": 1, "": 0}
|
||||
|
||||
|
||||
def load_track_meta(meta_path: Path) -> dict | None:
|
||||
try:
|
||||
return json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def list_curated_tracks(songs_dir: Path) -> list[dict]:
|
||||
tracks: list[dict] = []
|
||||
for meta_path in sorted(songs_dir.glob("*.meta.json")):
|
||||
data = load_track_meta(meta_path)
|
||||
if not data:
|
||||
continue
|
||||
mp3 = songs_dir / data.get("file", "")
|
||||
if not mp3.exists():
|
||||
continue
|
||||
tracks.append(data)
|
||||
tracks.sort(
|
||||
key=lambda t: (
|
||||
RATING_ORDER.get((t.get("curation") or {}).get("rating", ""), 0),
|
||||
t.get("saved_at", ""),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
return tracks
|
||||
|
||||
|
||||
def curation_summary_for_dj(songs_dir: Path) -> str:
|
||||
"""Compact block for DeepSeek — what worked, what failed, clone hints."""
|
||||
tracks = list_curated_tracks(songs_dir)
|
||||
if not tracks:
|
||||
return "No curated library yet."
|
||||
|
||||
lines = ["Listener curation (from saved track metadata):"]
|
||||
all_loved: list[str] = []
|
||||
all_disliked: list[str] = []
|
||||
all_avoid: list[str] = []
|
||||
|
||||
for t in tracks:
|
||||
c = t.get("curation") or {}
|
||||
rating = c.get("rating", "unrated")
|
||||
if rating == "skip":
|
||||
continue
|
||||
title = t.get("title", t.get("id", "?"))
|
||||
loved = c.get("loved") or []
|
||||
disliked = c.get("disliked") or []
|
||||
avoid = c.get("avoid_in_successors") or []
|
||||
notes = c.get("notes", "")
|
||||
hints = c.get("clone_prompt_hints", "")
|
||||
all_loved.extend(loved)
|
||||
all_disliked.extend(disliked)
|
||||
all_avoid.extend(avoid)
|
||||
|
||||
parts = [f"- {title} [{rating}]"]
|
||||
if loved:
|
||||
parts.append(f"loved: {', '.join(loved)}")
|
||||
if disliked:
|
||||
parts.append(f"disliked: {', '.join(disliked)}")
|
||||
if notes:
|
||||
parts.append(f"notes: {notes}")
|
||||
if hints:
|
||||
parts.append(f"clone hint: {hints}")
|
||||
lines.append(" ".join(parts))
|
||||
|
||||
if all_loved:
|
||||
lines.append(f"Across keepers, lean into: {', '.join(_unique(all_loved))}.")
|
||||
if all_disliked:
|
||||
lines.append(f"Avoid unless asked: {', '.join(_unique(all_disliked))}.")
|
||||
if all_avoid:
|
||||
lines.append(f"Hard avoid in new prompts: {', '.join(_unique(all_avoid))}.")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _unique(items: list[str]) -> list[str]:
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for item in items:
|
||||
key = item.lower().strip()
|
||||
if key and key not in seen:
|
||||
seen.add(key)
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
def default_curation_block() -> dict:
|
||||
return {
|
||||
"rating": "unrated",
|
||||
"shuffle_weight": 1.0,
|
||||
"public_playlist": True,
|
||||
"listener": "ozan",
|
||||
"notes": "",
|
||||
"loved": [],
|
||||
"disliked": [],
|
||||
"avoid_in_successors": [],
|
||||
"clone_prompt_hints": "",
|
||||
}
|
||||
+21
-3
@@ -7,12 +7,17 @@ from dataclasses import dataclass
|
||||
import httpx
|
||||
|
||||
from ozan_radio.config import Config
|
||||
from ozan_radio.settings import ListenerSettings, load_settings
|
||||
from ozan_radio.curation import curation_summary_for_dj
|
||||
from ozan_radio.settings import ListenerSettings, load_lyria_settings, load_settings
|
||||
from ozan_radio.taste import TasteSeeds
|
||||
|
||||
CHAT_SYSTEM = """You are the on-air DJ for Live Ozan Radio. Chat with the listener in a warm,
|
||||
concise radio-host voice. You never play catalog music — only AI-generated tracks via Lyria 3.
|
||||
|
||||
Lyria cannot remix or edit an existing MP3. If the listener asks to "add vocals to this song"
|
||||
or change a saved track, explain you're composing a NEW successor track in that spirit — not
|
||||
overlaying on the file they're hearing.
|
||||
|
||||
When the listener asks for a vibe, mood, genre, or "play something X", set action to "generate"
|
||||
and include a vibe_hint Lyria can use. Otherwise action is "none".
|
||||
|
||||
@@ -34,6 +39,13 @@ Your job:
|
||||
4. Keep variety — don't repeat the same vibe twice in a row.
|
||||
5. Follow settings.json taste profile when provided — it overrides generic defaults.
|
||||
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.
|
||||
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. Gold standard track: Sahara's Saz — saz+ney+sub bass by 0:20, whispered textures OK.
|
||||
|
||||
Respond with JSON only:
|
||||
{
|
||||
@@ -68,10 +80,16 @@ class DeepSeekDJ:
|
||||
self._config = config
|
||||
|
||||
def _taste_block(self) -> str:
|
||||
parts: list[str] = []
|
||||
settings = load_settings()
|
||||
if settings:
|
||||
return settings.dj_context()
|
||||
return "No settings.json — freestyle eclectic world groove."
|
||||
parts.append(settings.dj_context())
|
||||
else:
|
||||
parts.append("No settings.json — freestyle eclectic world groove.")
|
||||
parts.append(load_lyria_settings().dj_context())
|
||||
cfg = Config.from_env()
|
||||
parts.append(curation_summary_for_dj(cfg.output_dir))
|
||||
return "\n".join(parts)
|
||||
|
||||
async def _completion(self, messages: list[dict], *, json_mode: bool = False) -> str:
|
||||
self._config.require_deepseek()
|
||||
|
||||
@@ -4,6 +4,7 @@ import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from ozan_radio.curation import default_curation_block
|
||||
from ozan_radio.lyria import GeneratedTrack
|
||||
|
||||
|
||||
@@ -50,7 +51,12 @@ def migrate_legacy_cache(output_dir: Path) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def save_track_record(track: GeneratedTrack, output_dir: Path) -> Path:
|
||||
def save_track_record(
|
||||
track: GeneratedTrack,
|
||||
output_dir: Path,
|
||||
*,
|
||||
generation: dict | None = None,
|
||||
) -> Path:
|
||||
"""Write per-track metadata JSON alongside the MP3."""
|
||||
meta_path = output_dir / f"{track.plan.id}.meta.json"
|
||||
payload = {
|
||||
@@ -62,6 +68,11 @@ def save_track_record(track: GeneratedTrack, output_dir: Path) -> Path:
|
||||
"lyrics": track.lyrics,
|
||||
"file": track.audio_path.name,
|
||||
"saved_at": datetime.now(timezone.utc).isoformat(),
|
||||
"generation": generation or {},
|
||||
"structure": {},
|
||||
"instruments_detected": [],
|
||||
"tags": [],
|
||||
"curation": default_curation_block(),
|
||||
}
|
||||
meta_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||
return meta_path
|
||||
|
||||
+161
-35
@@ -38,6 +38,16 @@ def friendly_lyria_error(exc: Exception) -> str:
|
||||
return f"Lyria generation failed: {exc}"
|
||||
|
||||
|
||||
def _block_reason(response) -> str | None:
|
||||
feedback = getattr(response, "prompt_feedback", None)
|
||||
if not feedback:
|
||||
return None
|
||||
block = getattr(feedback, "block_reason", None)
|
||||
if not block:
|
||||
return None
|
||||
return str(block).replace("BlockedReason.", "")
|
||||
|
||||
|
||||
def _response_parts(response) -> list:
|
||||
"""SDK sometimes leaves response.parts None; audio lives on candidates."""
|
||||
parts = getattr(response, "parts", None)
|
||||
@@ -46,11 +56,6 @@ def _response_parts(response) -> list:
|
||||
|
||||
candidates = getattr(response, "candidates", None) or []
|
||||
if not candidates:
|
||||
feedback = getattr(response, "prompt_feedback", None)
|
||||
if feedback:
|
||||
block = getattr(feedback, "block_reason", None)
|
||||
if block:
|
||||
return []
|
||||
return []
|
||||
|
||||
content = getattr(candidates[0], "content", None)
|
||||
@@ -61,6 +66,108 @@ def _response_parts(response) -> list:
|
||||
return list(getattr(content, "parts", None) or [])
|
||||
|
||||
|
||||
def _wants_vocals(prompt: str) -> bool:
|
||||
lower = prompt.lower()
|
||||
explicit_no = any(
|
||||
x in lower for x in ("no vocals", "no vocal", "instrumental only", "instrumental.")
|
||||
)
|
||||
vocal_markers = (
|
||||
" with vocals",
|
||||
" lead vocal",
|
||||
" lead vocals",
|
||||
" male vocal",
|
||||
" female vocal",
|
||||
" singing",
|
||||
" singer",
|
||||
" lyrics",
|
||||
" chant",
|
||||
" griot",
|
||||
" hindi",
|
||||
" devotional",
|
||||
"call-and-response",
|
||||
"call and response",
|
||||
"vocal texture",
|
||||
"vocal textures",
|
||||
"whispered vocal",
|
||||
)
|
||||
if any(m in lower for m in vocal_markers):
|
||||
return True
|
||||
if explicit_no:
|
||||
return False
|
||||
return "vocal" in lower or "vocals" in lower
|
||||
|
||||
|
||||
def _build_lyria_prompt(plan: TrackPlan, lyria_cfg) -> str:
|
||||
from ozan_radio.settings import LANGUAGE_PROMPTS, SINGER_PROFILE_TEXT
|
||||
|
||||
base = plan.lyria_prompt.strip()
|
||||
wants_vocals = _wants_vocals(base)
|
||||
mode = lyria_cfg.vocal_mode
|
||||
if wants_vocals:
|
||||
mode = "vocals"
|
||||
|
||||
parts = [base, ""]
|
||||
|
||||
if mode == "instrumental":
|
||||
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. "
|
||||
"No full lead lyrics unless already specified above. "
|
||||
"High fidelity, stereo, radio-ready mix."
|
||||
)
|
||||
else:
|
||||
parts.append("Include lead vocals with melodic delivery. High fidelity, stereo, radio-ready mix.")
|
||||
if lyria_cfg.language and lyria_cfg.language != "auto":
|
||||
lang_line = LANGUAGE_PROMPTS.get(lyria_cfg.language)
|
||||
if lang_line:
|
||||
parts.append(lang_line)
|
||||
if lyria_cfg.singer_profile:
|
||||
profile = SINGER_PROFILE_TEXT.get(lyria_cfg.singer_profile, lyria_cfg.singer_profile)
|
||||
parts.append(f"Vocal delivery: {profile}")
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
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",
|
||||
"vocal sample": "wordless vocal texture",
|
||||
}
|
||||
for old, new in replacements.items():
|
||||
softened = softened.replace(old, new)
|
||||
if _wants_vocals(softened):
|
||||
softened += (
|
||||
"\n\nVocals should be melodic chants or wordless textures — "
|
||||
"no explicit lyrics, no speech samples."
|
||||
)
|
||||
return softened
|
||||
|
||||
|
||||
def _empty_response_message(response) -> str:
|
||||
block = _block_reason(response)
|
||||
if block and block != "BLOCK_REASON_UNSPECIFIED":
|
||||
return (
|
||||
f"Lyria blocked the prompt ({block}) — often triggered by vocal or "
|
||||
"cultural wording. Retrying with softer phrasing, or ask for "
|
||||
"'wordless Sahel vocal textures' instead of griot samples."
|
||||
)
|
||||
candidates = getattr(response, "candidates", None) or []
|
||||
if candidates:
|
||||
finish = getattr(candidates[0], "finish_reason", None)
|
||||
if finish:
|
||||
return f"Lyria returned no audio (finish_reason={finish})"
|
||||
return (
|
||||
"Lyria returned no audio — prompt may have been blocked or the API "
|
||||
"returned an empty response. Not a credits issue if other tracks worked."
|
||||
)
|
||||
|
||||
|
||||
class LyriaEngine:
|
||||
"""Google Lyria 3 — generates tracks from DJ prompts."""
|
||||
|
||||
@@ -69,49 +176,68 @@ class LyriaEngine:
|
||||
config.require_gemini()
|
||||
self._client = genai.Client(api_key=config.gemini_api_key)
|
||||
|
||||
def generate(self, plan: TrackPlan) -> GeneratedTrack:
|
||||
prompt = (
|
||||
f"{plan.lyria_prompt}\n\n"
|
||||
"Instrumental preferred unless the prompt explicitly asks for vocals. "
|
||||
"High fidelity, stereo, radio-ready mix."
|
||||
def _call_lyria(self, prompt: str, *, model: str, output_format: str):
|
||||
config_kwargs: dict = {"response_modalities": ["AUDIO", "TEXT"]}
|
||||
if output_format == "wav" and "clip" not in model:
|
||||
config_kwargs["response_format"] = {"audio": {"mime_type": "audio/wav"}}
|
||||
return self._client.models.generate_content(
|
||||
model=model,
|
||||
contents=prompt,
|
||||
config=types.GenerateContentConfig(**config_kwargs),
|
||||
)
|
||||
|
||||
def generate(self, plan: TrackPlan) -> GeneratedTrack:
|
||||
from ozan_radio.settings import load_lyria_settings
|
||||
|
||||
lyria_cfg = load_lyria_settings()
|
||||
model = lyria_cfg.model or self._config.lyria_model
|
||||
prompt = _build_lyria_prompt(plan, lyria_cfg)
|
||||
|
||||
try:
|
||||
response = self._client.models.generate_content(
|
||||
model=self._config.lyria_model,
|
||||
contents=prompt,
|
||||
config=types.GenerateContentConfig(
|
||||
response_modalities=["AUDIO", "TEXT"],
|
||||
),
|
||||
response = self._call_lyria(
|
||||
prompt, model=model, output_format=lyria_cfg.output_format
|
||||
)
|
||||
except (genai_errors.ClientError, genai_errors.ServerError) as exc:
|
||||
raise RuntimeError(friendly_lyria_error(exc)) from exc
|
||||
|
||||
parts = _response_parts(response)
|
||||
if not parts:
|
||||
raise RuntimeError(
|
||||
"Lyria returned no audio — prompt may have been blocked or the API "
|
||||
"returned an empty response. Not a credits issue if other tracks worked."
|
||||
)
|
||||
|
||||
lyrics = ""
|
||||
audio_bytes: bytes | None = None
|
||||
|
||||
for part in parts:
|
||||
if part.text:
|
||||
lyrics += part.text + "\n"
|
||||
elif part.inline_data and part.inline_data.data:
|
||||
audio_bytes = part.inline_data.data
|
||||
audio_bytes = _extract_audio(parts)
|
||||
|
||||
if not audio_bytes:
|
||||
raise RuntimeError(
|
||||
f"Lyria returned no audio for track {plan.id} — check API quota at "
|
||||
"https://aistudio.google.com/apikey"
|
||||
)
|
||||
retry_prompt = _sanitize_for_retry(prompt)
|
||||
if retry_prompt != prompt:
|
||||
try:
|
||||
response = self._call_lyria(
|
||||
retry_prompt, model=model, output_format=lyria_cfg.output_format
|
||||
)
|
||||
parts = _response_parts(response)
|
||||
audio_bytes = _extract_audio(parts)
|
||||
except (genai_errors.ClientError, genai_errors.ServerError) as exc:
|
||||
raise RuntimeError(friendly_lyria_error(exc)) from exc
|
||||
|
||||
ext = "mp3"
|
||||
if not audio_bytes:
|
||||
raise RuntimeError(_empty_response_message(response))
|
||||
|
||||
lyrics = _extract_lyrics(parts)
|
||||
|
||||
ext = "wav" if lyria_cfg.output_format == "wav" and "clip" not in model else "mp3"
|
||||
safe_title = "".join(c if c.isalnum() or c in "-_" else "_" for c in plan.title)[:40]
|
||||
out_path = self._config.output_dir / f"{plan.id}_{safe_title}.{ext}"
|
||||
out_path.write_bytes(audio_bytes)
|
||||
|
||||
return GeneratedTrack(plan=plan, audio_path=out_path, lyrics=lyrics.strip())
|
||||
|
||||
|
||||
def _extract_lyrics(parts: list) -> str:
|
||||
lyrics = ""
|
||||
for part in parts:
|
||||
if part.text:
|
||||
lyrics += part.text + "\n"
|
||||
return lyrics.strip()
|
||||
|
||||
|
||||
def _extract_audio(parts: list) -> bytes | None:
|
||||
for part in parts:
|
||||
if part.inline_data and part.inline_data.data:
|
||||
return part.inline_data.data
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Lyria 3 capabilities — synced from Google Gemini API docs + live model list."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from google import genai
|
||||
from google.genai import errors as genai_errors
|
||||
|
||||
DOCS_URL = "https://ai.google.dev/gemini-api/docs/music-generation"
|
||||
|
||||
VOCAL_MODES = [
|
||||
{
|
||||
"id": "instrumental",
|
||||
"label": "Instrumental only",
|
||||
"hint": "Lyria tip: include 'Instrumental only, no vocals' in every prompt.",
|
||||
},
|
||||
{
|
||||
"id": "mix",
|
||||
"label": "Mix (textures + chants)",
|
||||
"hint": "Wordless vocals, Sahel chants, whispers — no full lead lyrics unless chat asks.",
|
||||
},
|
||||
{
|
||||
"id": "vocals",
|
||||
"label": "Full vocals",
|
||||
"hint": "Lead vocals and lyrics; language follows prompt or Lyria language setting.",
|
||||
},
|
||||
]
|
||||
|
||||
LANGUAGES = [
|
||||
{"id": "auto", "label": "Auto (match prompt / taste)"},
|
||||
{"id": "en", "label": "English"},
|
||||
{"id": "hi", "label": "Hindi"},
|
||||
{"id": "tr", "label": "Turkish"},
|
||||
{"id": "fr", "label": "French"},
|
||||
{"id": "ar", "label": "Arabic"},
|
||||
{"id": "es", "label": "Spanish"},
|
||||
{"id": "pt", "label": "Portuguese"},
|
||||
{"id": "de", "label": "German"},
|
||||
]
|
||||
|
||||
SINGER_PROFILES = [
|
||||
{"id": "", "label": "Default (model picks)"},
|
||||
{"id": "male_baritone", "label": "Male baritone — warm, desert blues"},
|
||||
{"id": "male_tenor", "label": "Male tenor — bright, energetic"},
|
||||
{"id": "female_alto", "label": "Female alto — smoky, soulful"},
|
||||
{"id": "female_soprano", "label": "Female soprano — airy, soaring"},
|
||||
{"id": "weathered_rocker", "label": "Weathered rocker — raspy 90s grit"},
|
||||
]
|
||||
|
||||
OUTPUT_FORMATS = [
|
||||
{"id": "mp3", "label": "MP3 (default)"},
|
||||
{"id": "wav", "label": "WAV (Pro only, larger files)"},
|
||||
]
|
||||
|
||||
STATIC_MODELS = [
|
||||
{
|
||||
"id": "lyria-3-pro-preview",
|
||||
"label": "Lyria 3 Pro",
|
||||
"duration": "1–2 minutes",
|
||||
"cost_key": "lyria_pro_usd",
|
||||
"supports_wav": True,
|
||||
"supports_timestamps": True,
|
||||
"supports_image_input": True,
|
||||
},
|
||||
{
|
||||
"id": "lyria-3-clip-preview",
|
||||
"label": "Lyria 3 Clip",
|
||||
"duration": "30 seconds",
|
||||
"cost_key": "lyria_clip_usd",
|
||||
"supports_wav": False,
|
||||
"supports_timestamps": False,
|
||||
"supports_image_input": True,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class LyriaApiStatus:
|
||||
ok: bool
|
||||
message: str
|
||||
models_available: list[str]
|
||||
key_configured: bool
|
||||
|
||||
|
||||
def static_capabilities() -> dict:
|
||||
return {
|
||||
"docs_url": DOCS_URL,
|
||||
"models": STATIC_MODELS,
|
||||
"vocal_modes": VOCAL_MODES,
|
||||
"languages": LANGUAGES,
|
||||
"singer_profiles": SINGER_PROFILES,
|
||||
"output_formats": OUTPUT_FORMATS,
|
||||
"features": [
|
||||
"Text-to-music via generateContent",
|
||||
"Vocals + timed lyrics (Pro)",
|
||||
"Instrumental-only prompts",
|
||||
"Custom lyrics with [Verse]/[Chorus] tags",
|
||||
"Timestamp structure [0:00 - 0:30]",
|
||||
"Prompt language steers lyric language",
|
||||
"Image-to-music (multimodal)",
|
||||
"44.1 kHz stereo MP3 or WAV (Pro)",
|
||||
],
|
||||
"prompt_tips": [
|
||||
"Iterate with Clip (30s) before burning a Pro generation.",
|
||||
"Be specific: BPM, key, instruments, mood, structure.",
|
||||
"For instrumentals, always say 'Instrumental only, no vocals'.",
|
||||
"For vocals, describe singer gender, timbre, and range.",
|
||||
"Avoid 'vocal sample' / 'griot sample' — use 'Sahel blues chants'.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def probe_lyria_api(api_key: str | None) -> dict:
|
||||
if not api_key:
|
||||
return LyriaApiStatus(
|
||||
ok=False,
|
||||
message="GEMINI_API_KEY not set",
|
||||
models_available=[],
|
||||
key_configured=False,
|
||||
).__dict__
|
||||
|
||||
try:
|
||||
client = genai.Client(api_key=api_key)
|
||||
found: list[str] = []
|
||||
for model in client.models.list():
|
||||
name = (getattr(model, "name", "") or "").replace("models/", "")
|
||||
if "lyria" in name.lower():
|
||||
found.append(name)
|
||||
if not found:
|
||||
return LyriaApiStatus(
|
||||
ok=False,
|
||||
message="API key works but no Lyria models visible on this project",
|
||||
models_available=[],
|
||||
key_configured=True,
|
||||
).__dict__
|
||||
return LyriaApiStatus(
|
||||
ok=True,
|
||||
message=f"Lyria ready — {len(found)} model(s) available",
|
||||
models_available=sorted(found),
|
||||
key_configured=True,
|
||||
).__dict__
|
||||
except genai_errors.ClientError as exc:
|
||||
return LyriaApiStatus(
|
||||
ok=False,
|
||||
message=f"Gemini API error: {exc}",
|
||||
models_available=[],
|
||||
key_configured=True,
|
||||
).__dict__
|
||||
except Exception as exc:
|
||||
return LyriaApiStatus(
|
||||
ok=False,
|
||||
message=f"Probe failed: {exc}",
|
||||
models_available=[],
|
||||
key_configured=True,
|
||||
).__dict__
|
||||
@@ -7,6 +7,7 @@ from pathlib import Path
|
||||
|
||||
from ozan_radio.dj import TrackPlan
|
||||
from ozan_radio.library import save_track_record
|
||||
from ozan_radio.web_playlist import export_gateway_playlist
|
||||
from ozan_radio.lyria import GeneratedTrack
|
||||
|
||||
|
||||
@@ -50,12 +51,33 @@ class RadioQueue:
|
||||
GeneratedTrack(plan=plan, audio_path=path, lyrics=entry.get("lyrics", ""))
|
||||
)
|
||||
if self._tracks:
|
||||
self._prune_manifest()
|
||||
return
|
||||
except (json.JSONDecodeError, OSError, KeyError):
|
||||
self._tracks = []
|
||||
self._index = 0
|
||||
|
||||
self._restore_from_filenames()
|
||||
self._prune_manifest()
|
||||
|
||||
def _prune_manifest(self) -> None:
|
||||
"""Drop manifest entries whose MP3s were deleted; clamp index to queue length."""
|
||||
if self._index >= len(self._tracks):
|
||||
self._index = max(0, len(self._tracks) - 1)
|
||||
if not self._manifest.exists():
|
||||
return
|
||||
try:
|
||||
data = json.loads(self._manifest.read_text(encoding="utf-8"))
|
||||
on_disk = {t.audio_path.name for t in self._tracks}
|
||||
stale = [
|
||||
e
|
||||
for e in data.get("tracks", [])
|
||||
if e.get("file") not in on_disk
|
||||
]
|
||||
if stale:
|
||||
self._save_manifest()
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
|
||||
def _restore_from_filenames(self) -> None:
|
||||
for path in sorted(self._output_dir.glob("*.mp3")):
|
||||
@@ -101,6 +123,10 @@ class RadioQueue:
|
||||
save_track_record(track, self._output_dir)
|
||||
self._tracks.append(track)
|
||||
self._save_manifest()
|
||||
try:
|
||||
export_gateway_playlist(self._output_dir.parent)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def play_id(self, track_id: str) -> GeneratedTrack | None:
|
||||
for i, track in enumerate(self._tracks):
|
||||
|
||||
@@ -4,6 +4,9 @@ import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from ozan_radio.settings import load_lyria_settings
|
||||
from ozan_radio.stats import cost_per_track
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlaybackSettings:
|
||||
@@ -46,11 +49,10 @@ class RadioSettings:
|
||||
"per_track_estimate_usd": self.per_track_cost(),
|
||||
},
|
||||
"lyria_model": self.lyria_model,
|
||||
"lyria": load_lyria_settings().to_public_dict(),
|
||||
}
|
||||
|
||||
def per_track_cost(self) -> float:
|
||||
from ozan_radio.stats import cost_per_track
|
||||
|
||||
return cost_per_track(self.lyria_model, self.costs.__dict__)
|
||||
|
||||
|
||||
@@ -102,6 +104,11 @@ def save_radio_settings_patch(patch: dict, repo_root: Path | None = None) -> dic
|
||||
data.setdefault("limits", {}).update(patch["limits"])
|
||||
if "costs" in patch:
|
||||
data.setdefault("costs", {}).update(patch["costs"])
|
||||
if "lyria" in patch:
|
||||
lyria = data.setdefault("lyria", {})
|
||||
lyria.update(patch["lyria"])
|
||||
if "vocal_mode" in patch["lyria"]:
|
||||
lyria["prefer_instrumental"] = patch["lyria"]["vocal_mode"] == "instrumental"
|
||||
|
||||
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
return load_radio_settings(repo_root).to_public_dict()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import random
|
||||
from pathlib import Path
|
||||
|
||||
@@ -19,6 +20,8 @@ from ozan_radio.library import list_saved_songs
|
||||
from ozan_radio.lyria import GeneratedTrack
|
||||
from ozan_radio.radio_settings import load_radio_settings, save_radio_settings_patch
|
||||
from ozan_radio.stats import cost_per_track, record_generation, today_stats
|
||||
from ozan_radio.lyria_capabilities import probe_lyria_api, static_capabilities
|
||||
from ozan_radio.settings import load_lyria_settings
|
||||
from ozan_radio.taste import load_taste_seeds
|
||||
|
||||
app = FastAPI(title="Live Ozan Radio", version="0.1.0")
|
||||
@@ -44,6 +47,7 @@ class SettingsPatch(BaseModel):
|
||||
playback: dict | None = None
|
||||
limits: dict | None = None
|
||||
costs: dict | None = None
|
||||
lyria: dict | None = None
|
||||
|
||||
|
||||
def _can_generate_today(cfg: Config) -> tuple[bool, dict]:
|
||||
@@ -175,6 +179,7 @@ async def root() -> dict:
|
||||
"songs": "/api/songs",
|
||||
"stats": "/api/stats",
|
||||
"settings": "/api/settings",
|
||||
"lyria": "/api/lyria",
|
||||
"shuffle": "POST /api/shuffle/next",
|
||||
"player": "/player",
|
||||
},
|
||||
@@ -212,6 +217,26 @@ async def dashboard_stats() -> dict:
|
||||
return _dashboard_stats(cfg)
|
||||
|
||||
|
||||
@app.get("/api/lyria")
|
||||
async def lyria_capabilities() -> dict:
|
||||
cfg = _get_config()
|
||||
caps = static_capabilities()
|
||||
api_status = probe_lyria_api(cfg.gemini_api_key)
|
||||
active = load_lyria_settings().to_public_dict()
|
||||
available = set(api_status.get("models_available") or [])
|
||||
models = []
|
||||
for m in caps["models"]:
|
||||
entry = dict(m)
|
||||
entry["available"] = not available or m["id"] in available
|
||||
models.append(entry)
|
||||
caps["models"] = models
|
||||
return {
|
||||
"capabilities": caps,
|
||||
"active": active,
|
||||
"api": api_status,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/settings")
|
||||
async def get_settings() -> dict:
|
||||
cfg = _get_config()
|
||||
@@ -310,6 +335,34 @@ async def chat_with_dj(body: ChatRequest, background: BackgroundTasks) -> dict:
|
||||
return result
|
||||
|
||||
|
||||
def _vocal_cues_path() -> Path:
|
||||
return Path(__file__).resolve().parents[2] / "vocal_cues.json"
|
||||
|
||||
|
||||
def _load_vocal_cues() -> dict:
|
||||
path = _vocal_cues_path()
|
||||
if not path.exists():
|
||||
return {"tracks": {}}
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {"tracks": {}}
|
||||
|
||||
|
||||
@app.get("/api/vocal-cues")
|
||||
async def vocal_cues_all() -> dict:
|
||||
data = _load_vocal_cues()
|
||||
return {"tracks": list(data.get("tracks", {}).keys()), "count": len(data.get("tracks", {}))}
|
||||
|
||||
|
||||
@app.get("/api/vocal-cues/{track_id}")
|
||||
async def vocal_cues_for_track(track_id: str) -> dict:
|
||||
cues = _load_vocal_cues().get("tracks", {}).get(track_id)
|
||||
if not cues:
|
||||
raise HTTPException(404, "No vocal cues for this track")
|
||||
return {"track_id": track_id, **cues}
|
||||
|
||||
|
||||
@app.get("/api/songs")
|
||||
async def saved_songs() -> dict:
|
||||
cfg = _get_config()
|
||||
@@ -370,6 +423,9 @@ async def _background_compose(request: str | None = None) -> None:
|
||||
result = await _compose_track(request)
|
||||
if result.get("status") == "error":
|
||||
_chat.add("dj", f"Couldn't finish that track: {result.get('message', 'unknown error')}")
|
||||
elif result.get("status") == "ok" and result.get("track"):
|
||||
t = result["track"]
|
||||
_chat.add("dj", f"Fresh cut ready: {t.get('title', 'new track')} — hitting the stream now.")
|
||||
|
||||
|
||||
async def _autofill_queue(target: int = 2) -> None:
|
||||
|
||||
+101
-2
@@ -4,6 +4,77 @@ import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
SINGER_PROFILE_TEXT = {
|
||||
"male_baritone": (
|
||||
"Male Baritone: deep, chocolatey, velvet-smooth chest voice, "
|
||||
"resonant desert-blues crooning."
|
||||
),
|
||||
"male_tenor": (
|
||||
"Male Tenor: bright, piercing, energetic, youthful timbre with high belting power."
|
||||
),
|
||||
"female_alto": (
|
||||
"Female Alto: rich, warm, husky lower range, smoky timbre with vocal fry, soulful."
|
||||
),
|
||||
"female_soprano": (
|
||||
"Female Soprano: clear crystalline timbre, agile soaring quality, airy high notes."
|
||||
),
|
||||
"weathered_rocker": (
|
||||
"Weathered Rocker (Male): raspy gravelly timbre, 90s grunge strain in upper range."
|
||||
),
|
||||
}
|
||||
|
||||
LANGUAGE_PROMPTS = {
|
||||
"en": "Write and sing lyrics in English.",
|
||||
"hi": "Write and sing lyrics in Hindi. Use devotional or poetic phrasing if appropriate.",
|
||||
"tr": "Write and sing lyrics in Turkish.",
|
||||
"fr": "Write and sing lyrics in French.",
|
||||
"ar": "Write and sing lyrics in Arabic.",
|
||||
"es": "Write and sing lyrics in Spanish.",
|
||||
"pt": "Write and sing lyrics in Portuguese.",
|
||||
"de": "Write and sing lyrics in German.",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class LyriaSettings:
|
||||
model: str
|
||||
vocal_mode: str
|
||||
language: str
|
||||
singer_profile: str
|
||||
output_format: str
|
||||
prefer_instrumental: bool
|
||||
|
||||
def dj_context(self) -> str:
|
||||
lines = [
|
||||
f"Lyria model: {self.model}.",
|
||||
f"Vocal mode: {self.vocal_mode}.",
|
||||
]
|
||||
if self.language and self.language != "auto":
|
||||
lines.append(f"Preferred lyric language: {self.language}.")
|
||||
if self.singer_profile:
|
||||
text = SINGER_PROFILE_TEXT.get(self.singer_profile, self.singer_profile)
|
||||
lines.append(f"Singer profile: {text}")
|
||||
if self.vocal_mode == "instrumental":
|
||||
lines.append("All Lyria prompts must end with instrumental-only direction.")
|
||||
elif self.vocal_mode == "mix":
|
||||
lines.append(
|
||||
"Lyria prompts: wordless vocal textures, chants, whispers OK — "
|
||||
"avoid full lead lyrics unless listener asks in chat."
|
||||
)
|
||||
elif self.vocal_mode == "vocals":
|
||||
lines.append("Lyria prompts should include clear vocal delivery and lyrics.")
|
||||
return "\n".join(lines)
|
||||
|
||||
def to_public_dict(self) -> dict:
|
||||
return {
|
||||
"model": self.model,
|
||||
"vocal_mode": self.vocal_mode,
|
||||
"language": self.language,
|
||||
"singer_profile": self.singer_profile,
|
||||
"output_format": self.output_format,
|
||||
"prefer_instrumental": self.prefer_instrumental,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ListenerSettings:
|
||||
@@ -30,8 +101,6 @@ class ListenerSettings:
|
||||
]
|
||||
if self.avoid:
|
||||
lines.append(f"Avoid: {', '.join(self.avoid)}.")
|
||||
if self.prefer_instrumental:
|
||||
lines.append("Default to instrumental unless vocals are requested.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -62,3 +131,33 @@ def load_settings(repo_root: Path | None = None) -> ListenerSettings | None:
|
||||
avoid=taste.get("avoid", []),
|
||||
prefer_instrumental=lyria.get("prefer_instrumental", True),
|
||||
)
|
||||
|
||||
|
||||
def _settings_path(repo_root: Path | None = None) -> Path:
|
||||
root = repo_root or Path(__file__).resolve().parents[2]
|
||||
return root / "settings.json"
|
||||
|
||||
|
||||
def load_lyria_settings(repo_root: Path | None = None) -> LyriaSettings:
|
||||
path = _settings_path(repo_root)
|
||||
data: dict = {}
|
||||
if path.exists():
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
|
||||
lyria = data.get("lyria", {})
|
||||
prefer_inst = lyria.get("prefer_instrumental", True)
|
||||
vocal_mode = lyria.get("vocal_mode")
|
||||
if not vocal_mode:
|
||||
vocal_mode = "instrumental" if prefer_inst else "vocals"
|
||||
|
||||
return LyriaSettings(
|
||||
model=lyria.get("model", "lyria-3-pro-preview"),
|
||||
vocal_mode=vocal_mode,
|
||||
language=lyria.get("language", "auto"),
|
||||
singer_profile=lyria.get("singer_profile", ""),
|
||||
output_format=lyria.get("output_format", "mp3"),
|
||||
prefer_instrumental=prefer_inst,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Export static gateway playlist for tinqs.com auto-play radio."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from ozan_radio.library import list_saved_songs
|
||||
|
||||
REPO_SLUG = "tinqs/live-radio"
|
||||
MEDIA_BASE = f"https://tinqs.com/{REPO_SLUG}/media/branch/main/songs/"
|
||||
PLAYLIST_MARKER = "/*__PLAYLIST__*/"
|
||||
|
||||
|
||||
def build_playlist_payload(songs_dir: Path) -> dict:
|
||||
tracks = []
|
||||
for song in list_saved_songs(songs_dir):
|
||||
curation = song.get("curation") or {}
|
||||
if curation.get("public_playlist") is False:
|
||||
continue
|
||||
if curation.get("rating") == "skip":
|
||||
continue
|
||||
tracks.append(
|
||||
{
|
||||
"id": song["id"],
|
||||
"title": song.get("title", ""),
|
||||
"mood": song.get("mood", ""),
|
||||
"dj_line": song.get("dj_line", ""),
|
||||
"file": song.get("file", ""),
|
||||
"url": f"{MEDIA_BASE}{song['file']}",
|
||||
"rating": curation.get("rating", "unrated"),
|
||||
"shuffle_weight": float(curation.get("shuffle_weight", 1.0)),
|
||||
}
|
||||
)
|
||||
tracks.reverse()
|
||||
return {
|
||||
"station": "Live Ozan Radio",
|
||||
"tagline": "No catalog. AI-composed desert dub + Anadolu psych.",
|
||||
"media_base": MEDIA_BASE,
|
||||
"share_url": f"https://tinqs.com/{REPO_SLUG}/src/branch/main/gateway/index.html",
|
||||
"updated": datetime.now(timezone.utc).isoformat(),
|
||||
"tracks": tracks,
|
||||
}
|
||||
|
||||
|
||||
def export_gateway_playlist(repo_root: Path | None = None) -> Path | None:
|
||||
root = repo_root or Path(__file__).resolve().parents[2]
|
||||
songs_dir = root / "songs"
|
||||
gateway = root / "gateway"
|
||||
index_path = gateway / "index.html"
|
||||
playlist_path = gateway / "playlist.json"
|
||||
|
||||
if not index_path.exists():
|
||||
return 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
|
||||
|
||||
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,78 @@
|
||||
{
|
||||
"tracks": {
|
||||
"aee4994a": {
|
||||
"title": "Nomad's Saz",
|
||||
"bpm": 85,
|
||||
"skip_intro_sec": 30,
|
||||
"intro_note": "The opening guitar/perc loop is thin — skip to ~0:30 when the saz and ney lock in.",
|
||||
"style": "Deep chest drone (kargyraa-style low hum) with overtone peaks riding the saz melody. Wear headphones; the booth records your mic only while the track plays.",
|
||||
"phrases": [
|
||||
{
|
||||
"start_sec": 30,
|
||||
"end_sec": 44,
|
||||
"label": "Saz enters — ground the drone",
|
||||
"text": "Huuuuuuuum … Oooooh-maaan",
|
||||
"hint": "One long low Hu on the downbeat when saz appears. Chest resonance, jaw loose. 'Aman' floats above — no words needed, just vowels."
|
||||
},
|
||||
{
|
||||
"start_sec": 44,
|
||||
"end_sec": 58,
|
||||
"label": "Ride the riff — pulse sync",
|
||||
"text": "Hu … (rest) … Hu … Ya-hu … Ya-hu",
|
||||
"hint": "Short Hu punches on beats 1 and 3. Ya-hu doubles the saz phrase — throat closed slightly for a woody tone."
|
||||
},
|
||||
{
|
||||
"start_sec": 58,
|
||||
"end_sec": 72,
|
||||
"label": "Build — open the throat",
|
||||
"text": "Ruuuuuh … Aaa-lay-la … (breath)",
|
||||
"hint": "Sustain Ruuh from the belly. Lay-la is ghosted — lips barely open, dub-delay feel in your head."
|
||||
},
|
||||
{
|
||||
"start_sec": 72,
|
||||
"end_sec": 86,
|
||||
"label": "Drop — overtone peak",
|
||||
"text": "Ooooo (low) + iiiii (high whistle)",
|
||||
"hint": "Classic throat-singing stack: hold low O, tighten tongue for a faint overtone i on top of the saz."
|
||||
},
|
||||
{
|
||||
"start_sec": 86,
|
||||
"end_sec": 97,
|
||||
"label": "Outro — fade with ney",
|
||||
"text": "Haaaa … shhhhhh … Huuuuum",
|
||||
"hint": "Let volume fall with the reverb tail. End on a single long Hu."
|
||||
}
|
||||
]
|
||||
},
|
||||
"1c1d7b8a": {
|
||||
"title": "Sahara's Saz",
|
||||
"bpm": 85,
|
||||
"skip_intro_sec": 20,
|
||||
"intro_note": "Saz and ney arrive around 0:20 after the Tinariwen-style guitar intro.",
|
||||
"style": "Whispered chest textures and Sahel call-and-response — lighter than Nomad's take.",
|
||||
"phrases": [
|
||||
{
|
||||
"start_sec": 20,
|
||||
"end_sec": 50,
|
||||
"label": "Verse — saz arpeggios",
|
||||
"text": "Ya-lay … hu-u-u … (whisper)",
|
||||
"hint": "Barely voiced — breath + throat, not full sing."
|
||||
},
|
||||
{
|
||||
"start_sec": 50,
|
||||
"end_sec": 70,
|
||||
"label": "Build",
|
||||
"text": "Aaa-oh … Ruh … Haaaa",
|
||||
"hint": "More chest as percussion swells."
|
||||
},
|
||||
{
|
||||
"start_sec": 70,
|
||||
"end_sec": 90,
|
||||
"label": "Drop + return",
|
||||
"text": "Hu … Hu … Ya-la-la-ya",
|
||||
"hint": "Call on the drop, answer when full mix returns."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user