From 6843ecd6b000b7023673cfafdb98b56e1539b1e5 Mon Sep 17 00:00:00 2001 From: tinqs-limited Date: Sun, 7 Jun 2026 14:33:16 +0100 Subject: [PATCH] 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 --- gateway/player.html | 58 ++++++++++++++-- songs/82595273.meta.json | 10 +++ songs/82595273_Caravan_of_the_Blue_Hour.mp3 | 3 + songs/eeebb429.meta.json | 10 +++ songs/eeebb429_Desert_Mirage.mp3 | 3 + songs/manifest.json | 22 +++++- src/ozan_radio/lyria.py | 75 ++++++++++++++++++--- src/ozan_radio/server.py | 31 ++++++++- 8 files changed, 195 insertions(+), 17 deletions(-) create mode 100644 songs/82595273.meta.json create mode 100644 songs/82595273_Caravan_of_the_Blue_Hour.mp3 create mode 100644 songs/eeebb429.meta.json create mode 100644 songs/eeebb429_Desert_Mirage.mp3 diff --git a/gateway/player.html b/gateway/player.html index 7964a41..1985f0c 100644 --- a/gateway/player.html +++ b/gateway/player.html @@ -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') { diff --git a/songs/82595273.meta.json b/songs/82595273.meta.json new file mode 100644 index 0000000..85aa668 --- /dev/null +++ b/songs/82595273.meta.json @@ -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" +} \ No newline at end of file diff --git a/songs/82595273_Caravan_of_the_Blue_Hour.mp3 b/songs/82595273_Caravan_of_the_Blue_Hour.mp3 new file mode 100644 index 0000000..8f773a2 --- /dev/null +++ b/songs/82595273_Caravan_of_the_Blue_Hour.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1f6e13582ff27e9f95b99e99d116f028883f6aacd5dab841686213c370bc2e4d +size 4195908 diff --git a/songs/eeebb429.meta.json b/songs/eeebb429.meta.json new file mode 100644 index 0000000..9546cc2 --- /dev/null +++ b/songs/eeebb429.meta.json @@ -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" +} \ No newline at end of file diff --git a/songs/eeebb429_Desert_Mirage.mp3 b/songs/eeebb429_Desert_Mirage.mp3 new file mode 100644 index 0000000..f01e73c --- /dev/null +++ b/songs/eeebb429_Desert_Mirage.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c4cc1bf2c123f012f5d1ea250edc0e72bf7794608c28dfffdaee9c40755f0328 +size 4071775 diff --git a/songs/manifest.json b/songs/manifest.json index 2efdb50..a9fbcad 100644 --- a/songs/manifest.json +++ b/songs/manifest.json @@ -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" } ] } \ No newline at end of file diff --git a/src/ozan_radio/lyria.py b/src/ozan_radio/lyria.py index 101d36f..ed8574f 100644 --- a/src/ozan_radio/lyria.py +++ b/src/ozan_radio/lyria.py @@ -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] diff --git a/src/ozan_radio/server.py b/src/ozan_radio/server.py index 28f106b..baf2916 100644 --- a/src/ozan_radio/server.py +++ b/src/ozan_radio/server.py @@ -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("

Live Ozan Radio

gateway/player.html missing

") +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: