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:
@@ -1,10 +1,10 @@
|
|||||||
"""Audio loading and status endpoints."""
|
"""Audio upload, file listing, waveform, and beat analysis endpoints."""
|
||||||
import asyncio
|
import asyncio
|
||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from fastapi import APIRouter, HTTPException, Request, UploadFile, File
|
from fastapi import APIRouter, HTTPException, Request, UploadFile, File
|
||||||
from pydantic import BaseModel
|
|
||||||
from lightsync.audio.waveform import extract_peaks
|
from lightsync.audio.waveform import extract_peaks
|
||||||
|
from lightsync.audio.beats import analyze_async
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -12,44 +12,6 @@ SUPPORTED_EXTENSIONS = {".mp3", ".wav", ".flac", ".ogg"}
|
|||||||
SHOWS_DIR = Path("/app/shows")
|
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")
|
@router.get("/files")
|
||||||
async def list_audio_files():
|
async def list_audio_files():
|
||||||
"""List audio files available in the shows directory."""
|
"""List audio files available in the shows directory."""
|
||||||
@@ -63,14 +25,17 @@ async def list_audio_files():
|
|||||||
|
|
||||||
@router.post("/upload")
|
@router.post("/upload")
|
||||||
async def upload_audio(request: Request, file: UploadFile = File(...)):
|
async def upload_audio(request: Request, file: UploadFile = File(...)):
|
||||||
"""Upload an audio file to the shows directory and load it."""
|
"""Upload an audio file to the shows directory and run beat analysis.
|
||||||
engine = request.app.state.engine
|
|
||||||
if engine is None:
|
|
||||||
raise HTTPException(status_code=503, detail="Audio engine not ready")
|
|
||||||
|
|
||||||
|
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()
|
suffix = Path(file.filename).suffix.lower()
|
||||||
if suffix not in SUPPORTED_EXTENSIONS:
|
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)
|
SHOWS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
dest = SHOWS_DIR / file.filename
|
dest = SHOWS_DIR / file.filename
|
||||||
@@ -78,30 +43,76 @@ async def upload_audio(request: Request, file: UploadFile = File(...)):
|
|||||||
with dest.open("wb") as f:
|
with dest.open("wb") as f:
|
||||||
shutil.copyfileobj(file.file, f)
|
shutil.copyfileobj(file.file, f)
|
||||||
|
|
||||||
await asyncio.to_thread(engine.load, str(dest))
|
# Run beat analysis in background — cache result keyed by filepath
|
||||||
return {"status": "loaded", "path": str(dest), "filename": file.filename}
|
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")
|
@router.get("/waveform")
|
||||||
async def get_waveform(request: Request, peaks: int = 1000):
|
async def get_waveform(path: str, peaks: int = 1000):
|
||||||
"""Get waveform peak data for the currently loaded audio file (AUD-05).
|
"""Get waveform peak data for a file (AUD-05).
|
||||||
|
|
||||||
Query params:
|
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
|
p = Path(path)
|
||||||
if engine is None:
|
if not p.exists():
|
||||||
raise HTTPException(status_code=503, detail="Audio engine not ready")
|
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||||
|
|
||||||
state = engine.get_state()
|
|
||||||
if not state["loaded"] or not state["file"]:
|
|
||||||
raise HTTPException(status_code=400, detail="No audio file loaded")
|
|
||||||
|
|
||||||
num_peaks = min(max(peaks, 100), 5000)
|
num_peaks = min(max(peaks, 100), 5000)
|
||||||
|
|
||||||
try:
|
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:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"Waveform extraction failed: {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}
|
||||||
|
|||||||
Reference in New Issue
Block a user