Files
kithara-studio/backend/src/kithara_client.py
T
rin.kagamine 816f8fbd2f refactor: rename project Lyria Studio → Kithara Studio
- App name, UI text, page titles → Kithara Studio
- Python module: lyria_client.py → kithara_client.py
- Tailwind color classes: lyria-* → kithara-*
- CSS custom properties: --lyria-* → --kithara-*
- Database: lyria.db → kithara.db
- Package name: kithara-studio-frontend
- localStorage key: kithara-saved-searches
- Add project logo (lyre icon, black on transparent, invert via CSS)

Preserved as Google's property:
- LYRIA_API_KEY, LYRIA_BASE_URL, LYRIA_DEFAULT_MODEL env vars
- LYRIA_MODELS dict and model IDs (lyria-3-clip-preview, lyria-3-pro-preview)
- Google Lyria API references in error messages and docstrings
2026-05-31 09:45:22 -04:00

123 lines
5.0 KiB
Python

import httpx
import base64
import os
import logging
from pathlib import Path
logger = logging.getLogger("kithara_client")
LYRIA_API_KEY = os.getenv("LYRIA_API_KEY", "")
LYRIA_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
CLIPS_DIR = Path(__file__).parent.parent / "data" / "clips"
CLIPS_DIR.mkdir(parents=True, exist_ok=True)
async def generate_clip(prompt: str, model: str = "lyria-3-clip-preview", duration: int = 30) -> dict:
"""Generate a music clip using Google Lyria API.
Returns dict with:
- success: bool
- file_path: str (if success)
- file_size: int (if success)
- error: str (if failed)
"""
if not LYRIA_API_KEY:
return {"success": False, "error": "LYRIA_API_KEY not configured"}
url = f"{LYRIA_BASE_URL}/models/{model}:generateContent?key={LYRIA_API_KEY}"
payload = {
"contents": [{
"role": "user",
"parts": [{
"text": f"{prompt} ({duration} seconds)"
}]
}],
"generationConfig": {
"responseModalities": ["AUDIO"]
}
}
async with httpx.AsyncClient(timeout=120.0) as client:
try:
response = await client.post(url, json=payload)
response.raise_for_status()
data = response.json()
# Check for safety/copyright filter blocks first
# Google's filter can reject prompts and return finish_reason: "SAFETY"
# or blockReason with a message about copyrighted material
block_reason = data.get("blockReason", "")
if block_reason:
block_message = data.get("blockReasonMessage", "")
logger.warning(f"Google Lyria API blocked: {block_reason} — {block_message}")
return {"success": False, "error": f"Prompt was filtered. Try describing the sound using mood, instruments, and tempo instead of genre names. ({block_message or block_reason})"}
# Extract base64 audio from response
# Google Lyria returns parts array: first part may be text (e.g. "<instrumental>"),
# audio is in inlineData of a subsequent part.
candidates = data.get("candidates", [])
if not candidates:
return {"success": False, "error": "Prompt was filtered. Try describing the sound using mood, instruments, and tempo instead of genre names."}
# Check finish_reason for blocks
finish_reason = candidates[0].get("finishReason", "")
if finish_reason == "SAFETY":
return {"success": False, "error": "Prompt was filtered by safety controls. Try rephrasing — describe the sound using mood, instruments, and tempo instead of specific genre or artist names."}
content = candidates[0].get("content", {})
parts = content.get("parts", [])
if not parts:
return {"success": False, "error": "No audio was generated. Try rephrasing your prompt — describe the sound using mood, instruments, and tempo."}
# Find the part with inlineData (audio) — not necessarily parts[0]
audio_part = None
for part in parts:
if "inlineData" in part:
audio_part = part
break
if audio_part is None:
# No audio in any part — likely filtered
return {"success": False, "error": "No audio was generated. Try rephrasing your prompt — describe the sound using mood, instruments, and tempo instead of specific genre names."}
inline_data = audio_part.get("inlineData", {})
mime_type = inline_data.get("mimeType", "")
audio_b64 = inline_data.get("data", "")
if not audio_b64:
return {"success": False, "error": "No audio data in response"}
# Decode and save
audio_bytes = base64.b64decode(audio_b64)
return {
"success": True,
"audio_bytes": audio_bytes,
"mime_type": mime_type
}
except httpx.HTTPStatusError as e:
error_detail = ""
try:
error_detail = e.response.json().get("error", {}).get("message", str(e))
except:
error_detail = str(e)
return {"success": False, "error": f"Google Lyria API error: {error_detail}"}
except Exception as e:
return {"success": False, "error": f"Client error: {str(e)}"}
async def save_clip_file(clip_id: str, audio_bytes: bytes) -> dict:
"""Save audio bytes to disk. Returns file info."""
filename = f"{clip_id}.mp3"
file_path = CLIPS_DIR / filename
file_path.write_bytes(audio_bytes)
file_size = file_path.stat().st_size
return {
"filename": filename,
"file_path": str(file_path),
"file_size": file_size
}