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:
1 parent
8f5c8a87d1
commit
744f0ae6e5
2 files changed
+258
-207
No files matched your search
+56
-71
@@ -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>')
|
||||
|
||||
Reference in new issue
Block a user