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:
2026-06-07 14:33:16 +01:00
parent 41bb4d6b29
commit 6843ecd6b0
8 changed files with 195 additions and 17 deletions
+54 -4
View File
@@ -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
+10
View File
@@ -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.
+10
View File
@@ -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
View File
@@ -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
View File
@@ -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]
+29 -2
View File
@@ -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: