Files
kithara-studio/backend/src/main.py
T
rin.kagamine e0ce9e4b6c feat: Maestro Mode - context-aware prompt generation
- Backend: POST /api/text-gen/maestro endpoint with dual modes
  - Empty prompt → generate from scratch (temp 1.0)
  - Non-empty prompt → complete/extend (temp 0.85)
  - structured=true → returns JSON fields for Advanced/Pro mode
  - Three system prompts: MAESTRO_GENERATE, MAESTRO_COMPLETE, MAESTRO_STRUCTURED
  - Trailing sentence cleanup for plain text mode
  - JSON fallback parsing for structured mode

- Frontend: MaestroButton component
  - Context-aware labels: ♪ Maestro Mode (empty) / ♪ Keep Composing (has content)
  - Loading states: animating ♩ with Dreaming.../Composing...
  - structured prop for Pro mode → populates structured fields
  - onStructuredResult callback merges fields into StructuredPrompt

- UX: Replaced 'Get Suggestions ✨' with Maestro Mode
  - Suggestions required text in the box; Maestro works with or without
  - Single button, context-aware behavior, cleaner UI
  - Advanced/Pro mode gets structured fields from Maestro
  - Removed SuggestionsModal dependency

- Rename: 'Compose for Me' → 'Keep Composing'
2026-05-31 10:13:43 -04:00

553 lines
21 KiB
Python

