diff --git a/backend/src/main.py b/backend/src/main.py index 34fe25e..660eb31 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -49,7 +49,11 @@ async def list_models(): return {"models": LYRIA_MODELS, "default": DEFAULT_MODEL} async def _generate_and_save(clip_id: str, prompt: str, model: str, duration: int): - """Background task: call Lyria API and save the result.""" + """Background task: call Lyria API and save the result. + + DR-LYRIA-001: Pre-generates waveform SVG after clip is ready, + so users never wait for on-demand generation. + """ try: result = await generate_clip(prompt, model, duration) @@ -60,6 +64,16 @@ async def _generate_and_save(clip_id: str, prompt: str, model: str, duration: in status="ready", file_size=file_info["file_size"] ) + # DR-LYRIA-001: Pre-generate waveform SVG immediately + # so it's ready before the user views the clip + try: + clip = await get_clip(clip_id) + if clip and clip.get("filename"): + await generate_waveform_svg(clip_id, clip["filename"]) + except Exception as wf_err: + # Waveform pre-generation failure is non-fatal — + # on-demand fallback will catch it on view + print(f"Waveform pre-gen warning for {clip_id}: {wf_err}") else: await update_clip_status( clip_id, @@ -228,14 +242,22 @@ 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 + # Check if pre-generated waveform exists on disk 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 + return FileResponse( + svg_path, + media_type="image/svg+xml", + headers={"Cache-Control": "public, max-age=86400"} + ) + # Lazy fallback: generate on demand (should rarely trigger) try: - svg_path_str = generate_waveform_svg(clip_id, clip["filename"]) - return FileResponse(svg_path_str, media_type="image/svg+xml") + svg_path_str = await generate_waveform_svg(clip_id, clip["filename"]) + return FileResponse( + svg_path_str, + media_type="image/svg+xml", + headers={"Cache-Control": "public, max-age=86400"} + ) except FileNotFoundError: raise HTTPException(status_code=404, detail="Audio file not found") except Exception as e: diff --git a/backend/src/waveform.py b/backend/src/waveform.py index b22b29a..2385462 100644 --- a/backend/src/waveform.py +++ b/backend/src/waveform.py @@ -1,4 +1,4 @@ -import subprocess +import asyncio import numpy as np from pathlib import Path @@ -6,128 +6,137 @@ CLIPS_DIR = Path(__file__).parent.parent / "data" / "clips" WAVEFORM_DIR = Path(__file__).parent.parent / "data" / "waveforms" WAVEFORM_DIR.mkdir(parents=True, exist_ok=True) +# Downsample target: ~2000 samples is plenty for 200 bars. +# Each bar gets ~10 samples of resolution — more than enough for RMS. +DOWNSAMPLE_TARGET = 2000 -def generate_waveform_svg(clip_id: str, filename: str) -> str: + +async def generate_waveform_svg(clip_id: str, filename: str) -> str: """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. - + + Optimized for long tracks (>60s): + - Uses async subprocess for ffmpeg (non-blocking) + - Downsamples early to DOWNSAMPLE_TARGET samples before numpy processing + - Reduces memory footprint from ~5M samples to ~2K for a 120s track + 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 + + # Decode audio to raw PCM using ffmpeg — ASYNC, non-blocking 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]}") - + + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await proc.communicate() + + if proc.returncode != 0: + error_msg = stderr.decode()[:200] if stderr else "unknown error" + raise RuntimeError(f"ffmpeg failed: {error_msg}") + # Convert raw bytes to numpy array - audio_data = np.frombuffer(result.stdout, dtype=np.int16) - + audio_data = np.frombuffer(stdout, dtype=np.int16) + # Normalize to float [-1, 1] audio_data = audio_data.astype(np.float32) / 32768.0 - - # Parameters - num_bars = 200 # Time resolution — 200 bars across the width + + # ── EARLY DOWNSAMPLE ────────────────────────────────────────── + # Instead of computing RMS over millions of samples, downsample first. + # This reduces a 120s track from ~5.3M samples to ~2K — ~95% less work. + if len(audio_data) > DOWNSAMPLE_TARGET: + # Simple slice-based downsampling (nearest-sample, no aliasing + # concerns at this resolution — we're making bars, not audio) + indices = np.linspace(0, len(audio_data), DOWNSAMPLE_TARGET, dtype=int) + audio_data = audio_data[indices] + + # ── WAVEFORM COMPUTATION ────────────────────────────────────── + num_bars = 200 svg_width = 800 - svg_height = 160 # Slightly shorter for the detail panel - bar_gap = 1 # Tight spacing for dense look + svg_height = 160 + bar_gap = 1 bar_width = (svg_width - (num_bars - 1) * bar_gap) / num_bars max_bar_height = svg_height * 0.95 - - # Divide audio into time slices and compute RMS amplitude for each + + # Divide into time slices and compute RMS amplitude samples_per_bar = len(audio_data) // num_bars - + 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) == 0: bar_heights.append(0) continue - - # RMS amplitude — the actual "loudness" of this time slice + rms = np.sqrt(np.mean(segment ** 2)) - - # Apply slight compression so quiet parts are still visible - # but loud parts don't all max out compressed = np.power(rms, 0.7) - bar_heights.append(compressed) - - # Normalize to [0, 1] based on the max in THIS track - # (so each track uses its full dynamic range) + + # Normalize to [0, 1] based on max in THIS track 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) + + # Light smoothing (3-point moving average, center weight) 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)] + 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: warm amber-to-orange gradient that progresses with time - # Left side = deeper amber, right side = brighter orange + + # ── SVG GENERATION ──────────────────────────────────────────── + # Time-progressive warm amber-to-orange gradient 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 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'', f'', ] - + center_y = svg_height / 2 - + for i, height in enumerate(bar_heights): x = i * (bar_width + bar_gap) - bar_h = height * max_bar_height / 2 # Half up, half down (centered) - + bar_h = height * max_bar_height / 2 + if bar_h < 0.5: - # Skip rendering sub-pixel bars continue - + color = bar_color(i, num_bars) - - # Single centered bar (classic waveform look) + svg_parts.append( f' ' + f'fill="{color}" rx="{min(bar_width / 2, 2):.2f}" />' ) - + svg_parts.append('') - svg_content = '\n'.join(svg_parts) - svg_path.write_text(svg_content, encoding='utf-8') - + + # Write SVG to disk (async file write for consistency) + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, svg_path.write_text, svg_content, 'utf-8') + return str(svg_path) @@ -136,4 +145,4 @@ def get_waveform_path(clip_id: str) -> Path | None: svg_path = WAVEFORM_DIR / f"{clip_id}.svg" if svg_path.exists(): return svg_path - return None + return None \ No newline at end of file