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:
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user