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
@@ -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 .
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 |
@@ -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">
|
||||||
|
|||||||
Reference in new issue
Block a user