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)