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)
This commit is contained in:
Claude
2026-04-06 15:36:21 +00:00
parent 92cc09e908
commit 636160e9a1

View File

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