"""Audio upload, file listing, waveform, and beat analysis endpoints.""" import asyncio import shutil from pathlib import Path from fastapi import APIRouter, HTTPException, Request, UploadFile, File from lightsync.audio.waveform import extract_peaks from lightsync.audio.beats import analyze_async router = APIRouter() SUPPORTED_EXTENSIONS = {".mp3", ".wav", ".flac", ".ogg"} SHOWS_DIR = Path("/app/shows") @router.get("/files") async def list_audio_files(): """List audio files available in the shows directory.""" SHOWS_DIR.mkdir(parents=True, exist_ok=True) files = sorted( p.name for p in SHOWS_DIR.iterdir() if p.is_file() and p.suffix.lower() in SUPPORTED_EXTENSIONS ) return {"files": files, "directory": str(SHOWS_DIR)} @router.post("/upload") async def upload_audio(request: Request, file: UploadFile = File(...)): """Upload an audio file to the shows directory and run beat analysis. After upload, librosa beat detection runs in a thread and the result is cached in app.state.beats keyed by the absolute file path. """ suffix = Path(file.filename).suffix.lower() if suffix not in SUPPORTED_EXTENSIONS: raise HTTPException( status_code=400, detail=f"Unsupported format: {suffix}. Supported: {', '.join(sorted(SUPPORTED_EXTENSIONS))}" ) SHOWS_DIR.mkdir(parents=True, exist_ok=True) dest = SHOWS_DIR / file.filename with dest.open("wb") as f: shutil.copyfileobj(file.file, f) # Run beat analysis in background — cache result keyed by filepath dest_str = str(dest) try: beat_data = await analyze_async(dest_str) if not hasattr(request.app.state, "beats") or request.app.state.beats is None: request.app.state.beats = {} request.app.state.beats[dest_str] = beat_data except Exception as exc: # Beat analysis failure is non-fatal — upload still succeeds beat_data = None import logging logging.getLogger(__name__).warning("Beat analysis failed for %s: %s", dest_str, exc) return { "status": "uploaded", "path": dest_str, "filename": file.filename, "beats": beat_data, } @router.get("/beats") async def get_beats(request: Request, path: str): """Return cached beat data for a loaded file. Query params: path: absolute path to the audio file (must have been uploaded/analyzed) Returns: {"tempo": float, "beats": [float, ...], "file": str} """ beats_cache: dict = getattr(request.app.state, "beats", None) or {} if path not in beats_cache: # Not cached — run analysis now (may be slow) p = Path(path) if not p.exists(): raise HTTPException(status_code=404, detail=f"File not found: {path}") if p.suffix.lower() not in SUPPORTED_EXTENSIONS: raise HTTPException(status_code=400, detail=f"Unsupported format: {p.suffix}") try: beat_data = await analyze_async(path) if not hasattr(request.app.state, "beats") or request.app.state.beats is None: request.app.state.beats = {} request.app.state.beats[path] = beat_data except Exception as exc: raise HTTPException(status_code=500, detail=f"Beat analysis failed: {exc}") from exc else: beat_data = beats_cache[path] return {**beat_data, "file": path} @router.get("/waveform") async def get_waveform(path: str, peaks: int = 1000): """Get waveform peak data for a file (AUD-05). Query params: path: absolute path to audio file peaks: number of peak samples (default 1000, max 5000) """ p = Path(path) if not p.exists(): raise HTTPException(status_code=404, detail=f"File not found: {path}") num_peaks = min(max(peaks, 100), 5000) try: data = await asyncio.to_thread(extract_peaks, path, num_peaks) except Exception as e: raise HTTPException(status_code=500, detail=f"Waveform extraction failed: {e}") return {"peaks": data, "count": len(data), "file": path}