"""Waveform PNG with section markers — rebuilt to match v2 design spec. Bar-style waveform on a dark panel, each bar coloured by the section it falls inside, semantic section labels with a coloured top-border below. The palette mirrors `design/README.md`: Intro = mint-deep `#2BB89E` Build = mint `#5BE0C8` Core = coral `#FF6A3D` Outro = coral-bright `#FF8C5A` For >4 sections, the bracketing rule is: first→Intro, last→Outro, the middle ones cycle Build→Core→Build so the centre carries the most energetic colour. <2 sections falls back to a single Core span. """ from __future__ import annotations import os import tempfile from typing import Any import numpy as np from pipeline import Analysis # Design-token colors — keep in sync with theme.py / design/README.md. INK_BG_PANEL = "#121419" # outer panel INK_BG_CARD = "#0F1115" # inside the waveform area INK_BORDER = "#20242C" INK_LABEL = "#5E6671" INK_TIME = "#99A0AB" INK_PLAYHEAD = "#F2EFE9" CORAL = "#FF6A3D" CORAL_BRIGHT = "#FF8C5A" MINT = "#5BE0C8" MINT_DEEP = "#2BB89E" AMBER = "#FFC24B" # Semantic ordering — first → Intro, last → Outro, centre alternates. SECTION_COLORS_BY_ROLE = { "intro": MINT_DEEP, "build": MINT, "core": CORAL, "outro": CORAL_BRIGHT, } def _assign_roles(n: int) -> list[str]: """Pick a role per section index given a total count `n`. Always: - section 0 → intro - last section → outro - middle sections cycle build → core → build so the centre is coral. """ if n <= 0: return [] if n == 1: return ["core"] if n == 2: return ["intro", "outro"] if n == 3: return ["intro", "core", "outro"] roles = ["intro"] middle = n - 2 for i in range(middle): # Pattern: build, core, build, core, … with core landing at the # middle so a 5-section piece reads Intro/Build/Core/Build/Outro. if (i + (1 if middle % 2 == 0 else 0)) % 2 == 0: roles.append("build") else: roles.append("core") # Force a single core at the centre when even-numbered middles default # would skip it. E.g. middle=2 → [build, build]; we want [build, core]. if "core" not in roles: mid_idx = 1 + middle // 2 roles[mid_idx] = "core" roles.append("outro") return roles def render(a: Analysis) -> str | None: """Write a section-coloured bar waveform PNG. Returns path or None if rendering can't run (missing librosa / matplotlib).""" try: import librosa import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt import matplotlib.patches as patches except Exception: return None try: y, sr = librosa.load(a.source_path, sr=22050, mono=True) except Exception: return None duration = len(y) / sr if sr else 1.0 if duration <= 0: return None # Bar-style waveform: chunk into N evenly-spaced bins and take the # peak amplitude per bin. Density tuned so bars feel light at 15s # (~6 bars/s) but stay readable at 180s (~1 bar/s). n_bars = max(120, min(360, int(duration * 6))) samples_per_bar = max(1, len(y) // n_bars) bars = np.array([ np.max(np.abs(y[i * samples_per_bar:(i + 1) * samples_per_bar])) for i in range(n_bars) ]) # Normalise to a stable visual range; tiny clips don't go invisible. peak = bars.max() if bars.size else 1.0 if peak > 0: bars = bars / peak bars = np.clip(bars, 0.02, 1.0) # min height so silent regions still show a tick # Assign roles per section + map back to per-bar colour. sections: list[dict[str, Any]] = list(a.sections or []) roles = _assign_roles(len(sections)) section_colors = [SECTION_COLORS_BY_ROLE[r] for r in roles] if not sections: bar_colors = [CORAL] * n_bars else: bar_colors = [] for i in range(n_bars): t = (i + 0.5) / n_bars * duration picked = section_colors[0] for s, c in zip(sections, section_colors): if t >= s["start"] and t < s["end"]: picked = c break if t >= s["start"]: picked = c bar_colors.append(picked) # Figure: 1200 × 240 with the waveform up top and a compact label # row below carrying section name + time range under coloured rules. fig_w_in, fig_h_in = 11.0, 2.6 dpi = 110 fig = plt.figure(figsize=(fig_w_in, fig_h_in), dpi=dpi, facecolor=INK_BG_PANEL) # Main waveform axis (top ~75%); label strip axis (bottom ~25%). ax_wave = fig.add_axes([0.025, 0.30, 0.95, 0.62], facecolor=INK_BG_CARD) ax_labels = fig.add_axes([0.025, 0.05, 0.95, 0.22], facecolor=INK_BG_PANEL) # Bar widths in data coords — each spans 1 / n_bars of duration with # a small gap so the bars read individually rather than as a block. bar_w = duration / n_bars * 0.78 centers = (np.arange(n_bars) + 0.5) / n_bars * duration # Mirror about y=0 to draw the classic symmetrical waveform. for c, x, color in zip(bars, centers, bar_colors): ax_wave.add_patch(patches.Rectangle( (x - bar_w / 2, -c), bar_w, 2 * c, color=color, linewidth=0, )) ax_wave.set_xlim(0, duration) ax_wave.set_ylim(-1.08, 1.08) ax_wave.set_xticks([]) ax_wave.set_yticks([]) for spine in ax_wave.spines.values(): spine.set_edgecolor(INK_BORDER) spine.set_linewidth(0.8) # Section label strip — coloured top-border per section + name + time. ax_labels.set_xlim(0, duration) ax_labels.set_ylim(0, 1) ax_labels.set_xticks([]) ax_labels.set_yticks([]) for spine in ax_labels.spines.values(): spine.set_visible(False) for s, color, role in zip(sections, section_colors, roles): start = float(s["start"]) end = float(s["end"]) # 2px colored top-border drawn as a thin filled rect. ax_labels.add_patch(patches.Rectangle( (start, 0.85), end - start, 0.06, color=color, linewidth=0, )) mid = (start + end) / 2.0 # Role label (uppercase, mono feel via monospace family). ax_labels.text( mid, 0.55, role.upper(), ha="center", va="center", color=color, fontsize=8.5, fontweight="bold", family="monospace", ) # Time range under the role. ax_labels.text( mid, 0.18, f"{start:0.1f}s – {end:0.1f}s", ha="center", va="center", color=INK_LABEL, fontsize=7.0, family="monospace", ) out = os.path.join(a.workdir or tempfile.gettempdir(), "waveform.png") fig.savefig(out, facecolor=INK_BG_PANEL, edgecolor="none", bbox_inches="tight", pad_inches=0.18) plt.close(fig) return out