feat: Redesign waveform as amplitude-over-time visualization

Replaces 64-band FFT frequency spectrum with 200-bar amplitude envelope:
- RMS amplitude per time slice — shows actual track dynamics
- Quiet intros, loud drops, silence gaps all visible
- Per-track normalization — each track uses its full dynamic range
- Gentle compression (power 0.7) so quiet parts remain visible
- 3-point weighted smoothing preserves dynamics while looking polished
- Time-progressive HSL gradient: deep amber (20°) → bright orange (40°)
- Single centered bars with rounded caps — classic waveform look
- 160px height, 800px width, tight 1px gap for dense visualization
- Cleared cached waveforms so all clips regenerate with new algorithm
This commit is contained in:
rin.kagamine committed 2026-05-29 20:15:50 -04:00
1 parent 8f5c8a87d1
commit 744f0ae6e5
2 files changed
+258 -207

No files matched your search

+56 -71
View File
@@ -8,7 +8,10 @@ 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.
"""Generate an amplitude-over-time waveform SVG for an audio clip.
Shows the actual dynamics of the track — quiet intros, loud drops,
silence gaps. Each track's waveform is unique to its amplitude envelope.
Returns the path to the generated SVG file.
"""
@@ -38,104 +41,86 @@ def generate_waveform_svg(clip_id: str, filename: str) -> str:
audio_data = audio_data.astype(np.float32) / 32768.0
# Parameters
num_bands = 64
num_bars = 200 # Time resolution — 200 bars across the width
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
svg_height = 160 # Slightly shorter for the detail panel
bar_gap = 1 # Tight spacing for dense look
bar_width = (svg_width - (num_bars - 1) * bar_gap) / num_bars
max_bar_height = svg_height * 0.95
# Divide audio into segments and compute FFT for each
samples_per_band = len(audio_data) // num_bands
# Divide audio into time slices and compute RMS amplitude for each
samples_per_bar = len(audio_data) // num_bars
band_heights = []
for i in range(num_bands):
start = i * samples_per_band
end = start + samples_per_band
bar_heights = []
for i in range(num_bars):
start = i * samples_per_bar
end = start + samples_per_bar
segment = audio_data[start:end]
if len(segment) < 2:
band_heights.append(0)
if len(segment) == 0:
bar_heights.append(0)
continue
# Apply Hann window
window = np.hanning(len(segment))
windowed = segment * window
# RMS amplitude — the actual "loudness" of this time slice
rms = np.sqrt(np.mean(segment ** 2))
# FFT
fft = np.fft.rfft(windowed)
magnitude = np.abs(fft)
# Apply slight compression so quiet parts are still visible
# but loud parts don't all max out
compressed = np.power(rms, 0.7)
# 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)
bar_heights.append(compressed)
# Smooth the heights slightly for visual appeal
# Normalize to [0, 1] based on the max in THIS track
# (so each track uses its full dynamic range)
max_height = max(bar_heights) if bar_heights else 1
if max_height > 0:
bar_heights = [h / max_height for h in bar_heights]
# Light smoothing for visual polish (very gentle, preserves dynamics)
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
for i, h in enumerate(bar_heights):
# 3-point moving average with center weight
neighbors = bar_heights[max(0, i-1):min(len(bar_heights), i+2)]
weights = [0.25, 0.5, 0.25] if len(neighbors) == 3 else [1.0 / len(neighbors)] * len(neighbors)
smoothed.append(sum(n * w for n, w in zip(neighbors, weights)))
bar_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."""
# Color: warm amber-to-orange gradient that progresses with time
# Left side = deeper amber, right side = brighter orange
def bar_color(index: int, total: int) -> str:
"""Generate color for a bar — time-progressive warm 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}%)"
# HSL: hue 20° (deep amber) to 40° (bright orange)
# Saturation 75-90%, lightness 40-60%
hue = 20 + t * 20
sat = 75 + t * 15
light = 40 + t * 20
return f"hsl({hue:.1f}, {sat:.1f}%, {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):
for i, height in enumerate(bar_heights):
x = i * (bar_width + bar_gap)
bar_h = height * max_bar_height / 2 # Half goes up, half goes down
bar_h = height * max_bar_height / 2 # Half up, half down (centered)
# Color based on position in spectrum
color = band_color(i, num_bands)
if bar_h < 0.5:
# Skip rendering sub-pixel bars
continue
# Top bar (above center)
color = bar_color(i, num_bars)
# Single centered bar (classic waveform look)
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" />'
f'width="{bar_width:.2f}" height="{bar_h * 2:.2f}" '
f'fill="{color}" rx="{min(bar_width/2, 2):.2f}" />'
)
svg_parts.append('</svg>')