Files
led2/lightsync/api/audio.py
Claude d339ba588d feat(06-02): add feature extraction and segmentation backend modules + API
- Create lightsync/audio/features.py with extract_features and extract_features_async
- Create lightsync/audio/segments.py with detect_segments via agglomerative clustering
- Add /api/audio/segments and /api/audio/features endpoints to audio.py
- Initialize app.state.segments cache in main.py lifespan
2026-04-07 11:40:29 +00:00

231 lines
8.5 KiB
Python

"""Audio upload, file listing, waveform, and beat analysis endpoints."""
import asyncio
import json
import re
import shutil
import sys
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request, UploadFile, File
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from lightsync.audio.waveform import extract_peaks
from lightsync.audio.beats import analyze_async
from lightsync.audio.segments import detect_segments_async
from lightsync.audio.features import extract_features_async
router = APIRouter()
SUPPORTED_EXTENSIONS = {".mp3", ".wav", ".flac", ".ogg"}
SHOWS_DIR = Path("/app/shows")
PROGRESS_RE = re.compile(r'\[download\]\s+([\d.]+)%')
class YouTubeFetchRequest(BaseModel):
url: str
@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}
@router.post("/fetch")
async def fetch_youtube(request: Request, body: YouTubeFetchRequest):
"""Download audio from YouTube URL via yt-dlp. Streams SSE progress events."""
async def event_stream():
filename = None
try:
proc = await asyncio.create_subprocess_exec(
sys.executable, '-m', 'yt_dlp',
'--extract-audio',
'--audio-format', 'mp3',
'--audio-quality', '0',
'--newline',
'--no-playlist',
'-o', str(SHOWS_DIR / 'yt_%(id)s.%(ext)s'),
body.url,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
async for line_bytes in proc.stdout:
line = line_bytes.decode('utf-8', errors='replace').rstrip()
# Progress line
m = PROGRESS_RE.search(line)
if m:
pct = float(m.group(1))
yield f"data: {json.dumps({'type': 'progress', 'pct': pct})}\n\n"
# Destination line — captures actual output filename
if 'Destination:' in line:
filename = line.split('Destination:')[-1].strip()
await proc.wait()
if proc.returncode != 0:
yield f"data: {json.dumps({'type': 'error', 'msg': f'yt-dlp exited with code {proc.returncode}'})}\n\n"
return
if not filename:
# Fallback: find most recently created mp3 in SHOWS_DIR
mp3s = sorted(SHOWS_DIR.glob('yt_*.mp3'), key=lambda p: p.stat().st_mtime, reverse=True)
if mp3s:
filename = str(mp3s[0])
if filename:
# Run beat analysis in background and cache
try:
beat_data = await analyze_async(filename)
if not hasattr(request.app.state, 'beats') or request.app.state.beats is None:
request.app.state.beats = {}
request.app.state.beats[filename] = beat_data
except Exception:
pass # Non-fatal — beats can be loaded later
fname = Path(filename).name
yield f"data: {json.dumps({'type': 'done', 'path': filename, 'filename': fname})}\n\n"
else:
yield f"data: {json.dumps({'type': 'error', 'msg': 'Download completed but output file not found'})}\n\n"
except Exception as e:
yield f"data: {json.dumps({'type': 'error', 'msg': str(e)})}\n\n"
return StreamingResponse(event_stream(), media_type="text/event-stream")
@router.get("/segments")
async def get_segments(request: Request, path: str, n_segments: int | None = None):
"""Return structural segments for an audio file. Cached in app.state.segments."""
segments_cache: dict = getattr(request.app.state, "segments", None) or {}
cache_key = f"{path}:{n_segments or 'auto'}"
if cache_key not in segments_cache:
p = Path(path)
if not p.exists():
raise HTTPException(status_code=404, detail=f"File not found: {path}")
try:
segments = await detect_segments_async(path, n_segments)
if not hasattr(request.app.state, "segments") or request.app.state.segments is None:
request.app.state.segments = {}
request.app.state.segments[cache_key] = segments
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Segmentation failed: {exc}") from exc
else:
segments = segments_cache[cache_key]
return {"segments": segments, "count": len(segments), "file": path}
@router.get("/features")
async def get_features(request: Request, path: str):
"""Return extracted audio features (onsets, chroma, RMS) for a file."""
p = Path(path)
if not p.exists():
raise HTTPException(status_code=404, detail=f"File not found: {path}")
try:
features = await extract_features_async(path)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Feature extraction failed: {exc}") from exc
return {**features, "file": path}