From da474d978d6c31c5b8fac59574aa62f04e864d0b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Apr 2026 13:11:38 +0000 Subject: [PATCH] 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} --- lightsync/api/audio.py | 26 +++++++++++++++++ lightsync/audio/waveform.py | 58 +++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 lightsync/audio/waveform.py diff --git a/lightsync/api/audio.py b/lightsync/api/audio.py index 421d27d..8d6305d 100644 --- a/lightsync/api/audio.py +++ b/lightsync/api/audio.py @@ -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"]} diff --git a/lightsync/audio/waveform.py b/lightsync/audio/waveform.py new file mode 100644 index 0000000..89f8cb5 --- /dev/null +++ b/lightsync/audio/waveform.py @@ -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]