From 744f0ae6e5782495d79bb800433f9170a966ac6a Mon Sep 17 00:00:00 2001 From: Kagamine Rin Date: Fri, 29 May 2026 20:15:50 -0400 Subject: [PATCH] feat: Redesign waveform as amplitude-over-time visualization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/src/waveform.py | 127 +++---- .../7239af1a-2a3f-46bd-816a-2e349752cb83.svg | 338 +++++++++++------- 2 files changed, 258 insertions(+), 207 deletions(-) diff --git a/backend/src/waveform.py b/backend/src/waveform.py index 0e661fe..b22b29a 100644 --- a/backend/src/waveform.py +++ b/backend/src/waveform.py @@ -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'', - '', - ' ', - ' ', # amber-500 - ' ', # orange-600 - ' ', - '', f'', ] 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' ' - ) - - # Bottom bar (mirror, below center) - svg_parts.append( - f' ' + f'width="{bar_width:.2f}" height="{bar_h * 2:.2f}" ' + f'fill="{color}" rx="{min(bar_width/2, 2):.2f}" />' ) svg_parts.append('') diff --git a/data/waveforms/7239af1a-2a3f-46bd-816a-2e349752cb83.svg b/data/waveforms/7239af1a-2a3f-46bd-816a-2e349752cb83.svg index 6808582..f4aa175 100644 --- a/data/waveforms/7239af1a-2a3f-46bd-816a-2e349752cb83.svg +++ b/data/waveforms/7239af1a-2a3f-46bd-816a-2e349752cb83.svg @@ -1,137 +1,203 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file