Fix Lyria silent failures and surface generation status in the player.
Robust candidate-part parsing, quota-aware errors, live composing feedback, and two new desert dub tracks in the library. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+54
-4
@@ -267,7 +267,10 @@
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.status.generating { color: var(--accent); }
|
||||
.status.error { color: #ff5c7a; }
|
||||
.stack {
|
||||
font-size: 0.7rem;
|
||||
color: #555568;
|
||||
@@ -518,6 +521,7 @@
|
||||
let currentTrackId = null;
|
||||
let radioSettings = { playback: { shuffle: true, mix_existing_and_new: true, new_song_chance: 0.35 } };
|
||||
let savingSettings = false;
|
||||
let lastGenError = null;
|
||||
|
||||
function fmtUsd(n) {
|
||||
return '$' + Number(n || 0).toFixed(2);
|
||||
@@ -539,6 +543,34 @@
|
||||
loadLibrary();
|
||||
}
|
||||
|
||||
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 || {};
|
||||
@@ -552,6 +584,7 @@
|
||||
radioSettings.playback = stats.playback;
|
||||
syncSettingsUI(stats.playback);
|
||||
}
|
||||
applyGenerationState(stats.generation);
|
||||
updateModeBadge();
|
||||
}
|
||||
|
||||
@@ -568,11 +601,12 @@
|
||||
skipBtn.textContent = shuffle ? 'Shuffle next' : 'Skip';
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
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 (_) {}
|
||||
}
|
||||
|
||||
@@ -704,7 +738,8 @@
|
||||
const data = await res.json();
|
||||
if (data.reply) addBubble('dj', data.reply);
|
||||
if (data.generating) {
|
||||
statusEl.textContent = 'DJ is composing your request…';
|
||||
statusEl.textContent = 'DeepSeek planning your request…';
|
||||
statusEl.classList.add('generating');
|
||||
pollForNewTrack();
|
||||
}
|
||||
} catch (_) {
|
||||
@@ -722,10 +757,17 @@
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = setInterval(async () => {
|
||||
n += 1;
|
||||
const stats = await loadStats(true);
|
||||
if (stats?.generation?.busy) return;
|
||||
if (stats?.generation?.error) {
|
||||
clearInterval(pollTimer);
|
||||
return;
|
||||
}
|
||||
await refreshNow();
|
||||
await loadStats();
|
||||
const now = await fetch(`${API}/api/now`).then(r => r.json()).catch(() => null);
|
||||
if (now?.track?.track_id !== currentTrackId) clearInterval(pollTimer);
|
||||
if (n > 60) clearInterval(pollTimer);
|
||||
}, 5000);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
async function refreshNow() {
|
||||
@@ -756,6 +798,10 @@
|
||||
} else if (data.status === 'limit') {
|
||||
statusEl.textContent = data.message || 'Daily limit reached';
|
||||
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 === 'ok') {
|
||||
if (data.track) applyTrack(data.track, data.source || 'generated');
|
||||
await loadStats();
|
||||
@@ -780,8 +826,12 @@
|
||||
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') {
|
||||
|
||||
|
Before
After
|
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"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.",
|
||||
"lyria_prompt": "Create a dubtronica track at 90 BPM in A minor. Start with a deep sub bass and a slow, hypnotic stepper beat with rimshot and kick. Add warm analog spring reverb and delay on a muted guitar skank playing offbeat chords. Layer a melancholic kora melody over a djembe pattern with shakers. Introduce a melodica phrase with heavy delay, floating in the stereo field. Keep the arrangement spacious, with filtered breakdowns and echo drops. No vocals. Reference: Thievery Corporation meets Baaba Maal desert warmth.",
|
||||
"lyrics": "[[A0]]\n[[A1]]\n[[A2]]\n[[A3]]\n[[A4]]\n[[A5]]",
|
||||
"file": "82595273_Caravan_of_the_Blue_Hour.mp3",
|
||||
"saved_at": "2026-06-07T13:28:47.487693+00:00"
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"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.",
|
||||
"lyria_prompt": "Instrumental dubtronica track at 92 BPM in D minor. Start with a deep, sub-heavy bassline in a slow stepper rhythm. Layer a clean, plucked kora melody with long, spacey reverb and tape delay. Add a muted guitar skank playing offbeat chords with spring reverb. Hand percussion: djembe and shakers playing a relaxed, syncopated pattern. Introduce a melodica line with heavy echo, floating in the mix. Use analog warmth and subtle vinyl crackle. Build slowly, keeping the arrangement open and spacious. No vocals. Structure: intro with bass and percussion, add kora, then guitar, then melodica, then drop to just bass and percussion before returning with all elements. Fade out with delays.",
|
||||
"lyrics": "[[A0]]\n[[A1]]\n[[A2]]\n[[A3]]\n[[A4]]\n[[A5]]\n[[B6]]",
|
||||
"file": "eeebb429_Desert_Mirage.mp3",
|
||||
"saved_at": "2026-06-07T13:30:41.265970+00:00"
|
||||
}
|
||||
Binary file not shown.
+20
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"index": 0,
|
||||
"count": 2,
|
||||
"index": 2,
|
||||
"count": 4,
|
||||
"tracks": [
|
||||
{
|
||||
"id": "b51b1cb2",
|
||||
@@ -19,6 +19,24 @@
|
||||
"lyria_prompt": "A 1-2 minute instrumental track in the style of Sahelian desert blues. Acoustic guitar (kora-like fingerpicking), hand percussion (djembe and calabash), and a warm bass line. Slow-medium tempo (90 BPM), spiritual yet danceable energy. Call-and-response vocal samples woven into the texture, evoking griot traditions. Build from sparse to full, then ease out. No sharp transitions, keep a rolling, hypnotic groove.",
|
||||
"lyrics": "[[A0]]\n[[B1]]\n[16.0:] Ah-lay-la, ay-oh... (ay-oh)\n[:] Ah-lay-la, ay-oh... (ay-oh)\n[:] The spirits wake in the morning light,\n[:] Moving through the sand,\n[:] (Moving through the sand).\n[[C2]]\n[48.0:] Oh-lay-ka, ho-lay-ka!\n[:] Oh-lay-ka, ho-lay-ka!\n[:] Feel the heartbeat in the soil!\n[:] Feel the heartbeat in the soil!\n[:] From the river to the dune!\n[:] We are chanting with the sun!\n[:] We are chanting with the sun!\n[[D3]]\n[80.0:] (Mmm-hmmm...)\n[:] (Aaaa-la-ma...)\n[:] (Aaaa-la-ma...)",
|
||||
"file": "b548d6a6_Dune_Chant.mp3"
|
||||
},
|
||||
{
|
||||
"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.",
|
||||
"lyria_prompt": "Create a dubtronica track at 90 BPM in A minor. Start with a deep sub bass and a slow, hypnotic stepper beat with rimshot and kick. Add warm analog spring reverb and delay on a muted guitar skank playing offbeat chords. Layer a melancholic kora melody over a djembe pattern with shakers. Introduce a melodica phrase with heavy delay, floating in the stereo field. Keep the arrangement spacious, with filtered breakdowns and echo drops. No vocals. Reference: Thievery Corporation meets Baaba Maal desert warmth.",
|
||||
"lyrics": "[[A0]]\n[[A1]]\n[[A2]]\n[[A3]]\n[[A4]]\n[[A5]]",
|
||||
"file": "82595273_Caravan_of_the_Blue_Hour.mp3"
|
||||
},
|
||||
{
|
||||
"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.",
|
||||
"lyria_prompt": "Instrumental dubtronica track at 92 BPM in D minor. Start with a deep, sub-heavy bassline in a slow stepper rhythm. Layer a clean, plucked kora melody with long, spacey reverb and tape delay. Add a muted guitar skank playing offbeat chords with spring reverb. Hand percussion: djembe and shakers playing a relaxed, syncopated pattern. Introduce a melodica line with heavy echo, floating in the mix. Use analog warmth and subtle vinyl crackle. Build slowly, keeping the arrangement open and spacious. No vocals. Structure: intro with bass and percussion, add kora, then guitar, then melodica, then drop to just bass and percussion before returning with all elements. Fade out with delays.",
|
||||
"lyrics": "[[A0]]\n[[A1]]\n[[A2]]\n[[A3]]\n[[A4]]\n[[A5]]\n[[B6]]",
|
||||
"file": "eeebb429_Desert_Mirage.mp3"
|
||||
}
|
||||
]
|
||||
}
|
||||
+66
-9
@@ -4,6 +4,7 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from google import genai
|
||||
from google.genai import errors as genai_errors
|
||||
from google.genai import types
|
||||
|
||||
from ozan_radio.config import Config
|
||||
@@ -17,6 +18,49 @@ class GeneratedTrack:
|
||||
lyrics: str
|
||||
|
||||
|
||||
def friendly_lyria_error(exc: Exception) -> str:
|
||||
"""Turn Gemini/Lyria failures into actionable messages (quota, auth, etc.)."""
|
||||
msg = str(exc).lower()
|
||||
if isinstance(exc, genai_errors.ClientError):
|
||||
if any(k in msg for k in ("resource_exhausted", "quota", "rate limit", "429")):
|
||||
return (
|
||||
"Google API quota or credits exhausted — check billing at "
|
||||
"https://aistudio.google.com/apikey"
|
||||
)
|
||||
if any(k in msg for k in ("permission_denied", "403", "invalid api key", "api key")):
|
||||
return "Gemini API key invalid or missing Lyria access"
|
||||
if "billing" in msg or "payment" in msg:
|
||||
return "Google Cloud billing issue — enable billing for Lyria on your project"
|
||||
if isinstance(exc, genai_errors.ServerError):
|
||||
return f"Google Lyria server error — try again in a minute ({exc})"
|
||||
if isinstance(exc, RuntimeError):
|
||||
return str(exc)
|
||||
return f"Lyria generation failed: {exc}"
|
||||
|
||||
|
||||
def _response_parts(response) -> list:
|
||||
"""SDK sometimes leaves response.parts None; audio lives on candidates."""
|
||||
parts = getattr(response, "parts", None)
|
||||
if parts:
|
||||
return list(parts)
|
||||
|
||||
candidates = getattr(response, "candidates", None) or []
|
||||
if not candidates:
|
||||
feedback = getattr(response, "prompt_feedback", None)
|
||||
if feedback:
|
||||
block = getattr(feedback, "block_reason", None)
|
||||
if block:
|
||||
return []
|
||||
return []
|
||||
|
||||
content = getattr(candidates[0], "content", None)
|
||||
if not content:
|
||||
finish = getattr(candidates[0], "finish_reason", None)
|
||||
raise RuntimeError(f"Lyria returned empty content (finish_reason={finish})")
|
||||
|
||||
return list(getattr(content, "parts", None) or [])
|
||||
|
||||
|
||||
class LyriaEngine:
|
||||
"""Google Lyria 3 — generates tracks from DJ prompts."""
|
||||
|
||||
@@ -32,25 +76,38 @@ class LyriaEngine:
|
||||
"High fidelity, stereo, radio-ready mix."
|
||||
)
|
||||
|
||||
response = self._client.models.generate_content(
|
||||
model=self._config.lyria_model,
|
||||
contents=prompt,
|
||||
config=types.GenerateContentConfig(
|
||||
response_modalities=["AUDIO", "TEXT"],
|
||||
),
|
||||
)
|
||||
try:
|
||||
response = self._client.models.generate_content(
|
||||
model=self._config.lyria_model,
|
||||
contents=prompt,
|
||||
config=types.GenerateContentConfig(
|
||||
response_modalities=["AUDIO", "TEXT"],
|
||||
),
|
||||
)
|
||||
except (genai_errors.ClientError, genai_errors.ServerError) as exc:
|
||||
raise RuntimeError(friendly_lyria_error(exc)) from exc
|
||||
|
||||
parts = _response_parts(response)
|
||||
if not parts:
|
||||
raise RuntimeError(
|
||||
"Lyria returned no audio — prompt may have been blocked or the API "
|
||||
"returned an empty response. Not a credits issue if other tracks worked."
|
||||
)
|
||||
|
||||
lyrics = ""
|
||||
audio_bytes: bytes | None = None
|
||||
|
||||
for part in response.parts:
|
||||
for part in parts:
|
||||
if part.text:
|
||||
lyrics += part.text + "\n"
|
||||
elif part.inline_data and part.inline_data.data:
|
||||
audio_bytes = part.inline_data.data
|
||||
|
||||
if not audio_bytes:
|
||||
raise RuntimeError(f"Lyria returned no audio for track {plan.id}")
|
||||
raise RuntimeError(
|
||||
f"Lyria returned no audio for track {plan.id} — check API quota at "
|
||||
"https://aistudio.google.com/apikey"
|
||||
)
|
||||
|
||||
ext = "mp3"
|
||||
safe_title = "".join(c if c.isalnum() or c in "-_" else "_" for c in plan.title)[:40]
|
||||
|
||||
@@ -33,6 +33,7 @@ app.add_middleware(
|
||||
_config: Config | None = None
|
||||
_queue: RadioQueue | None = None
|
||||
_generating = False
|
||||
_generation_state: dict = {"busy": False, "phase": None, "error": None, "track_title": None}
|
||||
_chat = ChatStore()
|
||||
|
||||
|
||||
@@ -66,6 +67,7 @@ def _dashboard_stats(cfg: Config) -> dict:
|
||||
"costs": rs.costs.__dict__ | {"per_track_estimate_usd": per_track},
|
||||
"playback": rs.playback.__dict__,
|
||||
"projected_daily_max_usd": round(per_track * rs.limits.max_new_songs_per_day, 2),
|
||||
"generation": dict(_generation_state),
|
||||
}
|
||||
|
||||
|
||||
@@ -87,17 +89,29 @@ def _play_library_entry(q: RadioQueue, cfg: Config, entry: dict) -> dict:
|
||||
return {"status": "ok", "source": "library", "track": np.__dict__ if np else None}
|
||||
|
||||
|
||||
def _set_generation(*, busy: bool, phase: str | None = None, error: str | None = None, title: str | None = None) -> None:
|
||||
global _generation_state
|
||||
_generation_state = {
|
||||
"busy": busy,
|
||||
"phase": phase,
|
||||
"error": error,
|
||||
"track_title": title,
|
||||
}
|
||||
|
||||
|
||||
async def _compose_track(request: str | None = None, *, check_limit: bool = True) -> dict:
|
||||
global _generating
|
||||
if _generating:
|
||||
return {"status": "busy", "message": "Already generating a track"}
|
||||
return {"status": "busy", "message": "Already generating a track", "generation": _generation_state}
|
||||
|
||||
_generating = True
|
||||
_set_generation(busy=True, phase="planning", error=None)
|
||||
try:
|
||||
cfg = _get_config()
|
||||
if check_limit:
|
||||
ok, budget = _can_generate_today(cfg)
|
||||
if not ok:
|
||||
_set_generation(busy=False)
|
||||
return {
|
||||
"status": "limit",
|
||||
"message": f"Daily limit reached ({budget['max_per_day']} new songs)",
|
||||
@@ -110,6 +124,7 @@ async def _compose_track(request: str | None = None, *, check_limit: bool = True
|
||||
plan = await DeepSeekDJ(cfg).plan_next(
|
||||
taste, q.recent_titles, seeds, request=vibe or None
|
||||
)
|
||||
_set_generation(busy=True, phase="composing", title=plan.title)
|
||||
track = LyriaEngine(cfg).generate(plan)
|
||||
q.add(track)
|
||||
rs = load_radio_settings(lyria_model=cfg.lyria_model)
|
||||
@@ -117,6 +132,7 @@ async def _compose_track(request: str | None = None, *, check_limit: bool = True
|
||||
record_generation(cfg.output_dir, cost, track.plan.id, track.plan.title)
|
||||
np = q.now_playing()
|
||||
_, budget = _can_generate_today(cfg)
|
||||
_set_generation(busy=False)
|
||||
return {
|
||||
"status": "ok",
|
||||
"source": "generated",
|
||||
@@ -125,6 +141,10 @@ async def _compose_track(request: str | None = None, *, check_limit: bool = True
|
||||
"cost_usd": cost,
|
||||
"budget": budget,
|
||||
}
|
||||
except Exception as exc:
|
||||
message = str(exc)
|
||||
_set_generation(busy=False, error=message)
|
||||
return {"status": "error", "message": message, "generation": _generation_state}
|
||||
finally:
|
||||
_generating = False
|
||||
|
||||
@@ -285,7 +305,7 @@ async def chat_with_dj(body: ChatRequest, background: BackgroundTasks) -> dict:
|
||||
ok, _ = _can_generate_today(cfg)
|
||||
if ok:
|
||||
result["generating"] = True
|
||||
background.add_task(_compose_track, reply.vibe_hint or None)
|
||||
background.add_task(_background_compose, reply.vibe_hint or None)
|
||||
else:
|
||||
result["reply"] += " (Daily new-song limit reached — playing saved tracks only.)"
|
||||
|
||||
@@ -347,6 +367,13 @@ async def player_page() -> HTMLResponse:
|
||||
return HTMLResponse("<h1>Live Ozan Radio</h1><p>gateway/player.html missing</p>")
|
||||
|
||||
|
||||
async def _background_compose(request: str | None = None) -> None:
|
||||
"""Chat-triggered generation — never raises; errors land in _generation_state."""
|
||||
result = await _compose_track(request)
|
||||
if result.get("status") == "error":
|
||||
_chat.add("dj", f"Couldn't finish that track: {result.get('message', 'unknown error')}")
|
||||
|
||||
|
||||
async def _autofill_queue(target: int = 2) -> None:
|
||||
"""Background: keep a small buffer of generated tracks."""
|
||||
while True:
|
||||
|
||||
Reference in New Issue
Block a user