Files
led2/lightsync/api/audio.py
Claude 636160e9a1 feat(02-03): redesign audio.py — browser-audio architecture, beat endpoint
- Remove engine dependency entirely (no MPVEngine, no app.state.engine)
- POST /api/audio/upload: saves file, runs librosa beat analysis, caches in app.state.beats
- GET /api/audio/beats?path=...: returns cached or on-demand beat analysis
- GET /api/audio/waveform?path=...: now takes path query param (no engine state lookup)
- GET /api/audio/files: unchanged
- Removed POST /api/audio/load and GET /api/audio/state (engine-only endpoints)
2026-04-06 15:36:21 +00:00

119 lines
4.0 KiB
Python

"""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}