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:
2026-06-07 15:20:10 +01:00
parent 4b2003866d
commit b2aad43a44
25 changed files with 1869 additions and 75 deletions
+268
View File
@@ -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 &amp; 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
View File
@@ -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
+59
View File
@@ -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
}
]
}