feat: Add themed FFT waveform visualization to detail panel
Backend:
- Install ffmpeg + numpy in Docker image
- New waveform.py: FFT-based SVG generator with 64 frequency bands
- GET /api/clips/{id}/waveform — generates on demand, caches to /data/waveforms/
- DELETE /api/clips/{id} now cascade-removes waveform SVG
- Warm amber-to-orange HSL gradient (25° to 35° hue)
- Mirrored bars: top full opacity, bottom 60% opacity
Frontend:
- DetailPanel shows waveform image beneath album art (80px height)
- Only shown for clips with status='ready'
Storage: /data/waveforms/ persisted via existing ./data volume mount
This commit is contained in:
1 parent
3c6a5d1a84
commit
8f5c8a87d1
6 files changed
+330
No files matched your search
@@ -12,6 +12,7 @@ load_dotenv(Path(__file__).parent.parent / ".env")
|
||||
from models import GenerateRequest, GenerateResponse, ClipList, RenameRequest, LYRIA_MODELS, DEFAULT_MODEL
|
||||
from database import init_db, create_clip, get_clips, get_clip, delete_clip, update_clip_status, rename_clip, set_artwork, remove_artwork
|
||||
from lyria_client import generate_clip, save_clip_file
|
||||
from waveform import generate_waveform_svg, get_waveform_path
|
||||
|
||||
app = FastAPI(title="Lyria Studio", version="0.1.0")
|
||||
|
||||
@@ -124,10 +125,33 @@ async def remove_clip(clip_id: str):
|
||||
artwork_path = ARTWORK_DIR / clip["artwork_filename"]
|
||||
if artwork_path.exists():
|
||||
artwork_path.unlink()
|
||||
# Also delete waveform if it exists
|
||||
waveform_path = get_waveform_path(clip_id)
|
||||
if waveform_path and waveform_path.exists():
|
||||
waveform_path.unlink()
|
||||
await delete_clip(clip_id)
|
||||
return {"deleted": True}
|
||||
|
||||
|
||||
@app.get("/api/clips/{clip_id}/waveform")
|
||||
async def get_waveform(clip_id: str):
|
||||
clip = await get_clip(clip_id)
|
||||
if not clip:
|
||||
raise HTTPException(status_code=404, detail="Clip not found")
|
||||
# Check if waveform already exists
|
||||
svg_path = get_waveform_path(clip_id)
|
||||
if svg_path and svg_path.exists():
|
||||
return FileResponse(svg_path, media_type="image/svg+xml")
|
||||
# Generate on demand
|
||||
try:
|
||||
svg_path_str = generate_waveform_svg(clip_id, clip["filename"])
|
||||
return FileResponse(svg_path_str, media_type="image/svg+xml")
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Audio file not found")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Waveform generation failed: {str(e)}")
|
||||
|
||||
|
||||
@app.get("/api/clips/{clip_id}/artwork")
|
||||
async def get_artwork(clip_id: str):
|
||||
clip = await get_clip(clip_id)
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import subprocess
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
|
||||
CLIPS_DIR = Path(__file__).parent.parent / "data" / "clips"
|
||||
WAVEFORM_DIR = Path(__file__).parent.parent / "data" / "waveforms"
|
||||
WAVEFORM_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def generate_waveform_svg(clip_id: str, filename: str) -> str:
|
||||
"""Generate a themed FFT waveform SVG for an audio clip.
|
||||
|
||||
Returns the path to the generated SVG file.
|
||||
"""
|
||||
audio_path = CLIPS_DIR / filename
|
||||
svg_path = WAVEFORM_DIR / f"{clip_id}.svg"
|
||||
|
||||
if not audio_path.exists():
|
||||
raise FileNotFoundError(f"Audio file not found: {audio_path}")
|
||||
|
||||
# Decode audio to raw PCM using ffmpeg
|
||||
# Output: 16-bit signed little-endian, mono, 22050 Hz
|
||||
cmd = [
|
||||
"ffmpeg", "-i", str(audio_path),
|
||||
"-f", "s16le", "-acodec", "pcm_s16le",
|
||||
"-ac", "1", "-ar", "22050",
|
||||
"-"
|
||||
]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"ffmpeg failed: {result.stderr.decode()[:200]}")
|
||||
|
||||
# Convert raw bytes to numpy array
|
||||
audio_data = np.frombuffer(result.stdout, dtype=np.int16)
|
||||
|
||||
# Normalize to float [-1, 1]
|
||||
audio_data = audio_data.astype(np.float32) / 32768.0
|
||||
|
||||
# Parameters
|
||||
num_bands = 64
|
||||
svg_width = 800
|
||||
svg_height = 200
|
||||
bar_gap = 2
|
||||
bar_width = (svg_width - (num_bands - 1) * bar_gap) / num_bands
|
||||
max_bar_height = svg_height * 0.9 # Leave some margin
|
||||
|
||||
# Divide audio into segments and compute FFT for each
|
||||
samples_per_band = len(audio_data) // num_bands
|
||||
|
||||
band_heights = []
|
||||
for i in range(num_bands):
|
||||
start = i * samples_per_band
|
||||
end = start + samples_per_band
|
||||
segment = audio_data[start:end]
|
||||
|
||||
if len(segment) < 2:
|
||||
band_heights.append(0)
|
||||
continue
|
||||
|
||||
# Apply Hann window
|
||||
window = np.hanning(len(segment))
|
||||
windowed = segment * window
|
||||
|
||||
# FFT
|
||||
fft = np.fft.rfft(windowed)
|
||||
magnitude = np.abs(fft)
|
||||
|
||||
# Use the average magnitude in the lower-mid frequencies (most audible)
|
||||
# Focus on ~100Hz - 4kHz range
|
||||
freqs = np.fft.rfftfreq(len(segment), d=1.0/22050)
|
||||
mask = (freqs >= 100) & (freqs <= 4000)
|
||||
if np.any(mask):
|
||||
avg_mag = np.mean(magnitude[mask])
|
||||
else:
|
||||
avg_mag = np.mean(magnitude[:len(magnitude)//4]) # fallback: first quarter
|
||||
|
||||
# Convert to dB-like scale and normalize
|
||||
# Add small offset to avoid log(0)
|
||||
db = 20 * np.log10(avg_mag + 1e-10)
|
||||
# Map roughly -60dB to 0dB range to 0-1
|
||||
normalized = (db + 60) / 60
|
||||
normalized = np.clip(normalized, 0, 1)
|
||||
|
||||
band_heights.append(normalized)
|
||||
|
||||
# Smooth the heights slightly for visual appeal
|
||||
smoothed = []
|
||||
for i, h in enumerate(band_heights):
|
||||
neighbors = band_heights[max(0, i-1):min(len(band_heights), i+2)]
|
||||
smoothed.append(np.mean(neighbors))
|
||||
band_heights = smoothed
|
||||
|
||||
# Build SVG
|
||||
# Color gradient: warm amber/orange tones (lyria-accent themed)
|
||||
# Using HSL interpolation from deep amber to bright orange
|
||||
def band_color(index: int, total: int) -> str:
|
||||
"""Generate color for a band — warm amber to orange gradient."""
|
||||
t = index / (total - 1) if total > 1 else 0
|
||||
# HSL: hue 25° (amber) to 35° (orange), saturation 80-90%, lightness 45-65%
|
||||
hue = 25 + t * 10
|
||||
sat = 85
|
||||
light = 45 + t * 20
|
||||
return f"hsl({hue:.1f}, {sat}%, {light:.1f}%)"
|
||||
|
||||
# SVG content
|
||||
svg_parts = [
|
||||
f'<svg xmlns="http://www.w3.org/2000/svg" width="{svg_width}" height="{svg_height}" viewBox="0 0 {svg_width} {svg_height}" preserveAspectRatio="none">',
|
||||
'<defs>',
|
||||
' <linearGradient id="barGrad" x1="0%" y1="0%" x2="0%" y2="100%">',
|
||||
' <stop offset="0%" stop-color="#f59e0b" />', # amber-500
|
||||
' <stop offset="100%" stop-color="#ea580c" />', # orange-600
|
||||
' </linearGradient>',
|
||||
'</defs>',
|
||||
f'<rect width="{svg_width}" height="{svg_height}" fill="transparent" />',
|
||||
]
|
||||
|
||||
center_y = svg_height / 2
|
||||
|
||||
for i, height in enumerate(band_heights):
|
||||
x = i * (bar_width + bar_gap)
|
||||
bar_h = height * max_bar_height / 2 # Half goes up, half goes down
|
||||
|
||||
# Color based on position in spectrum
|
||||
color = band_color(i, num_bands)
|
||||
|
||||
# Top bar (above center)
|
||||
svg_parts.append(
|
||||
f' <rect x="{x:.2f}" y="{center_y - bar_h:.2f}" '
|
||||
f'width="{bar_width:.2f}" height="{bar_h:.2f}" '
|
||||
f'fill="{color}" rx="{bar_width/4:.2f}" />'
|
||||
)
|
||||
|
||||
# Bottom bar (mirror, below center)
|
||||
svg_parts.append(
|
||||
f' <rect x="{x:.2f}" y="{center_y:.2f}" '
|
||||
f'width="{bar_width:.2f}" height="{bar_h:.2f}" '
|
||||
f'fill="{color}" rx="{bar_width/4:.2f}" opacity="0.6" />'
|
||||
)
|
||||
|
||||
svg_parts.append('</svg>')
|
||||
|
||||
svg_content = '\n'.join(svg_parts)
|
||||
svg_path.write_text(svg_content, encoding='utf-8')
|
||||
|
||||
return str(svg_path)
|
||||
|
||||
|
||||
def get_waveform_path(clip_id: str) -> Path | None:
|
||||
"""Get path to existing waveform SVG, or None if not generated."""
|
||||
svg_path = WAVEFORM_DIR / f"{clip_id}.svg"
|
||||
if svg_path.exists():
|
||||
return svg_path
|
||||
return None
|
||||
Reference in new issue
Block a user