From 8f5c8a87d13e1340a2b770769155aa140dd62d4f Mon Sep 17 00:00:00 2001 From: Kagamine Rin Date: Fri, 29 May 2026 20:11:01 -0400 Subject: [PATCH] feat: Add themed FFT waveform visualization to detail panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/Dockerfile | 3 + backend/requirements.txt | 1 + backend/src/main.py | 24 +++ backend/src/waveform.py | 154 ++++++++++++++++++ .../7239af1a-2a3f-46bd-816a-2e349752cb83.svg | 137 ++++++++++++++++ frontend/src/components/DetailPanel.tsx | 11 ++ 6 files changed, 330 insertions(+) create mode 100644 backend/src/waveform.py create mode 100644 data/waveforms/7239af1a-2a3f-46bd-816a-2e349752cb83.svg diff --git a/backend/Dockerfile b/backend/Dockerfile index 5a8c000..06e935b 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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 . diff --git a/backend/requirements.txt b/backend/requirements.txt index 0e298d1..b592f4c 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/backend/src/main.py b/backend/src/main.py index bc2e6a1..d9d9aa6 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -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) diff --git a/backend/src/waveform.py b/backend/src/waveform.py new file mode 100644 index 0000000..0e661fe --- /dev/null +++ b/backend/src/waveform.py @@ -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'', + '', + ' ', + ' ', # amber-500 + ' ', # orange-600 + ' ', + '', + f'', + ] + + 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' ' + ) + + # Bottom bar (mirror, below center) + svg_parts.append( + f' ' + ) + + svg_parts.append('') + + 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 diff --git a/data/waveforms/7239af1a-2a3f-46bd-816a-2e349752cb83.svg b/data/waveforms/7239af1a-2a3f-46bd-816a-2e349752cb83.svg new file mode 100644 index 0000000..6808582 --- /dev/null +++ b/data/waveforms/7239af1a-2a3f-46bd-816a-2e349752cb83.svg @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/DetailPanel.tsx b/frontend/src/components/DetailPanel.tsx index bc27acd..8c32dbe 100644 --- a/frontend/src/components/DetailPanel.tsx +++ b/frontend/src/components/DetailPanel.tsx @@ -141,6 +141,17 @@ export default function DetailPanel({ clip, onClose, onRename, onDelete, onUploa /> + {/* FFT Waveform */} + {clip.status === 'ready' && ( +
+ Waveform +
+ )} + {/* Title */}