diff --git a/lightsync/api/audio.py b/lightsync/api/audio.py index 0d01f61..d5dffb4 100644 --- a/lightsync/api/audio.py +++ b/lightsync/api/audio.py @@ -1,10 +1,10 @@ -"""Audio loading and status endpoints.""" +"""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 pydantic import BaseModel from lightsync.audio.waveform import extract_peaks +from lightsync.audio.beats import analyze_async router = APIRouter() @@ -12,44 +12,6 @@ SUPPORTED_EXTENSIONS = {".mp3", ".wav", ".flac", ".ogg"} SHOWS_DIR = Path("/app/shows") -class LoadRequest(BaseModel): - path: str - - -class AudioState(BaseModel): - position: float - paused: bool - duration: float | None - loaded: bool - file: str | None - - -@router.post("/load") -async def load_audio(req: LoadRequest, request: Request): - """Load an audio file by server path (AUD-01). YouTube URL deferred to Phase 6 (AUD-02 stub).""" - engine = request.app.state.engine - if engine is None: - raise HTTPException(status_code=503, detail="Audio engine not ready") - - p = Path(req.path) - if not p.exists(): - raise HTTPException(status_code=404, detail=f"File not found: {req.path}") - if p.suffix.lower() not in SUPPORTED_EXTENSIONS: - raise HTTPException(status_code=400, detail=f"Unsupported format: {p.suffix}. Supported: {', '.join(sorted(SUPPORTED_EXTENSIONS))}") - - await asyncio.to_thread(engine.load, req.path) - return {"status": "loaded", "path": req.path} - - -@router.get("/state") -async def get_audio_state(request: Request): - """Get current audio playback state.""" - engine = request.app.state.engine - if engine is None: - raise HTTPException(status_code=503, detail="Audio engine not ready") - return engine.get_state() - - @router.get("/files") async def list_audio_files(): """List audio files available in the shows directory.""" @@ -63,14 +25,17 @@ async def list_audio_files(): @router.post("/upload") async def upload_audio(request: Request, file: UploadFile = File(...)): - """Upload an audio file to the shows directory and load it.""" - engine = request.app.state.engine - if engine is None: - raise HTTPException(status_code=503, detail="Audio engine not ready") + """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))}") + 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 @@ -78,30 +43,76 @@ async def upload_audio(request: Request, file: UploadFile = File(...)): with dest.open("wb") as f: shutil.copyfileobj(file.file, f) - await asyncio.to_thread(engine.load, str(dest)) - return {"status": "loaded", "path": str(dest), "filename": file.filename} + # 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(request: Request, peaks: int = 1000): - """Get waveform peak data for the currently loaded audio file (AUD-05). +async def get_waveform(path: str, peaks: int = 1000): + """Get waveform peak data for a file (AUD-05). Query params: - peaks: Number of peak samples (default 1000, max 5000) + path: absolute path to audio file + peaks: number of peak samples (default 1000, max 5000) """ - engine = request.app.state.engine - if engine is None: - raise HTTPException(status_code=503, detail="Audio engine not ready") - - state = engine.get_state() - if not state["loaded"] or not state["file"]: - raise HTTPException(status_code=400, detail="No audio file loaded") + 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, state["file"], num_peaks) + 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": state["file"]} + return {"peaks": data, "count": len(data), "file": path}