from fastapi import FastAPI, HTTPException, BackgroundTasks, UploadFile, File
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from pathlib import Path
import os
import shutil
from dotenv import load_dotenv
# Load environment variables BEFORE importing kithara_client (it reads env at module level)
load_dotenv(Path(__file__).parent.parent / ".env")
from models import GenerateRequest, GenerateResponse, ClipList, RenameRequest, MetadataRequest, GenerateArtworkRequest, LYRIA_MODELS, DEFAULT_MODEL
from database import init_db, create_clip, get_clips, get_clip, delete_clip, update_clip_status, rename_clip, set_artwork, remove_artwork, delete_all_clips, set_analysis, update_metadata
from kithara_client import generate_clip, save_clip_file
from waveform import generate_waveform_svg, get_waveform_path
from analysis import analyze_clip
from image_gen import generate_artwork, get_image_gen_config, get_available_models, SAFETY_LEVELS
from text_gen import suggest_prompts, maestro_prompt, get_text_gen_config, get_available_models as get_text_models
app = FastAPI(title="Kithara Studio", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
CLIPS_DIR = Path(__file__).parent.parent / "data" / "clips"
CLIPS_DIR.mkdir(parents=True, exist_ok=True)
ARTWORK_DIR = Path(__file__).parent.parent / "data" / "artwork"
ARTWORK_DIR.mkdir(parents=True, exist_ok=True)
ALLOWED_ARTWORK_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
MAX_ARTWORK_SIZE = 5 * 1024 * 1024 # 5 MB
@app.on_event("startup")
async def startup():
await init_db()
@app.get("/api/health")
async def health():
return {"status": "ok", "version": "0.1.0"}
@app.get("/api/models")
async def list_models():
return {"models": LYRIA_MODELS, "default": DEFAULT_MODEL}
async def _generate_and_save(clip_id: str, prompt: str, model: str, duration: int):
"""Background task: call Google Lyria API and save the result.
DR-LYRIA-001: Pre-generates waveform SVG after clip is ready,
so users never wait for on-demand generation.
"""
try:
result = await generate_clip(prompt, model, duration)
if result["success"]:
file_info = await save_clip_file(clip_id, result["audio_bytes"])
await update_clip_status(
clip_id,
status="ready",
file_size=file_info["file_size"]
)
# DR-LYRIA-001: Pre-generate waveform SVG immediately
# so it's ready before the user views the clip
try:
clip = await get_clip(clip_id)
if clip and clip.get("filename"):
await generate_waveform_svg(clip_id, clip["filename"])
except Exception as wf_err:
# Waveform pre-generation failure is non-fatal —
# on-demand fallback will catch it on view
print(f"Waveform pre-gen warning for {clip_id}: {wf_err}")
else:
await update_clip_status(
clip_id,
status="failed",
error=result.get("error", "Unknown error")
)
except Exception as e:
await update_clip_status(
clip_id,
status="failed",
error=str(e)
)
@app.post("/api/generate", response_model=GenerateResponse)
async def generate(request: GenerateRequest, background_tasks: BackgroundTasks):
duration = request.get_effective_duration()
# Build prompt from structured fields if present
final_prompt = request.build_prompt()
# Serialize structured prompt for DB storage
import json
prompt_structure_json = json.dumps(request.prompt_structure) if request.prompt_structure else None
clip_id = await create_clip(final_prompt, request.model, duration, prompt_structure_json)
# Kick off generation in background
background_tasks.add_task(
_generate_and_save,
clip_id,
final_prompt,
request.model,
duration
)
return GenerateResponse(
id=clip_id,
status="generating",
message="Clip generation started. Check back for status."
)
@app.get("/api/clips", response_model=ClipList)
async def list_clips(limit: int = 50, offset: int = 0):
clips = await get_clips(limit, offset)
return ClipList(clips=clips, total=len(clips))
@app.get("/api/clips/{clip_id}")
async def get_clip_by_id(clip_id: str):
clip = await get_clip(clip_id)
if not clip:
raise HTTPException(status_code=404, detail="Clip not found")
return clip
@app.get("/api/clips/{clip_id}/audio")
async def stream_audio(clip_id: str):
clip = await get_clip(clip_id)
if not clip:
raise HTTPException(status_code=404, detail="Clip not found")
file_path = CLIPS_DIR / clip["filename"]
if not file_path.exists():
raise HTTPException(status_code=404, detail="Audio file not found")
return FileResponse(file_path, media_type="audio/mpeg", filename=clip["filename"])
from mutagen.mp3 import MP3
from mutagen.id3 import ID3, TIT1, TIT2, TPE1, TALB, COMM, TBPM, TKEY
@app.get("/api/clips/{clip_id}/download")
async def download_clip(clip_id: str):
clip = await get_clip(clip_id)
if not clip:
raise HTTPException(status_code=404, detail="Clip not found")
file_path = CLIPS_DIR / clip["filename"]
if not file_path.exists():
raise HTTPException(status_code=404, detail="Audio file not found")
# Build download filename from title or prompt
base_name = clip.get("title") or clip["prompt"]
# Sanitize for filesystem
safe_name = "".join(c for c in base_name if c.isalnum() or c in " -_").strip()
safe_name = safe_name[:60] # Limit length
if not safe_name:
safe_name = clip_id
download_filename = f"{safe_name}.mp3"
# Create a temporary copy with ID3 tags
import tempfile
import shutil
temp_file = tempfile.NamedTemporaryFile(suffix='.mp3', delete=False)
temp_path = Path(temp_file.name)
temp_file.close()
try:
# Copy original file
shutil.copy2(file_path, temp_path)
# Write ID3 tags
try:
audio = MP3(temp_path)
if audio.tags is None:
audio.add_tags()
# Title
if clip.get("title"):
audio.tags["TIT2"] = TIT2(encoding=3, text=clip["title"])
# Artist
if clip.get("artist"):
audio.tags["TPE1"] = TPE1(encoding=3, text=clip["artist"])
# Album
if clip.get("album"):
audio.tags["TALB"] = TALB(encoding=3, text=clip["album"])
# Comment
if clip.get("comment"):
audio.tags["COMM"] = COMM(encoding=3, lang='eng', desc='', text=clip["comment"])
# BPM
if clip.get("bpm"):
audio.tags["TBPM"] = TBPM(encoding=3, text=str(int(clip["bpm"])))
# Key
if clip.get("key"):
audio.tags["TKEY"] = TKEY(encoding=3, text=clip["key"])
# Also store the prompt in a custom tag for reference
if clip.get("prompt"):
audio.tags["TIT1"] = TIT1(encoding=3, text=clip["prompt"][:100])
audio.save()
except Exception as e:
print(f"Warning: Failed to write ID3 tags: {e}")
return FileResponse(
temp_path,
media_type="audio/mpeg",
filename=download_filename,
content_disposition_type="attachment"
)
except Exception as e:
if temp_path.exists():
temp_path.unlink()
raise HTTPException(status_code=500, detail=f"Failed to prepare download: {str(e)}")
@app.delete("/api/clips/{clip_id}")
async def remove_clip(clip_id: str):
clip = await get_clip(clip_id)
if not clip:
raise HTTPException(status_code=404, detail="Clip not found")
file_path = CLIPS_DIR / clip["filename"]
if file_path.exists():
file_path.unlink()
# Also delete artwork if it exists
if clip.get("artwork_filename"):
artwork_path = ARTWORK_DIR / clip["artwork_filename"]
if artwork_path.exists():
artwork_path.unlink()
# Also delete waveform if it exists
waveform_path = get_waveform_path(clip_id)
if waveform_path and waveform_path.exists():
waveform_path.unlink()
await delete_clip(clip_id)
return {"deleted": True}
@app.get("/api/clips/{clip_id}/waveform")
async def get_waveform(clip_id: str):
clip = await get_clip(clip_id)
if not clip:
raise HTTPException(status_code=404, detail="Clip not found")
# Check if pre-generated waveform exists on disk
svg_path = get_waveform_path(clip_id)
if svg_path and svg_path.exists():
return FileResponse(
svg_path,
media_type="image/svg+xml",
headers={"Cache-Control": "public, max-age=86400"}
)
# Lazy fallback: generate on demand (should rarely trigger)
try:
svg_path_str = await generate_waveform_svg(clip_id, clip["filename"])
return FileResponse(
svg_path_str,
media_type="image/svg+xml",
headers={"Cache-Control": "public, max-age=86400"}
)
except FileNotFoundError:
raise HTTPException(status_code=404, detail="Audio file not found")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Waveform generation failed: {str(e)}")
@app.get("/api/clips/{clip_id}/artwork")
async def get_artwork(clip_id: str):
clip = await get_clip(clip_id)
if not clip:
raise HTTPException(status_code=404, detail="Clip not found")
if not clip.get("artwork_filename"):
raise HTTPException(status_code=404, detail="No artwork for this clip")
artwork_path = ARTWORK_DIR / clip["artwork_filename"]
if not artwork_path.exists():
raise HTTPException(status_code=404, detail="Artwork file not found")
# Determine media type from extension
ext = artwork_path.suffix.lower()
media_types = {".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif", ".webp": "image/webp"}
media_type = media_types.get(ext, "application/octet-stream")
return FileResponse(artwork_path, media_type=media_type)
@app.patch("/api/clips/{clip_id}/artwork")
async def upload_artwork(clip_id: str, file: UploadFile = File(...)):
clip = await get_clip(clip_id)
if not clip:
raise HTTPException(status_code=404, detail="Clip not found")
# Validate content type
if file.content_type not in ALLOWED_ARTWORK_TYPES:
raise HTTPException(
status_code=400,
detail=f"Invalid file type. Allowed: {', '.join(ALLOWED_ARTWORK_TYPES)}"
)
# Read and validate size
contents = await file.read()
if len(contents) > MAX_ARTWORK_SIZE:
raise HTTPException(status_code=400, detail="File too large. Maximum size is 5 MB.")
# Determine extension
ext_map = {"image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp"}
ext = ext_map.get(file.content_type or "", ".jpg")
artwork_filename = f"{clip_id}{ext}"
artwork_path = ARTWORK_DIR / artwork_filename
# Remove old artwork if it exists with a different extension
if clip.get("artwork_filename") and clip["artwork_filename"] != artwork_filename:
old_path = ARTWORK_DIR / clip["artwork_filename"]
if old_path.exists():
old_path.unlink()
# Save the file
with open(artwork_path, "wb") as f:
f.write(contents)
# Update DB
await set_artwork(clip_id, artwork_filename)
updated = await get_clip(clip_id)
return updated
@app.delete("/api/clips/{clip_id}/artwork")
async def remove_artwork_endpoint(clip_id: str):
clip = await get_clip(clip_id)
if not clip:
raise HTTPException(status_code=404, detail="Clip not found")
if not clip.get("artwork_filename"):
raise HTTPException(status_code=404, detail="No artwork for this clip")
artwork_path = ARTWORK_DIR / clip["artwork_filename"]
if artwork_path.exists():
artwork_path.unlink()
await remove_artwork(clip_id)
updated = await get_clip(clip_id)
return updated
@app.post("/api/clips/{clip_id}/generate-artwork")
async def generate_clip_artwork(clip_id: str, request: GenerateArtworkRequest):
"""Generate album artwork using Gemini Image model."""
clip = await get_clip(clip_id)
if not clip:
raise HTTPException(status_code=404, detail="Clip not found")
try:
# Generate image
config = get_image_gen_config()
image_bytes = await generate_artwork(request.prompt, config)
# Save to disk
import uuid
artwork_filename = f"{clip_id}_{uuid.uuid4().hex[:8]}.png"
artwork_path = ARTWORK_DIR / artwork_filename
with open(artwork_path, "wb") as f:
f.write(image_bytes)
# Remove old artwork if exists
if clip.get("artwork_filename"):
old_path = ARTWORK_DIR / clip["artwork_filename"]
if old_path.exists():
old_path.unlink()
# Update DB
await set_artwork(clip_id, artwork_filename)
updated = await get_clip(clip_id)
return updated
except Exception as e:
raise HTTPException(status_code=500, detail=f"Image generation failed: {str(e)}")
@app.get("/api/image-gen/models")
async def list_image_gen_models():
"""List available image generation models."""
return {"models": get_available_models()}
@app.get("/api/image-gen/config")
async def get_image_gen_configuration():
"""Get current image generation configuration (safe values only)."""
config = get_image_gen_config()
return {
"model": config["model"],
"safety_harassment": config["safety_harassment"],
"safety_hate_speech": config["safety_hate_speech"],
"safety_sexually_explicit": config["safety_sexually_explicit"],
"safety_dangerous": config["safety_dangerous"],
# API key is masked
"has_api_key": bool(config.get("api_key")),
}
@app.post("/api/image-gen/config")
async def update_image_gen_configuration(request: dict):
"""Update image generation configuration (in-memory only, not persisted to .env)."""
# Update environment variables in-memory for this process
if "model" in request:
os.environ["IMAGE_GEN_MODEL"] = request["model"]
if "safety_harassment" in request:
os.environ["IMAGE_GEN_SAFETY_HARASSMENT"] = request["safety_harassment"]
if "safety_hate_speech" in request:
os.environ["IMAGE_GEN_SAFETY_HATE_SPEECH"] = request["safety_hate_speech"]
if "safety_sexually_explicit" in request:
os.environ["IMAGE_GEN_SAFETY_SEXUALLY_EXPLICIT"] = request["safety_sexually_explicit"]
if "safety_dangerous" in request:
os.environ["IMAGE_GEN_SAFETY_DANGEROUS"] = request["safety_dangerous"]
return {"status": "updated"}
# ─── Text Generation Endpoints ──────────────────────────────────────
@app.get("/api/text-gen/models")
async def list_text_gen_models():
"""List available text generation models."""
return {"models": get_text_models()}
@app.get("/api/text-gen/config")
async def get_text_gen_configuration():
"""Get current text generation configuration (safe values only)."""
config = get_text_gen_config()
return {
"model": config["model"],
"safety_harassment": config["safety_harassment"],
"safety_hate_speech": config["safety_hate_speech"],
"safety_sexually_explicit": config["safety_sexually_explicit"],
"safety_dangerous": config["safety_dangerous"],
"has_api_key": bool(config.get("api_key")),
}
@app.post("/api/text-gen/config")
async def update_text_gen_configuration(request: dict):
"""Update text generation configuration (in-memory only)."""
if "model" in request:
os.environ["TEXT_GEN_MODEL"] = request["model"]
if "safety_harassment" in request:
os.environ["TEXT_GEN_SAFETY_HARASSMENT"] = request["safety_harassment"]
if "safety_hate_speech" in request:
os.environ["TEXT_GEN_SAFETY_HATE_SPEECH"] = request["safety_hate_speech"]
if "safety_sexually_explicit" in request:
os.environ["TEXT_GEN_SAFETY_SEXUALLY_EXPLICIT"] = request["safety_sexually_explicit"]
if "safety_dangerous" in request:
os.environ["TEXT_GEN_SAFETY_DANGEROUS"] = request["safety_dangerous"]
return {"status": "updated"}
@app.post("/api/text-gen/suggest")
async def get_suggestions(request: dict):
"""Get prompt suggestions based on user input."""
user_input = request.get("input", "").strip()
if not user_input:
raise HTTPException(status_code=400, detail="Input is required")
try:
config = get_text_gen_config()
suggestions = await suggest_prompts(user_input, config=config)
return {"suggestions": suggestions}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Suggestion generation failed: {str(e)}")
@app.post("/api/text-gen/maestro")
async def maestro(request: dict):
"""Generate or complete a music prompt using Maestro Mode.
If hint is empty/missing: generates a creative prompt from scratch ("Dream for Me").
If hint is provided: completes the user's existing prompt ("Keep Composing").
If structured=true: returns JSON fields for Advanced/Pro mode instead of plain text.
"""
hint = request.get("hint", "").strip()
model = request.get("model") # Optional text model override
structured = request.get("structured", False) # Return structured fields for Pro mode
try:
config = get_text_gen_config()
result = await maestro_prompt(hint=hint, model=model, config=config, structured=structured)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=f"Maestro generation failed: {str(e)}")
@app.patch("/api/clips/{clip_id}")
async def rename_clip_endpoint(clip_id: str, request: RenameRequest):
clip = await get_clip(clip_id)
if not clip:
raise HTTPException(status_code=404, detail="Clip not found")
await rename_clip(clip_id, request.title)
updated = await get_clip(clip_id)
return updated
@app.patch("/api/clips/{clip_id}/metadata")
async def update_clip_metadata(clip_id: str, request: MetadataRequest):
clip = await get_clip(clip_id)
if not clip:
raise HTTPException(status_code=404, detail="Clip not found")
await update_metadata(clip_id, request.artist, request.album, request.comment)
updated = await get_clip(clip_id)
return updated
# ─── Admin Endpoints ──────────────────────────────────────────────
@app.get("/api/clips/{clip_id}/analysis")
async def get_clip_analysis(clip_id: str):
"""Get BPM and key analysis for a clip. Computes on-demand if not cached."""
clip = await get_clip(clip_id)
if not clip:
raise HTTPException(status_code=404, detail="Clip not found")
if clip.get("status") != "ready":
raise HTTPException(status_code=400, detail="Clip is not ready for analysis")
# Return cached analysis if available
if clip.get("bpm") and clip.get("key"):
return {"bpm": clip["bpm"], "key": clip["key"]}
# Compute analysis
mp3_path = CLIPS_DIR / clip["filename"]
if not mp3_path.exists():
raise HTTPException(status_code=404, detail="Audio file not found")
try:
result = analyze_clip(mp3_path)
# Cache in DB
await set_analysis(clip_id, result["bpm"], result["key"])
return result
except Exception as e:
raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}")
@app.post("/api/admin/clear-waveforms")
async def clear_waveforms():
"""Delete all cached waveform SVGs. They regenerate on next view."""
from waveform import WAVEFORM_DIR
count = 0
if WAVEFORM_DIR.exists():
for f in WAVEFORM_DIR.glob("*.svg"):
f.unlink()
count += 1
return {"cleared": count}
@app.post("/api/admin/delete-all-clips")
async def delete_all_clips_endpoint():
"""Delete all clips, MP3s, artwork, and waveforms. Nuclear option."""
# Get all clips to clean up files
all_clips = await get_clips()
for clip in all_clips:
# Delete MP3
mp3_path = CLIPS_DIR / clip["filename"]
if mp3_path.exists():
mp3_path.unlink()
# Delete artwork
if clip.get("artwork_filename"):
art_path = ARTWORK_DIR / clip["artwork_filename"]
if art_path.exists():
art_path.unlink()
# Delete waveform
wf_path = get_waveform_path(clip["id"])
if wf_path and wf_path.exists():
wf_path.unlink()
# Clear DB
await delete_all_clips()
return {"deleted": len(all_clips)}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)