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
+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