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 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 WORKDIR /app
COPY requirements.txt . COPY requirements.txt .
+1
View File
@@ -5,3 +5,4 @@ aiosqlite==0.20.0
pydantic>=2.10.0 pydantic>=2.10.0
python-multipart==0.0.12 python-multipart==0.0.12
python-dotenv==1.0.0 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 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 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 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") 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"] artwork_path = ARTWORK_DIR / clip["artwork_filename"]
if artwork_path.exists(): if artwork_path.exists():
artwork_path.unlink() 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) await delete_clip(clip_id)
return {"deleted": True} 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") @app.get("/api/clips/{clip_id}/artwork")
async def get_artwork(clip_id: str): async def get_artwork(clip_id: str):
clip = await get_clip(clip_id) 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
@@ -0,0 +1,137 @@
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="200" viewBox="0 0 800 200" preserveAspectRatio="none">
<defs>
<linearGradient id="barGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#f59e0b" />
<stop offset="100%" stop-color="#ea580c" />
</linearGradient>
</defs>
<rect width="800" height="200" fill="transparent" />
<rect x="0.00" y="10.00" width="10.53" height="90.00" fill="hsl(25.0, 85%, 45.0%)" rx="2.63" />
<rect x="0.00" y="100.00" width="10.53" height="90.00" fill="hsl(25.0, 85%, 45.0%)" rx="2.63" opacity="0.6" />
<rect x="12.53" y="10.00" width="10.53" height="90.00" fill="hsl(25.2, 85%, 45.3%)" rx="2.63" />
<rect x="12.53" y="100.00" width="10.53" height="90.00" fill="hsl(25.2, 85%, 45.3%)" rx="2.63" opacity="0.6" />
<rect x="25.06" y="10.00" width="10.53" height="90.00" fill="hsl(25.3, 85%, 45.6%)" rx="2.63" />
<rect x="25.06" y="100.00" width="10.53" height="90.00" fill="hsl(25.3, 85%, 45.6%)" rx="2.63" opacity="0.6" />
<rect x="37.59" y="10.00" width="10.53" height="90.00" fill="hsl(25.5, 85%, 46.0%)" rx="2.63" />
<rect x="37.59" y="100.00" width="10.53" height="90.00" fill="hsl(25.5, 85%, 46.0%)" rx="2.63" opacity="0.6" />
<rect x="50.12" y="10.00" width="10.53" height="90.00" fill="hsl(25.6, 85%, 46.3%)" rx="2.63" />
<rect x="50.12" y="100.00" width="10.53" height="90.00" fill="hsl(25.6, 85%, 46.3%)" rx="2.63" opacity="0.6" />
<rect x="62.66" y="10.00" width="10.53" height="90.00" fill="hsl(25.8, 85%, 46.6%)" rx="2.63" />
<rect x="62.66" y="100.00" width="10.53" height="90.00" fill="hsl(25.8, 85%, 46.6%)" rx="2.63" opacity="0.6" />
<rect x="75.19" y="10.00" width="10.53" height="90.00" fill="hsl(26.0, 85%, 46.9%)" rx="2.63" />
<rect x="75.19" y="100.00" width="10.53" height="90.00" fill="hsl(26.0, 85%, 46.9%)" rx="2.63" opacity="0.6" />
<rect x="87.72" y="10.00" width="10.53" height="90.00" fill="hsl(26.1, 85%, 47.2%)" rx="2.63" />
<rect x="87.72" y="100.00" width="10.53" height="90.00" fill="hsl(26.1, 85%, 47.2%)" rx="2.63" opacity="0.6" />
<rect x="100.25" y="10.00" width="10.53" height="90.00" fill="hsl(26.3, 85%, 47.5%)" rx="2.63" />
<rect x="100.25" y="100.00" width="10.53" height="90.00" fill="hsl(26.3, 85%, 47.5%)" rx="2.63" opacity="0.6" />
<rect x="112.78" y="10.00" width="10.53" height="90.00" fill="hsl(26.4, 85%, 47.9%)" rx="2.63" />
<rect x="112.78" y="100.00" width="10.53" height="90.00" fill="hsl(26.4, 85%, 47.9%)" rx="2.63" opacity="0.6" />
<rect x="125.31" y="10.00" width="10.53" height="90.00" fill="hsl(26.6, 85%, 48.2%)" rx="2.63" />
<rect x="125.31" y="100.00" width="10.53" height="90.00" fill="hsl(26.6, 85%, 48.2%)" rx="2.63" opacity="0.6" />
<rect x="137.84" y="10.00" width="10.53" height="90.00" fill="hsl(26.7, 85%, 48.5%)" rx="2.63" />
<rect x="137.84" y="100.00" width="10.53" height="90.00" fill="hsl(26.7, 85%, 48.5%)" rx="2.63" opacity="0.6" />
<rect x="150.38" y="10.00" width="10.53" height="90.00" fill="hsl(26.9, 85%, 48.8%)" rx="2.63" />
<rect x="150.38" y="100.00" width="10.53" height="90.00" fill="hsl(26.9, 85%, 48.8%)" rx="2.63" opacity="0.6" />
<rect x="162.91" y="10.00" width="10.53" height="90.00" fill="hsl(27.1, 85%, 49.1%)" rx="2.63" />
<rect x="162.91" y="100.00" width="10.53" height="90.00" fill="hsl(27.1, 85%, 49.1%)" rx="2.63" opacity="0.6" />
<rect x="175.44" y="10.00" width="10.53" height="90.00" fill="hsl(27.2, 85%, 49.4%)" rx="2.63" />
<rect x="175.44" y="100.00" width="10.53" height="90.00" fill="hsl(27.2, 85%, 49.4%)" rx="2.63" opacity="0.6" />
<rect x="187.97" y="10.00" width="10.53" height="90.00" fill="hsl(27.4, 85%, 49.8%)" rx="2.63" />
<rect x="187.97" y="100.00" width="10.53" height="90.00" fill="hsl(27.4, 85%, 49.8%)" rx="2.63" opacity="0.6" />
<rect x="200.50" y="10.00" width="10.53" height="90.00" fill="hsl(27.5, 85%, 50.1%)" rx="2.63" />
<rect x="200.50" y="100.00" width="10.53" height="90.00" fill="hsl(27.5, 85%, 50.1%)" rx="2.63" opacity="0.6" />
<rect x="213.03" y="10.00" width="10.53" height="90.00" fill="hsl(27.7, 85%, 50.4%)" rx="2.63" />
<rect x="213.03" y="100.00" width="10.53" height="90.00" fill="hsl(27.7, 85%, 50.4%)" rx="2.63" opacity="0.6" />
<rect x="225.56" y="10.00" width="10.53" height="90.00" fill="hsl(27.9, 85%, 50.7%)" rx="2.63" />
<rect x="225.56" y="100.00" width="10.53" height="90.00" fill="hsl(27.9, 85%, 50.7%)" rx="2.63" opacity="0.6" />
<rect x="238.09" y="10.00" width="10.53" height="90.00" fill="hsl(28.0, 85%, 51.0%)" rx="2.63" />
<rect x="238.09" y="100.00" width="10.53" height="90.00" fill="hsl(28.0, 85%, 51.0%)" rx="2.63" opacity="0.6" />
<rect x="250.62" y="10.00" width="10.53" height="90.00" fill="hsl(28.2, 85%, 51.3%)" rx="2.63" />
<rect x="250.62" y="100.00" width="10.53" height="90.00" fill="hsl(28.2, 85%, 51.3%)" rx="2.63" opacity="0.6" />
<rect x="263.16" y="10.00" width="10.53" height="90.00" fill="hsl(28.3, 85%, 51.7%)" rx="2.63" />
<rect x="263.16" y="100.00" width="10.53" height="90.00" fill="hsl(28.3, 85%, 51.7%)" rx="2.63" opacity="0.6" />
<rect x="275.69" y="10.00" width="10.53" height="90.00" fill="hsl(28.5, 85%, 52.0%)" rx="2.63" />
<rect x="275.69" y="100.00" width="10.53" height="90.00" fill="hsl(28.5, 85%, 52.0%)" rx="2.63" opacity="0.6" />
<rect x="288.22" y="10.00" width="10.53" height="90.00" fill="hsl(28.7, 85%, 52.3%)" rx="2.63" />
<rect x="288.22" y="100.00" width="10.53" height="90.00" fill="hsl(28.7, 85%, 52.3%)" rx="2.63" opacity="0.6" />
<rect x="300.75" y="10.00" width="10.53" height="90.00" fill="hsl(28.8, 85%, 52.6%)" rx="2.63" />
<rect x="300.75" y="100.00" width="10.53" height="90.00" fill="hsl(28.8, 85%, 52.6%)" rx="2.63" opacity="0.6" />
<rect x="313.28" y="10.00" width="10.53" height="90.00" fill="hsl(29.0, 85%, 52.9%)" rx="2.63" />
<rect x="313.28" y="100.00" width="10.53" height="90.00" fill="hsl(29.0, 85%, 52.9%)" rx="2.63" opacity="0.6" />
<rect x="325.81" y="10.00" width="10.53" height="90.00" fill="hsl(29.1, 85%, 53.3%)" rx="2.63" />
<rect x="325.81" y="100.00" width="10.53" height="90.00" fill="hsl(29.1, 85%, 53.3%)" rx="2.63" opacity="0.6" />
<rect x="338.34" y="10.00" width="10.53" height="90.00" fill="hsl(29.3, 85%, 53.6%)" rx="2.63" />
<rect x="338.34" y="100.00" width="10.53" height="90.00" fill="hsl(29.3, 85%, 53.6%)" rx="2.63" opacity="0.6" />
<rect x="350.88" y="10.00" width="10.53" height="90.00" fill="hsl(29.4, 85%, 53.9%)" rx="2.63" />
<rect x="350.88" y="100.00" width="10.53" height="90.00" fill="hsl(29.4, 85%, 53.9%)" rx="2.63" opacity="0.6" />
<rect x="363.41" y="10.00" width="10.53" height="90.00" fill="hsl(29.6, 85%, 54.2%)" rx="2.63" />
<rect x="363.41" y="100.00" width="10.53" height="90.00" fill="hsl(29.6, 85%, 54.2%)" rx="2.63" opacity="0.6" />
<rect x="375.94" y="10.00" width="10.53" height="90.00" fill="hsl(29.8, 85%, 54.5%)" rx="2.63" />
<rect x="375.94" y="100.00" width="10.53" height="90.00" fill="hsl(29.8, 85%, 54.5%)" rx="2.63" opacity="0.6" />
<rect x="388.47" y="10.00" width="10.53" height="90.00" fill="hsl(29.9, 85%, 54.8%)" rx="2.63" />
<rect x="388.47" y="100.00" width="10.53" height="90.00" fill="hsl(29.9, 85%, 54.8%)" rx="2.63" opacity="0.6" />
<rect x="401.00" y="10.00" width="10.53" height="90.00" fill="hsl(30.1, 85%, 55.2%)" rx="2.63" />
<rect x="401.00" y="100.00" width="10.53" height="90.00" fill="hsl(30.1, 85%, 55.2%)" rx="2.63" opacity="0.6" />
<rect x="413.53" y="10.00" width="10.53" height="90.00" fill="hsl(30.2, 85%, 55.5%)" rx="2.63" />
<rect x="413.53" y="100.00" width="10.53" height="90.00" fill="hsl(30.2, 85%, 55.5%)" rx="2.63" opacity="0.6" />
<rect x="426.06" y="10.00" width="10.53" height="90.00" fill="hsl(30.4, 85%, 55.8%)" rx="2.63" />
<rect x="426.06" y="100.00" width="10.53" height="90.00" fill="hsl(30.4, 85%, 55.8%)" rx="2.63" opacity="0.6" />
<rect x="438.59" y="10.00" width="10.53" height="90.00" fill="hsl(30.6, 85%, 56.1%)" rx="2.63" />
<rect x="438.59" y="100.00" width="10.53" height="90.00" fill="hsl(30.6, 85%, 56.1%)" rx="2.63" opacity="0.6" />
<rect x="451.12" y="10.00" width="10.53" height="90.00" fill="hsl(30.7, 85%, 56.4%)" rx="2.63" />
<rect x="451.12" y="100.00" width="10.53" height="90.00" fill="hsl(30.7, 85%, 56.4%)" rx="2.63" opacity="0.6" />
<rect x="463.66" y="10.00" width="10.53" height="90.00" fill="hsl(30.9, 85%, 56.7%)" rx="2.63" />
<rect x="463.66" y="100.00" width="10.53" height="90.00" fill="hsl(30.9, 85%, 56.7%)" rx="2.63" opacity="0.6" />
<rect x="476.19" y="10.00" width="10.53" height="90.00" fill="hsl(31.0, 85%, 57.1%)" rx="2.63" />
<rect x="476.19" y="100.00" width="10.53" height="90.00" fill="hsl(31.0, 85%, 57.1%)" rx="2.63" opacity="0.6" />
<rect x="488.72" y="10.00" width="10.53" height="90.00" fill="hsl(31.2, 85%, 57.4%)" rx="2.63" />
<rect x="488.72" y="100.00" width="10.53" height="90.00" fill="hsl(31.2, 85%, 57.4%)" rx="2.63" opacity="0.6" />
<rect x="501.25" y="10.00" width="10.53" height="90.00" fill="hsl(31.3, 85%, 57.7%)" rx="2.63" />
<rect x="501.25" y="100.00" width="10.53" height="90.00" fill="hsl(31.3, 85%, 57.7%)" rx="2.63" opacity="0.6" />
<rect x="513.78" y="10.00" width="10.53" height="90.00" fill="hsl(31.5, 85%, 58.0%)" rx="2.63" />
<rect x="513.78" y="100.00" width="10.53" height="90.00" fill="hsl(31.5, 85%, 58.0%)" rx="2.63" opacity="0.6" />
<rect x="526.31" y="10.00" width="10.53" height="90.00" fill="hsl(31.7, 85%, 58.3%)" rx="2.63" />
<rect x="526.31" y="100.00" width="10.53" height="90.00" fill="hsl(31.7, 85%, 58.3%)" rx="2.63" opacity="0.6" />
<rect x="538.84" y="10.00" width="10.53" height="90.00" fill="hsl(31.8, 85%, 58.7%)" rx="2.63" />
<rect x="538.84" y="100.00" width="10.53" height="90.00" fill="hsl(31.8, 85%, 58.7%)" rx="2.63" opacity="0.6" />
<rect x="551.38" y="10.00" width="10.53" height="90.00" fill="hsl(32.0, 85%, 59.0%)" rx="2.63" />
<rect x="551.38" y="100.00" width="10.53" height="90.00" fill="hsl(32.0, 85%, 59.0%)" rx="2.63" opacity="0.6" />
<rect x="563.91" y="10.00" width="10.53" height="90.00" fill="hsl(32.1, 85%, 59.3%)" rx="2.63" />
<rect x="563.91" y="100.00" width="10.53" height="90.00" fill="hsl(32.1, 85%, 59.3%)" rx="2.63" opacity="0.6" />
<rect x="576.44" y="10.00" width="10.53" height="90.00" fill="hsl(32.3, 85%, 59.6%)" rx="2.63" />
<rect x="576.44" y="100.00" width="10.53" height="90.00" fill="hsl(32.3, 85%, 59.6%)" rx="2.63" opacity="0.6" />
<rect x="588.97" y="10.00" width="10.53" height="90.00" fill="hsl(32.5, 85%, 59.9%)" rx="2.63" />
<rect x="588.97" y="100.00" width="10.53" height="90.00" fill="hsl(32.5, 85%, 59.9%)" rx="2.63" opacity="0.6" />
<rect x="601.50" y="10.00" width="10.53" height="90.00" fill="hsl(32.6, 85%, 60.2%)" rx="2.63" />
<rect x="601.50" y="100.00" width="10.53" height="90.00" fill="hsl(32.6, 85%, 60.2%)" rx="2.63" opacity="0.6" />
<rect x="614.03" y="10.00" width="10.53" height="90.00" fill="hsl(32.8, 85%, 60.6%)" rx="2.63" />
<rect x="614.03" y="100.00" width="10.53" height="90.00" fill="hsl(32.8, 85%, 60.6%)" rx="2.63" opacity="0.6" />
<rect x="626.56" y="10.00" width="10.53" height="90.00" fill="hsl(32.9, 85%, 60.9%)" rx="2.63" />
<rect x="626.56" y="100.00" width="10.53" height="90.00" fill="hsl(32.9, 85%, 60.9%)" rx="2.63" opacity="0.6" />
<rect x="639.09" y="10.00" width="10.53" height="90.00" fill="hsl(33.1, 85%, 61.2%)" rx="2.63" />
<rect x="639.09" y="100.00" width="10.53" height="90.00" fill="hsl(33.1, 85%, 61.2%)" rx="2.63" opacity="0.6" />
<rect x="651.62" y="10.00" width="10.53" height="90.00" fill="hsl(33.3, 85%, 61.5%)" rx="2.63" />
<rect x="651.62" y="100.00" width="10.53" height="90.00" fill="hsl(33.3, 85%, 61.5%)" rx="2.63" opacity="0.6" />
<rect x="664.16" y="10.00" width="10.53" height="90.00" fill="hsl(33.4, 85%, 61.8%)" rx="2.63" />
<rect x="664.16" y="100.00" width="10.53" height="90.00" fill="hsl(33.4, 85%, 61.8%)" rx="2.63" opacity="0.6" />
<rect x="676.69" y="10.00" width="10.53" height="90.00" fill="hsl(33.6, 85%, 62.1%)" rx="2.63" />
<rect x="676.69" y="100.00" width="10.53" height="90.00" fill="hsl(33.6, 85%, 62.1%)" rx="2.63" opacity="0.6" />
<rect x="689.22" y="10.00" width="10.53" height="90.00" fill="hsl(33.7, 85%, 62.5%)" rx="2.63" />
<rect x="689.22" y="100.00" width="10.53" height="90.00" fill="hsl(33.7, 85%, 62.5%)" rx="2.63" opacity="0.6" />
<rect x="701.75" y="10.00" width="10.53" height="90.00" fill="hsl(33.9, 85%, 62.8%)" rx="2.63" />
<rect x="701.75" y="100.00" width="10.53" height="90.00" fill="hsl(33.9, 85%, 62.8%)" rx="2.63" opacity="0.6" />
<rect x="714.28" y="10.00" width="10.53" height="90.00" fill="hsl(34.0, 85%, 63.1%)" rx="2.63" />
<rect x="714.28" y="100.00" width="10.53" height="90.00" fill="hsl(34.0, 85%, 63.1%)" rx="2.63" opacity="0.6" />
<rect x="726.81" y="10.00" width="10.53" height="90.00" fill="hsl(34.2, 85%, 63.4%)" rx="2.63" />
<rect x="726.81" y="100.00" width="10.53" height="90.00" fill="hsl(34.2, 85%, 63.4%)" rx="2.63" opacity="0.6" />
<rect x="739.34" y="10.00" width="10.53" height="90.00" fill="hsl(34.4, 85%, 63.7%)" rx="2.63" />
<rect x="739.34" y="100.00" width="10.53" height="90.00" fill="hsl(34.4, 85%, 63.7%)" rx="2.63" opacity="0.6" />
<rect x="751.88" y="13.74" width="10.53" height="86.26" fill="hsl(34.5, 85%, 64.0%)" rx="2.63" />
<rect x="751.88" y="100.00" width="10.53" height="86.26" fill="hsl(34.5, 85%, 64.0%)" rx="2.63" opacity="0.6" />
<rect x="764.41" y="23.38" width="10.53" height="76.62" fill="hsl(34.7, 85%, 64.4%)" rx="2.63" />
<rect x="764.41" y="100.00" width="10.53" height="76.62" fill="hsl(34.7, 85%, 64.4%)" rx="2.63" opacity="0.6" />
<rect x="776.94" y="35.46" width="10.53" height="64.54" fill="hsl(34.8, 85%, 64.7%)" rx="2.63" />
<rect x="776.94" y="100.00" width="10.53" height="64.54" fill="hsl(34.8, 85%, 64.7%)" rx="2.63" opacity="0.6" />
<rect x="789.47" y="42.58" width="10.53" height="57.42" fill="hsl(35.0, 85%, 65.0%)" rx="2.63" />
<rect x="789.47" y="100.00" width="10.53" height="57.42" fill="hsl(35.0, 85%, 65.0%)" rx="2.63" opacity="0.6" />
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

+11
View File
@@ -141,6 +141,17 @@ export default function DetailPanel({ clip, onClose, onRename, onDelete, onUploa
/> />
</div> </div>
{/* FFT Waveform */}
{clip.status === 'ready' && (
<div className="rounded-lg overflow-hidden">
<img
src={`/api/clips/${clip.id}/waveform`}
alt="Waveform"
className="w-full h-20 object-cover"
/>
</div>
)}
{/* Title */} {/* Title */}
<div> <div>
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">