Files
led2/lightsync/api/audio.py
Claude 9239988d38 fix(02): Alpine libmpv/libsndfile symlinks, upload endpoint, browser file picker
- Dockerfile: add libmpv.so + libsndfile.so symlinks (Alpine/musl has no ldconfig)
- engine.py: monkey-patch ctypes.util.find_library as fallback for Alpine
- audio.py: add /upload (multipart) and /files endpoints
- pyproject.toml: add python-multipart
- frontend: replace path input with upload button + file selector dropdown
2026-04-06 15:34:11 +00:00

108 lines
3.5 KiB
Python

"""Audio loading and status 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
router = APIRouter()
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."""
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 load it."""
engine = request.app.state.engine
if engine is None:
raise HTTPException(status_code=503, detail="Audio engine not ready")
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)
await asyncio.to_thread(engine.load, str(dest))
return {"status": "loaded", "path": str(dest), "filename": file.filename}
@router.get("/waveform")
async def get_waveform(request: Request, peaks: int = 1000):
"""Get waveform peak data for the currently loaded audio file (AUD-05).
Query params:
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")
num_peaks = min(max(peaks, 100), 5000)
try:
data = await asyncio.to_thread(extract_peaks, state["file"], 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"]}