feat(02-03): add waveform extraction module and HTTP endpoint
- Create lightsync/audio/waveform.py with extract_peaks() (AUD-05)
- MP3 via ffmpeg pipe, WAV/FLAC/OGG via soundfile+numpy
- Add GET /api/audio/waveform endpoint with asyncio.to_thread
- peaks param clamped 100-5000, returns {peaks, count, file}
This commit is contained in:
@@ -3,6 +3,7 @@ import asyncio
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
from lightsync.audio.waveform import extract_peaks
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -45,3 +46,28 @@ async def get_audio_state(request: Request):
|
||||
if engine is None:
|
||||
raise HTTPException(status_code=503, detail="Audio engine not ready")
|
||||
return engine.get_state()
|
||||
|
||||
|
||||
@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"]}
|
||||
|
||||
58
lightsync/audio/waveform.py
Normal file
58
lightsync/audio/waveform.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Waveform peak extraction for timeline display (AUD-05)."""
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
|
||||
|
||||
def extract_peaks(path: str, num_peaks: int = 1000) -> list[float]:
|
||||
"""Extract downsampled peak amplitudes from audio file.
|
||||
|
||||
Returns a list of floats (0.0-1.0) representing peak amplitude per chunk.
|
||||
Handles MP3 via ffmpeg subprocess (libsndfile doesn't support MP3).
|
||||
|
||||
Args:
|
||||
path: Absolute path to audio file
|
||||
num_peaks: Number of peak samples to return (default 1000)
|
||||
|
||||
Returns:
|
||||
List of float peak values, length <= num_peaks
|
||||
"""
|
||||
p = Path(path)
|
||||
if p.suffix.lower() == ".mp3":
|
||||
return _extract_peaks_mp3(path, num_peaks)
|
||||
return _extract_peaks_soundfile(path, num_peaks)
|
||||
|
||||
|
||||
def _extract_peaks_soundfile(path: str, num_peaks: int) -> list[float]:
|
||||
"""Extract peaks using soundfile (WAV, FLAC, OGG)."""
|
||||
data, _ = sf.read(path, always_2d=True)
|
||||
mono = np.mean(data, axis=1)
|
||||
return _downsample_peaks(mono, num_peaks)
|
||||
|
||||
|
||||
def _extract_peaks_mp3(path: str, num_peaks: int) -> list[float]:
|
||||
"""Extract peaks from MP3 via ffmpeg pipe to float32 PCM."""
|
||||
cmd = [
|
||||
"ffmpeg", "-i", path,
|
||||
"-f", "f32le", "-ar", "44100", "-ac", "1",
|
||||
"pipe:1", "-loglevel", "quiet",
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, timeout=60)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"ffmpeg failed for {path}: exit code {result.returncode}")
|
||||
mono = np.frombuffer(result.stdout, dtype=np.float32)
|
||||
return _downsample_peaks(mono, num_peaks)
|
||||
|
||||
|
||||
def _downsample_peaks(mono: np.ndarray, num_peaks: int) -> list[float]:
|
||||
"""Downsample mono audio to peak amplitude array."""
|
||||
if len(mono) == 0:
|
||||
return []
|
||||
chunk_size = max(1, len(mono) // num_peaks)
|
||||
peaks = []
|
||||
for i in range(0, len(mono), chunk_size):
|
||||
chunk = mono[i : i + chunk_size]
|
||||
peaks.append(float(np.max(np.abs(chunk))))
|
||||
return peaks[:num_peaks]
|
||||
Reference in New Issue
Block a user