Files
ozan ec4ca4b7c0 Raise daily generation limit to 100 and fix limit UI coercion.
0 was misread as zero allowed on older servers; player no longer falls back to 10 when saving limits.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-07 16:28:19 +01:00

1370 lines
44 KiB
HTML

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>
<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; }
body {
font-family: "Segoe UI", system-ui, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
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);
}
.radio {
width: min(480px, 92vw);
background: var(--panel);
border: 1px solid #2a2a3a;
border-radius: 20px;
padding: 2rem;
box-shadow: 0 24px 80px rgba(0,0,0,0.5);
}
.header-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.25rem;
}
.header-row h1 { margin-bottom: 0; }
.icon-btn {
flex: 0 0 auto;
width: 40px;
height: 40px;
min-width: 40px;
padding: 0;
border-radius: 10px;
background: #2a2a3a;
border: 1px solid #3a3a50;
color: var(--text);
font-size: 1.1rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: border-color 0.15s, background 0.15s;
}
.icon-btn:hover { border-color: var(--accent); background: #32324a; }
.icon-btn.active { border-color: var(--accent); color: var(--accent); }
.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;
font-weight: 700;
margin-bottom: 0.25rem;
}
.tagline {
color: var(--muted);
font-size: 0.85rem;
margin-bottom: 1rem;
line-height: 1.4;
}
.dashboard {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
margin-bottom: 1.25rem;
}
.stat-card {
background: #1a1a28;
border: 1px solid #2a2a3a;
border-radius: 12px;
padding: 0.65rem 0.75rem;
}
.stat-card.wide { grid-column: 1 / -1; }
.stat-label {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
margin-bottom: 0.2rem;
}
.stat-value {
font-size: 1rem;
font-weight: 700;
color: var(--text);
}
.stat-value.accent { color: var(--accent); }
.stat-sub {
font-size: 0.7rem;
color: var(--muted);
margin-top: 0.15rem;
}
.settings-panel {
display: none;
background: #1a1a28;
border: 1px solid #3a3a50;
border-radius: 14px;
padding: 1rem;
margin-bottom: 1.25rem;
}
.settings-panel.open { display: block; }
.settings-panel h2 {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
margin-bottom: 0.75rem;
}
.setting-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.5rem 0;
border-bottom: 1px solid #2a2a3a;
font-size: 0.85rem;
}
.setting-row:last-child { border-bottom: none; }
.setting-row label { flex: 1; color: var(--text); }
.setting-row .hint {
display: block;
font-size: 0.7rem;
color: var(--muted);
margin-top: 0.15rem;
}
.toggle {
position: relative;
width: 44px;
height: 24px;
flex-shrink: 0;
}
.toggle input { opacity: 0; width: 0; height: 0; }
.toggle span {
position: absolute;
inset: 0;
background: #3a3a50;
border-radius: 12px;
cursor: pointer;
transition: background 0.2s;
}
.toggle span::before {
content: "";
position: absolute;
width: 18px;
height: 18px;
left: 3px;
top: 3px;
background: #fff;
border-radius: 50%;
transition: transform 0.2s;
}
.toggle input:checked + span { background: var(--accent); }
.toggle input:checked + span::before { transform: translateX(20px); }
.num-input {
width: 64px;
background: #14141f;
border: 1px solid #3a3a50;
border-radius: 8px;
padding: 0.35rem 0.5rem;
color: var(--text);
font-size: 0.85rem;
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);
}
.viz {
height: 64px;
display: flex;
align-items: flex-end;
justify-content: center;
gap: 4px;
margin-bottom: 1.25rem;
}
.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; }
.bar:nth-child(6) { height: 44px; animation-delay: 0.25s; }
.bar:nth-child(7) { height: 32px; animation-delay: 0.12s; }
.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; }
}
.now-title {
font-size: 1.15rem;
font-weight: 600;
margin-bottom: 0.35rem;
}
.now-mood, .dj-line {
font-size: 0.85rem;
color: var(--muted);
margin-bottom: 0.5rem;
}
.dj-line { font-style: italic; color: #b0b0c8; }
audio {
width: 100%;
margin: 1rem 0;
border-radius: 8px;
}
.controls {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
button {
flex: 1;
min-width: 100px;
padding: 0.65rem 1rem;
border: none;
border-radius: 10px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: transform 0.15s, opacity 0.15s;
}
button:hover { transform: translateY(-1px); }
button:active { transform: translateY(0); }
button:disabled { opacity: 0.45; cursor: not-allowed; transform: none; }
.primary {
background: linear-gradient(135deg, var(--accent), #ff8f65);
color: #fff;
}
.secondary {
background: #2a2a3a;
color: var(--text);
border: 1px solid #3a3a50;
}
.status {
margin-top: 1rem;
font-size: 0.75rem;
color: var(--muted);
text-align: center;
line-height: 1.4;
}
.status.generating { color: var(--accent); }
.status.ready-flash { color: #6bcf8e; font-weight: 600; }
.status.error { color: #ff5c7a; }
.gen-ready-toast {
position: fixed;
top: 1rem;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
background: linear-gradient(135deg, #1e3a2f, #2a4a3a);
border: 1px solid #6bcf8e;
color: #e8fff0;
padding: 0.85rem 1.25rem;
border-radius: 12px;
font-size: 0.85rem;
font-weight: 600;
box-shadow: 0 8px 32px rgba(0,0,0,0.45);
animation: toast-in 0.35s ease;
max-width: 90vw;
text-align: center;
}
@keyframes toast-in {
from { opacity: 0; transform: translateX(-50%) translateY(-12px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
.stack {
font-size: 0.7rem;
color: #555568;
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px solid #2a2a3a;
line-height: 1.6;
}
.chat {
margin-top: 1.25rem;
border-top: 1px solid #2a2a3a;
padding-top: 1rem;
}
.chat h2 {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
margin-bottom: 0.65rem;
}
.chat-log {
height: 160px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 0.65rem;
padding-right: 0.25rem;
}
.chat-log::-webkit-scrollbar { width: 4px; }
.chat-log::-webkit-scrollbar-thumb { background: #3a3a50; border-radius: 2px; }
.bubble {
max-width: 88%;
padding: 0.55rem 0.75rem;
border-radius: 12px;
font-size: 0.82rem;
line-height: 1.4;
}
.bubble.user {
align-self: flex-end;
background: #2a2a3a;
color: var(--text);
border-bottom-right-radius: 4px;
}
.bubble.dj {
align-self: flex-start;
background: linear-gradient(135deg, rgba(255,107,53,0.15), rgba(123,92,255,0.12));
border: 1px solid #3a3a50;
border-bottom-left-radius: 4px;
}
.chat-form {
display: flex;
gap: 0.5rem;
}
.chat-form input {
flex: 1;
background: #1a1a28;
border: 1px solid #3a3a50;
border-radius: 10px;
padding: 0.6rem 0.75rem;
color: var(--text);
font-size: 0.85rem;
}
.chat-form input:focus {
outline: none;
border-color: var(--accent);
}
.chat-form button {
flex: 0 0 auto;
min-width: 72px;
padding: 0.6rem 1rem;
}
.library {
margin-top: 1.25rem;
border-top: 1px solid #2a2a3a;
padding-top: 1rem;
}
.library h2 {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
margin-bottom: 0.5rem;
}
.library-list {
display: flex;
flex-direction: column;
gap: 0.35rem;
max-height: 140px;
overflow-y: auto;
}
.song-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.5rem 0.65rem;
background: #1a1a28;
border: 1px solid #2a2a3a;
border-radius: 10px;
cursor: pointer;
font-size: 0.82rem;
text-align: left;
color: var(--text);
width: 100%;
}
.song-row:hover { border-color: var(--accent); }
.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>
<div class="radio">
<div class="badge" id="modeBadge">● Live</div>
<div class="header-row">
<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.
<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">
<div class="stat-label">Today spent</div>
<div class="stat-value accent" id="statSpent">$0.00</div>
<div class="stat-sub" id="statPerTrack">~$0.08 / track</div>
</div>
<div class="stat-card">
<div class="stat-label">New songs today</div>
<div class="stat-value" id="statGenerated">0 / 10</div>
<div class="stat-sub" id="statRemaining">10 remaining</div>
</div>
<div class="stat-card wide">
<div class="stat-label">Max daily budget (at limit)</div>
<div class="stat-value" id="statMaxBudget">$0.82</div>
<div class="stat-sub">Lyria Pro + DeepSeek estimate</div>
</div>
</section>
<section class="settings-panel" id="settingsPanel">
<h2>Playback & limits</h2>
<div class="setting-row">
<label>
Shuffle mode
<span class="hint">Mix saved songs and freshly generated tracks</span>
</label>
<label class="toggle">
<input type="checkbox" id="setShuffle" checked>
<span></span>
</label>
</div>
<div class="setting-row">
<label>
Mix existing + new
<span class="hint">When shuffle is on, also compose new tracks</span>
</label>
<label class="toggle">
<input type="checkbox" id="setMix" checked>
<span></span>
</label>
</div>
<div class="setting-row">
<label>
New song chance
<span class="hint">Probability each shuffle picks a fresh track</span>
</label>
<input type="range" class="range-input" id="setChance" min="0" max="100" value="35">
</div>
<div class="setting-row">
<label>
Max new songs / day
<span class="hint">Hard cap on Lyria generations</span>
</label>
<input type="number" class="num-input" id="setMaxPerDay" min="0" max="200" value="100" title="0 = unlimited">
</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">
<div class="bar"></div><div class="bar"></div><div class="bar"></div>
<div class="bar"></div><div class="bar"></div><div class="bar"></div><div class="bar"></div>
</div>
<div class="now-title" id="title">Waiting for first track…</div>
<div class="now-mood" id="mood"></div>
<div class="dj-line" id="dj"></div>
<audio id="player" controls></audio>
<div class="controls">
<button class="primary" id="genBtn">Generate next</button>
<button class="secondary" id="skipBtn">Skip</button>
<button class="secondary" id="refreshBtn">Refresh</button>
</div>
<div class="status" id="status">Connecting…</div>
<div id="genReadyToast" class="gen-ready-toast" hidden></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">
<div class="library-empty">Loading library…</div>
</div>
</section>
<section class="chat">
<h2>Talk to the DJ</h2>
<div class="chat-log" id="chatLog"></div>
<form class="chat-form" id="chatForm">
<input id="chatInput" type="text" placeholder="Ask for a vibe… e.g. more Sahel, slower, surprise me" maxlength="500" autocomplete="off">
<button type="submit" class="primary" id="chatSend">Send</button>
</form>
</section>
<div class="stack">
DJ: DeepSeek · Music: Lyria 3 · Vocal booth: Chrome mic + cue sheet<br>
Optional live layer: Magenta RealTime 2 on Apple Silicon
</div>
</div>
<script>
const API = window.location.origin;
const player = document.getElementById('player');
const viz = document.getElementById('viz');
const titleEl = document.getElementById('title');
const moodEl = document.getElementById('mood');
const djEl = document.getElementById('dj');
const statusEl = document.getElementById('status');
const genReadyToast = document.getElementById('genReadyToast');
const genBtn = document.getElementById('genBtn');
let pollStartedAt = null;
let lastNotifiedCompletedAt = null;
const skipBtn = document.getElementById('skipBtn');
const refreshBtn = document.getElementById('refreshBtn');
const chatLog = document.getElementById('chatLog');
const chatForm = document.getElementById('chatForm');
const chatInput = document.getElementById('chatInput');
const chatSend = document.getElementById('chatSend');
const libraryList = document.getElementById('libraryList');
const settingsBtn = document.getElementById('settingsBtn');
const settingsPanel = document.getElementById('settingsPanel');
const modeBadge = document.getElementById('modeBadge');
const setShuffle = document.getElementById('setShuffle');
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;
titleEl.textContent = t.title;
moodEl.textContent = t.mood ? `Mood: ${t.mood}` : '';
djEl.textContent = t.dj_line ? `"${t.dj_line}"` : '';
const url = `${API}${t.audio_url}`;
if (player.src !== url) {
player.src = url;
player.play().catch(() => {});
}
const srcLabel = source === 'generated' ? 'New · ' : source === 'library' ? 'Library · ' : '';
statusEl.textContent = `${srcLabel}Playing · ${t.track_id}`;
loadLibrary();
loadVocalBooth(t.track_id);
resetTake();
}
function applyGenerationState(gen) {
if (!gen) return;
if (gen.busy) {
genBtn.disabled = true;
const title = gen.track_title ? ` "${gen.track_title}"` : '';
const phase = gen.phase === 'planning'
? `DeepSeek planning${title}`
: `Lyria composing${title}… (~30s)`;
statusEl.textContent = phase;
statusEl.classList.add('generating');
statusEl.classList.remove('error');
return;
}
genBtn.disabled = false;
statusEl.classList.remove('generating');
if (gen.error) {
statusEl.textContent = gen.error;
statusEl.classList.add('error');
if (gen.error !== lastGenError) {
lastGenError = gen.error;
addBubble('dj', `Couldn't finish that track: ${gen.error}`);
}
return;
}
lastGenError = null;
statusEl.classList.remove('error');
}
function updateDashboard(stats) {
if (!stats) return;
const today = stats.today || {};
const costs = stats.costs || {};
statSpent.textContent = fmtUsd(today.estimated_usd);
statPerTrack.textContent = `~${fmtUsd(costs.per_track_estimate_usd)} / track`;
const unlimited = today.unlimited || today.max_per_day === 0;
statGenerated.textContent = unlimited
? `${today.generated || 0} (unlimited)`
: `${today.generated || 0} / ${today.max_per_day || 10}`;
statRemaining.textContent = unlimited ? '∞' : `${today.remaining ?? 0} remaining`;
statMaxBudget.textContent = fmtUsd(stats.projected_daily_max_usd);
if (stats.playback) {
radioSettings.playback = stats.playback;
syncSettingsUI(stats.playback);
}
applyGenerationState(stats.generation);
updateModeBadge();
}
function syncSettingsUI(pb) {
if (savingSettings) return;
setShuffle.checked = !!pb.shuffle;
setMix.checked = !!pb.mix_existing_and_new;
setChance.value = Math.round((pb.new_song_chance || 0.35) * 100);
}
function updateModeBadge() {
const shuffle = radioSettings.playback?.shuffle;
modeBadge.textContent = shuffle ? '● Shuffle' : '● Live';
skipBtn.textContent = shuffle ? 'Shuffle next' : 'Skip';
}
async function loadStats(returnData = false) {
try {
const res = await fetch(`${API}/api/stats`);
const data = await res.json();
updateDashboard(data);
return returnData ? data : undefined;
} catch (_) {}
}
async function loadSettings() {
try {
const res = await fetch(`${API}/api/settings`);
const data = await res.json();
radioSettings = data;
syncSettingsUI({
shuffle: data.playback?.shuffle,
mix_existing_and_new: data.playback?.mix_existing_and_new,
new_song_chance: data.playback?.new_song_chance,
});
if (data.budget) {
const cap = data.limits?.max_new_songs_per_day ?? data.budget?.max_per_day;
setMaxPerDay.value = cap === undefined || cap === null ? 100 : cap;
}
if (data.lyria) syncLyriaUI(data.lyria);
updateModeBadge();
} catch (_) {}
}
let saveTimer = null;
function scheduleSaveSettings() {
clearTimeout(saveTimer);
saveTimer = setTimeout(saveSettings, 400);
}
async function saveSettings() {
savingSettings = true;
const patch = {
playback: {
shuffle: setShuffle.checked,
mix_existing_and_new: setMix.checked,
new_song_chance: setChance.value / 100,
},
limits: {
max_new_songs_per_day: (() => {
const n = parseInt(setMaxPerDay.value, 10);
return Number.isFinite(n) && n >= 0 ? n : 100;
})(),
},
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`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
const data = await res.json();
radioSettings = data;
updateModeBadge();
if (data.budget) {
statGenerated.textContent = `${data.budget.generated || 0} / ${data.budget.max_per_day}`;
statRemaining.textContent = `${data.budget.remaining ?? 0} remaining`;
}
const perTrack = data.costs?.per_track_estimate_usd || 0.082;
const cap = data.limits?.max_new_songs_per_day ?? 100;
statMaxBudget.textContent = cap <= 0 ? '∞' : fmtUsd(perTrack * cap);
} catch (_) {}
savingSettings = false;
}
settingsBtn.addEventListener('click', () => {
settingsPanel.classList.toggle('open');
settingsBtn.classList.toggle('active', settingsPanel.classList.contains('open'));
});
[setShuffle, setMix, setChance, setMaxPerDay, setLyriaModel, setVocalMode, setLanguage, setSinger, setOutputFormat].forEach(el => {
el.addEventListener('change', () => { updateLyriaHints(); scheduleSaveSettings(); });
el.addEventListener('input', scheduleSaveSettings);
});
async function loadLibrary() {
try {
const res = await fetch(`${API}/api/songs`);
const data = await res.json();
libraryList.innerHTML = '';
if (!data.songs || data.songs.length === 0) {
libraryList.innerHTML = '<div class="library-empty">No saved songs yet — generate one!</div>';
return;
}
for (const song of data.songs) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'song-row' + (song.id === currentTrackId ? ' playing' : '');
const sizeMb = song.size_bytes ? (song.size_bytes / 1e6).toFixed(1) + ' MB' : '';
btn.innerHTML = `<span>${song.title}</span><span class="song-meta">${sizeMb}</span>`;
btn.addEventListener('click', () => playSong(song.id));
libraryList.appendChild(btn);
}
} catch (_) {
libraryList.innerHTML = '<div class="library-empty">Library offline</div>';
}
}
async function playSong(trackId) {
try {
const res = await fetch(`${API}/api/songs/${trackId}/play`, { method: 'POST' });
const data = await res.json();
if (data.status === 'ok' && data.track) {
applyTrack(data.track, 'library');
}
} catch (_) {}
}
function addBubble(role, text) {
const el = document.createElement('div');
el.className = `bubble ${role}`;
el.textContent = text;
chatLog.appendChild(el);
chatLog.scrollTop = chatLog.scrollHeight;
}
async function loadChat() {
try {
const res = await fetch(`${API}/api/chat`);
const data = await res.json();
chatLog.innerHTML = '';
for (const m of data.messages || []) {
addBubble(m.role, m.content);
}
} catch (_) {}
}
async function sendChat(text) {
chatSend.disabled = true;
chatInput.disabled = true;
addBubble('user', text);
try {
const res = await fetch(`${API}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: text }),
});
const data = await res.json();
if (data.reply) addBubble('dj', data.reply);
if (data.generating) {
statusEl.textContent = 'DeepSeek planning your request…';
statusEl.classList.add('generating');
pollForNewTrack();
}
} catch (_) {
addBubble('dj', 'Signal lost — try again.');
} finally {
chatSend.disabled = false;
chatInput.disabled = false;
chatInput.focus();
}
}
let pollTimer = null;
let toastTimer = null;
function showReadyToast(title) {
genReadyToast.textContent = `Ready · ${title}`;
genReadyToast.hidden = false;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { genReadyToast.hidden = true; }, 8000);
}
function notifyTrackReady(track, gen) {
const title = track?.title || gen?.last_completed_title || 'New track';
statusEl.textContent = `Ready · playing ${title}`;
statusEl.classList.remove('generating', 'error');
statusEl.classList.add('ready-flash');
setTimeout(() => statusEl.classList.remove('ready-flash'), 5000);
showReadyToast(title);
addBubble('dj', `Fresh cut ready: ${title} — on air now.`);
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
new Notification('Live Ozan Radio', { body: `Ready: ${title}`, tag: 'track-ready' });
}
lastNotifiedCompletedAt = gen?.last_completed_at || null;
}
function pollForNewTrack() {
let n = 0;
pollStartedAt = Date.now();
clearInterval(pollTimer);
pollTimer = setInterval(async () => {
n += 1;
const stats = await loadStats(true);
const gen = stats?.generation;
if (gen?.busy) return;
if (gen?.error) {
clearInterval(pollTimer);
statusEl.textContent = gen.error;
statusEl.classList.add('error');
addBubble('dj', `Couldn't finish that track: ${gen.error}`);
return;
}
const completedAt = gen?.last_completed_at;
const isNewCompletion = completedAt
&& completedAt !== lastNotifiedCompletedAt
&& (!pollStartedAt || new Date(completedAt).getTime() >= pollStartedAt - 2000);
await refreshNow();
const now = await fetch(`${API}/api/now`).then(r => r.json()).catch(() => null);
if (isNewCompletion && now?.track) {
applyTrack(now.track, 'generated');
notifyTrackReady(now.track, gen);
clearInterval(pollTimer);
return;
}
if (now?.track?.track_id !== currentTrackId) clearInterval(pollTimer);
if (n > 90) {
clearInterval(pollTimer);
statusEl.textContent = 'Generation taking longer than usual — still polling…';
}
}, 2000);
}
async function refreshNow() {
try {
const res = await fetch(`${API}/api/now`);
const data = await res.json();
if (data.status === 'idle') {
titleEl.textContent = 'Queue empty';
moodEl.textContent = '';
djEl.textContent = 'Hit Generate to compose the first track.';
statusEl.textContent = data.message || 'Idle';
return;
}
applyTrack(data.track);
} catch (e) {
statusEl.textContent = 'Server offline — start: python -m ozan_radio';
}
}
async function generate() {
if (typeof Notification !== 'undefined' && Notification.permission === 'default') {
Notification.requestPermission();
}
genBtn.disabled = true;
statusEl.textContent = 'Starting… DeepSeek plans, then Lyria composes (~3090s)';
statusEl.classList.add('generating');
try {
const res = await fetch(`${API}/api/generate`, { method: 'POST' });
const data = await res.json();
if (data.status === 'busy') {
statusEl.textContent = data.message;
applyGenerationState(data.generation);
pollForNewTrack();
} else if (data.status === 'limit') {
statusEl.textContent = data.message || 'Daily limit reached';
statusEl.classList.remove('generating');
updateDashboard({ today: data.budget, costs: radioSettings.costs });
} else if (data.status === 'error') {
statusEl.textContent = data.message || 'Generation failed';
statusEl.classList.add('error');
applyGenerationState(data.generation || { error: data.message });
} else if (data.status === 'accepted' || data.generating) {
applyGenerationState(data.generation || { busy: true, phase: 'planning' });
pollForNewTrack();
} else if (data.status === 'ok') {
if (data.track) {
applyTrack(data.track, data.source || 'generated');
notifyTrackReady(data.track, data.generation);
}
await loadStats();
} else {
statusEl.textContent = 'Generation failed — check server logs';
}
} catch (e) {
statusEl.textContent = 'Request failed';
} finally {
genBtn.disabled = false;
}
}
async function nextTrack() {
skipBtn.disabled = true;
const shuffle = radioSettings.playback?.shuffle;
try {
const endpoint = shuffle ? `${API}/api/shuffle/next` : `${API}/api/skip`;
const res = await fetch(endpoint, { method: 'POST' });
const data = await res.json();
if (data.status === 'limit') {
statusEl.textContent = data.message || 'Daily limit — no more new songs today';
} else if (data.status === 'busy') {
statusEl.textContent = data.message;
statusEl.classList.add('generating');
pollForNewTrack();
return;
} else if (data.status === 'error') {
statusEl.textContent = data.message || 'Generation failed';
statusEl.classList.add('error');
} else if (data.status === 'ok' && data.track) {
applyTrack(data.track, data.source);
} else if (data.status === 'idle') {
await refreshNow();
}
if (data.budget || data.cost_usd !== undefined) {
await loadStats();
}
} finally {
skipBtn.disabled = false;
}
}
genBtn.addEventListener('click', generate);
skipBtn.addEventListener('click', nextTrack);
refreshBtn.addEventListener('click', () => { refreshNow(); loadStats(); });
player.addEventListener('ended', nextTrack);
player.addEventListener('play', () => viz.classList.remove('paused'));
player.addEventListener('pause', () => viz.classList.add('paused'));
chatForm.addEventListener('submit', (e) => {
e.preventDefault();
const text = chatInput.value.trim();
if (!text) return;
chatInput.value = '';
sendChat(text);
});
refreshNow();
loadLibrary();
loadChat();
loadSettings();
loadLyriaCapabilities();
loadStats();
setInterval(() => { refreshNow(); loadStats(); }, 15000);
</script>
</body>
</html>