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:
rin.kagamine committed 2026-05-29 20:11:01 -04:00
1 parent 3c6a5d1a84
commit 8f5c8a87d1
6 files changed
+330

No files matched your search

+3
View File
@@ -1,5 +1,8 @@
FROM python:3.12-slim
# Install ffmpeg for audio decoding
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
+1
View File
@@ -5,3 +5,4 @@ aiosqlite==0.20.0
pydantic>=2.10.0
python-multipart==0.0.12
python-dotenv==1.0.0
numpy==2.2.0
+24
View File
@@ -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)
+154
View File
@@ -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