("create", _canvas);
+ }
+ catch (JSException ex)
+ {
+ Logger.LogWarning(ex, "MixWaveformVisualizer: failed to load the visualizer module; rendering a plain backdrop.");
+ return;
+ }
+
+ // Seed the module with the current state now that it exists.
+ await PushZoomAsync();
+ await PushDatumAsync();
+ await PushPlaybackAsync();
+ await PushThemeIfChangedAsync();
+ return;
+ }
+
+ // On every subsequent render (e.g. dark-mode toggle), re-theme if it changed.
+ await PushThemeIfChangedAsync();
+ }
+
+ private async Task OnZoomFractionChanged(double fraction)
+ {
+ ZoomState.VisibleSeconds = MixZoomMapping.FractionToSeconds(fraction);
+ await PushZoomAsync();
+ StateHasChanged();
+ }
+
+ // ── Bridge pushes. Each is a no-op until the module handle exists. ───────────────────────────
+
+ ///
+ /// Push the datum to the module, but only when it actually changed — a different profile, or the
+ /// mix duration becoming available for the first time. Idempotent so the per-tick playback path
+ /// can call it without re-decoding the (large) base64 datum in JS every frame.
+ ///
+ private async Task PushDatumAsync()
+ {
+ if (_handle is null) return;
+
+ var haveDuration = _profile is not null && PlayerDurationSeconds is > 0;
+
+ // No change since the last push? Nothing to do.
+ if (ReferenceEquals(_profile, _pushedProfile) && haveDuration == _pushedWithDuration)
+ return;
+
+ if (haveDuration)
+ {
+ // The mix duration must come from the player (no DTO field carries it); without a
+ // positive duration we cannot map samples↔time, so we hold off until it arrives.
+ await _handle.InvokeVoidAsync("setDatum", _profile!.Data, PlayerDurationSeconds!.Value);
+ }
+ else
+ {
+ await _handle.InvokeVoidAsync("setDatum", string.Empty, 0d);
+ }
+
+ _pushedProfile = _profile;
+ _pushedWithDuration = haveDuration;
+ }
+
+ private async Task PushPlaybackAsync()
+ {
+ if (_handle is null) return;
+
+ // Duration arrives via the player after the initial (duration-less) datum push; the
+ // idempotent PushDatumAsync re-pushes exactly once when it first becomes available.
+ await PushDatumAsync();
+
+ await _handle.InvokeVoidAsync("setPlayback", CurrentPositionSeconds, IsPlaying);
+ }
+
+ private async Task PushZoomAsync()
+ {
+ if (_handle is null) return;
+ await _handle.InvokeVoidAsync("setZoom", ZoomState.VisibleSeconds);
+ }
+
+ private async Task PushThemeIfChangedAsync()
+ {
+ if (_handle is null) return;
+ var isDark = DarkMode?.IsDarkMode ?? false;
+ if (_lastIsDark == isDark) return;
+ _lastIsDark = isDark;
+
+ // The module reads the gradient stops directly from the canvas's computed --mud-palette-*
+ // vars (canvas gradients can't resolve var(), so resolution must happen in JS). The bespoke
+ // light/dark themes swap those vars on toggle; we just tell the module to re-read.
+ await _handle.InvokeVoidAsync("refreshTheme");
+ }
+
+ // ── Live signal sources. The matching player wins; PlaybackPosition is the no-player fallback. ─
+
+ /// True only when the cascaded player is loaded with THIS mix's track.
+ private bool IsActivePlayer =>
+ PlayerService is { CurrentTrack: not null }
+ && TrackId is { } id
+ && PlayerService.CurrentTrack.Id == id;
+
+ private double? PlayerDurationSeconds =>
+ IsActivePlayer && PlayerService!.Duration is > 0 ? PlayerService.Duration : null;
+
+ private bool IsPlaying => IsActivePlayer && (PlayerService?.IsPlaying ?? false);
+
+ private double CurrentPositionSeconds
+ {
+ get
+ {
+ // Prefer the matching player's absolute time. Otherwise fall back to the one-way
+ // PlaybackPosition ([0,1]) scaled by whatever duration we have; with no duration the
+ // position is unusable, so show the at-rest slice (0).
+ if (IsActivePlayer)
+ return PlayerService!.CurrentTime;
+ if (PlayerDurationSeconds is { } dur)
+ return Math.Clamp(PlaybackPosition, 0, 1) * dur;
+ return 0;
}
}
- // Builds a closed, vertically mirrored silhouette path across the buckets. Loudness bytes are
- // [0, 255]; mapped to a half-height amplitude around the vertical midline (y=50). The top edge
- // runs left-to-right, the bottom edge mirrors right-to-left, and the path closes — yielding a
- // filled continuous wave shape rather than separate bars.
- private static string BuildSilhouettePath(WaveformProfileDto profile)
+ public async ValueTask DisposeAsync()
{
- var data = Convert.FromBase64String(profile.Data);
- int n = data.Length;
- if (n == 0) return string.Empty;
-
- const double midline = 50d;
- const double maxAmplitude = 48d; // leave a 2-unit margin top and bottom
- double step = n > 1 ? (double)ViewBoxWidth / (n - 1) : ViewBoxWidth;
-
- var sb = new StringBuilder();
-
- // Top edge, left to right.
- for (int i = 0; i < n; i++)
+ if (_subscribedService is not null)
{
- double x = i * step;
- double amp = data[i] / 255d * maxAmplitude;
- double y = midline - amp;
- sb.Append(i == 0 ? 'M' : 'L');
- AppendPoint(sb, x, y);
+ _subscribedService.StateChanged -= OnPlayerStateChanged;
+ _subscribedService = null;
}
- // Bottom edge, right to left (mirror).
- for (int i = n - 1; i >= 0; i--)
+ if (_handle is not null)
{
- double x = i * step;
- double amp = data[i] / 255d * maxAmplitude;
- double y = midline + amp;
- sb.Append('L');
- AppendPoint(sb, x, y);
+ try { await _handle.InvokeVoidAsync("dispose"); } catch (JSDisconnectedException) { }
+ try { await _handle.DisposeAsync(); } catch (JSDisconnectedException) { }
+ _handle = null;
}
- sb.Append('Z');
- return sb.ToString();
- }
-
- private static void AppendPoint(StringBuilder sb, double x, double y)
- {
- sb.Append(x.ToString("0.##", CultureInfo.InvariantCulture));
- sb.Append(' ');
- sb.Append(y.ToString("0.##", CultureInfo.InvariantCulture));
- sb.Append(' ');
+ if (_module is not null)
+ {
+ try { await _module.DisposeAsync(); } catch (JSDisconnectedException) { }
+ _module = null;
+ }
}
}
diff --git a/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor.css b/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor.css
index f1ba9dd..4572c29 100644
--- a/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor.css
+++ b/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor.css
@@ -1,31 +1,39 @@
-/* Full-viewport fixed backdrop. Sits behind page content (negative-ish z-index within the
- detail layout) and never intercepts pointer events until click-to-seek ships. */
+/* Full-viewport fixed backdrop. Sits behind the detail content (.mix-detail-foreground is z-index:1)
+ and never intercepts pointer events — except the zoom slider, which re-enables them on itself. */
.mix-waveform-bg {
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
overflow: hidden;
- display: flex;
- align-items: center;
}
-.mix-waveform-bg--empty {
- /* No datum: nothing to draw. Kept as a hook for a future flat-line fallback. */
-}
-
-.mix-waveform-svg {
+/* The canvas fills the viewport. The glassy/frosted treatment is a CSS backdrop-blur on this layer
+ (the ribbon's luminous depth is drawn inside the canvas by the module); together they read as lit
+ glass moving behind the content rather than a hard chart. */
+.mix-waveform-canvas {
+ position: absolute;
+ inset: 0;
width: 100%;
- height: 60vh;
- margin: auto 0;
- opacity: 0.18;
+ height: 100%;
+ display: block;
+ backdrop-filter: blur(2px);
+ -webkit-backdrop-filter: blur(2px);
}
-/* Native SVG elements — scoped CSS stamps these directly, no ::deep needed. */
-.mix-waveform-fill {
- fill: var(--mud-palette-text-secondary);
+/* Zoom slider — a small viewing control pinned to the bottom-right. Pointer events are re-enabled
+ here only (the backdrop stays inert), and it is never a seek surface. */
+.mix-waveform-zoom {
+ position: absolute;
+ right: 1.5rem;
+ bottom: 1.5rem;
+ width: 180px;
+ max-width: 40vw;
+ pointer-events: auto;
+ opacity: 0.7;
+ transition: opacity 0.2s ease;
}
-.mix-waveform-played {
- fill: var(--mud-palette-primary);
+.mix-waveform-zoom:hover {
+ opacity: 1;
}
diff --git a/DeepDrftPublic.Client/Controls/MixZoomMapping.cs b/DeepDrftPublic.Client/Controls/MixZoomMapping.cs
new file mode 100644
index 0000000..c23d7f4
--- /dev/null
+++ b/DeepDrftPublic.Client/Controls/MixZoomMapping.cs
@@ -0,0 +1,36 @@
+namespace DeepDrftPublic.Client.Controls;
+
+///
+/// Pure mapping between the Mix visualizer's zoom slider position [0, 1] and the visible time-span in
+/// seconds. The span range is wide (0.333 s … 30 s, ~90×), so the mapping is logarithmic — equal
+/// slider travel changes the span by an equal *ratio*, which feels even to the hand. Slider
+/// orientation: fraction 0 = most zoomed-out (longest span), fraction 1 = most zoomed-in (the
+/// 0.333 s quarter-note-@-180-BPM anchor). Extracted from the component so the math is unit-testable.
+///
+public static class MixZoomMapping
+{
+ /// Shortest span (max zoom): one quarter note at 180 BPM = 60/180 s. Hard anchor.
+ public const double MinVisibleSeconds = 60.0 / 180.0;
+
+ /// Longest span (min zoom). Tunable.
+ public const double MaxVisibleSeconds = 30.0;
+
+ /// Slider position [0, 1] -> visible seconds. 0 = zoomed out, 1 = zoomed in.
+ public static double FractionToSeconds(double fraction)
+ {
+ fraction = Math.Clamp(fraction, 0, 1);
+ var logMax = Math.Log(MaxVisibleSeconds);
+ var logMin = Math.Log(MinVisibleSeconds);
+ // Interpolate in log space from out (frac 0 -> logMax) to in (frac 1 -> logMin).
+ return Math.Exp(logMax + (logMin - logMax) * fraction);
+ }
+
+ /// Visible seconds -> slider position [0, 1]. Inverse of .
+ public static double SecondsToFraction(double seconds)
+ {
+ seconds = Math.Clamp(seconds, MinVisibleSeconds, MaxVisibleSeconds);
+ var logMax = Math.Log(MaxVisibleSeconds);
+ var logMin = Math.Log(MinVisibleSeconds);
+ return (logMax - Math.Log(seconds)) / (logMax - logMin);
+ }
+}
diff --git a/DeepDrftPublic.Client/Pages/MixDetail.razor b/DeepDrftPublic.Client/Pages/MixDetail.razor
index f8d44ae..a53db34 100644
--- a/DeepDrftPublic.Client/Pages/MixDetail.razor
+++ b/DeepDrftPublic.Client/Pages/MixDetail.razor
@@ -35,8 +35,9 @@ else
var hasDate = release.ReleaseDate is not null;
@* Full-page waveform sits behind the scaffold content. The scaffold's container is positioned
- above it via the mix-detail-foreground stacking context. *@
-
+ above it via the mix-detail-foreground stacking context. TrackId lets the visualizer couple to
+ playback only when the player is on this mix's track. *@
+
+/// Holds the Mix visualizer's zoom (visible time-span in seconds) for the lifetime of the WASM app
+/// instance. Scoped in DI, so it lives across SPA navigations within one listening session — open a
+/// second mix and the slider keeps where you left it — but a fresh page load (F5) constructs a new
+/// instance, resetting to the default. That matches the spec's "persist within session, reset on
+/// fresh load" without any cookie/localStorage round-trip (see phase-9-mix-visualizer-redesign §B).
+///
+public sealed class MixVisualizerZoomState
+{
+ ///
+ /// Default opening window. Mirrors DEFAULT_VISIBLE_SECONDS in MixVisualizer.ts; keep the
+ /// two in sync (the TS owns the rendering anchors, this owns the C#-side session default).
+ ///
+ public const double DefaultVisibleSeconds = 10.0;
+
+ /// Visible time-span in seconds. Survives navigation; resets on fresh page load.
+ public double VisibleSeconds { get; set; } = DefaultVisibleSeconds;
+}
diff --git a/DeepDrftPublic.Client/Startup.cs b/DeepDrftPublic.Client/Startup.cs
index 5b3dab6..80d245e 100644
--- a/DeepDrftPublic.Client/Startup.cs
+++ b/DeepDrftPublic.Client/Startup.cs
@@ -26,6 +26,10 @@ public static class Startup
services.AddScoped();
services.AddScoped();
services.AddScoped();
+
+ // Mix visualizer zoom — scoped so it persists across navigation within a session and
+ // resets on a fresh page load (see MixVisualizerZoomState).
+ services.AddScoped();
}
public static void ConfigureApiHttpClient(IServiceCollection services, string baseAddress)
diff --git a/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts b/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts
new file mode 100644
index 0000000..de0d33f
--- /dev/null
+++ b/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts
@@ -0,0 +1,366 @@
+/**
+ * MixVisualizer — the scrolling Mix waveform background (Phase 9, 8.K Wave 2).
+ *
+ * What this renders: a *windowed* slice of a mix's loudness profile, scrolling
+ * bottom-to-top, coupled to playback position. New audio enters at the bottom,
+ * already-played audio exits off the top, and the "now" playhead sits at a fixed
+ * line (vertical centre by default). This is a read-only, ambient lava-lamp
+ * background — there is no seek, no click handling, no write-back to playback.
+ *
+ * Rendering tech: HTML5 Canvas 2D. This is the industry-standard, well-documented
+ * choice for a single flowing gradient waveform: createLinearGradient gives us the
+ * theme gradient directly, and layered translucent draws give the glassy look
+ * without any exotic tricks. Canvas 2D holds 60fps comfortably here because each
+ * frame draws one filled path of a few hundred points, not a per-pixel shader.
+ * (If this ever fails the 60fps budget at the glassy treatment, the textbook next
+ * step is WebGL — but we are nowhere near needing it, so we stay on Canvas 2D.)
+ *
+ * The Blazor component owns the canvas element and the inputs (datum, playback,
+ * zoom, theme); this module owns the requestAnimationFrame loop and all the
+ * drawing/scroll/zoom math. The component drives it through the small handle
+ * returned by `create`.
+ */
+
+// ── Tuning anchors (see spec §B). These are the load-bearing constants. ──────────
+
+/**
+ * Hard anchor: at maximum zoom the window shows exactly one quarter note at
+ * 180 BPM = 60 / 180 s = 0.333 s of audio, top to bottom. This is a fixed
+ * requirement, not a tunable.
+ */
+export const MIN_VISIBLE_SECONDS = 60 / 180; // 0.3333… s — quarter note @ 180 BPM
+
+/** Slow end of the zoom range — how much of the mix is visible at minimum zoom. Tunable. */
+export const MAX_VISIBLE_SECONDS = 30;
+
+/** Default opening window when a mix is first opened. Tunable. */
+export const DEFAULT_VISIBLE_SECONDS = 10;
+
+/**
+ * Where the "now" line sits within the window, as a fraction from the top.
+ * 0.5 = vertical centre (default): a short lead-in below, a short trail-out above.
+ * Tunable.
+ */
+const NOW_ANCHOR_FROM_TOP = 0.5;
+
+/** Background opacity of the whole ribbon — keeps it a backdrop, not a chart. */
+const RIBBON_OPACITY = 0.22;
+
+// ── Theme: the gradient stop colours, read live from the active MudBlazor palette. ─
+//
+// We do NOT take colours from Blazor: canvas createLinearGradient stop colours must be concrete
+// CSS colour strings (it does not resolve `var(--…)`). Instead the module reads the computed
+// `--mud-palette-*` custom properties straight off the canvas element, which inherits them from the
+// page. The bespoke light/dark themes ("Charleston in the Day" / "Lowcountry Summer Nights") swap
+// those vars when dark mode toggles, so re-reading them re-themes the gradient with no reload. The
+// component just calls `refreshTheme()` after a dark-mode change.
+
+interface ResolvedTheme {
+ /** Colour at the "now" line (brightest). Concrete CSS colour. */
+ accent: string;
+ /** Colour at the window edges (dimmer). Concrete CSS colour. */
+ edge: string;
+}
+
+/** Read a CSS custom property off an element, falling back if it is empty/undefined. */
+function readVar(el: Element, name: string, fallback: string): string {
+ const v = getComputedStyle(el).getPropertyValue(name).trim();
+ return v.length > 0 ? v : fallback;
+}
+
+// ── Datum: the pre-downloaded loudness profile (spec §F). ────────────────────────
+
+interface Datum {
+ /** Loudness samples, each already normalized to [0, 1]. */
+ samples: Float32Array;
+ /** Total mix duration in seconds — needed to map time <-> sample index. */
+ durationSeconds: number;
+ /**
+ * samplesPerSecond = samples.length / durationSeconds. Wave 1 made the sample
+ * count duration-derived (~333/s), so this is NOT a fixed 2048/duration — we
+ * always compute it from the actual datum length.
+ */
+ samplesPerSecond: number;
+}
+
+interface Playback {
+ /** Current playback head in seconds. */
+ positionSeconds: number;
+ /** Whether audio is actively playing — gates the rAF loop so a paused mix stays cool. */
+ isPlaying: boolean;
+}
+
+export interface MixVisualizerHandle {
+ setDatum(samplesBase64: string, durationSeconds: number): void;
+ setPlayback(positionSeconds: number, isPlaying: boolean): void;
+ setZoom(visibleSeconds: number): void;
+ /** Re-read the palette CSS vars off the canvas (call after a dark-mode toggle). */
+ refreshTheme(): void;
+ dispose(): void;
+}
+
+/**
+ * Decode the base64 loudness datum (bytes [0,255]) into normalized [0,1] floats.
+ * Done once per datum, off the animation path.
+ */
+function decodeSamples(base64: string): Float32Array {
+ const binary = atob(base64);
+ const out = new Float32Array(binary.length);
+ for (let i = 0; i < binary.length; i++) {
+ out[i] = binary.charCodeAt(i) / 255; // [0,255] -> [0,1]
+ }
+ return out;
+}
+
+export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
+ const maybeCtx = canvas.getContext('2d');
+ if (!maybeCtx) {
+ // No 2D context (extremely old/headless engine): hand back a no-op handle so
+ // the component still functions as a plain backdrop.
+ return {
+ setDatum() {},
+ setPlayback() {},
+ setZoom() {},
+ refreshTheme() {},
+ dispose() {},
+ };
+ }
+ // Non-null binding so the closures below (draw/frame) keep the narrowing.
+ const ctx: CanvasRenderingContext2D = maybeCtx;
+
+ // ── Mutable state, fed by the component through the handle. ──────────────────
+ let datum: Datum | null = null;
+ let playback: Playback = { positionSeconds: 0, isPlaying: false };
+ let visibleSeconds = DEFAULT_VISIBLE_SECONDS;
+
+ /** Resolve the gradient stops from the live palette vars on the canvas. */
+ function readTheme(): ResolvedTheme {
+ return {
+ // Brightest stop at the "now" line — the bespoke themes' primary accent.
+ accent: readVar(canvas, '--mud-palette-primary', '#b08d57'),
+ // Dim stop at the edges — the surface/background colour so the ribbon fades into the page.
+ edge: readVar(canvas, '--mud-palette-surface', '#1a1a1a'),
+ };
+ }
+
+ let theme: ResolvedTheme = readTheme();
+
+ let rafId: number | null = null;
+ let disposed = false;
+
+ // Backing-store size in device pixels, tracked so we only resize the canvas
+ // (which clears it) when the CSS box actually changed.
+ let cssWidth = 0;
+ let cssHeight = 0;
+ let dpr = 1;
+
+ /**
+ * Sync the canvas backing store to its CSS size × devicePixelRatio so the draw
+ * is crisp on HiDPI without blurring. Returns true if a resize happened.
+ */
+ function syncCanvasSize(): boolean {
+ const rect = canvas.getBoundingClientRect();
+ const nextDpr = window.devicePixelRatio || 1;
+ // Cap DPR at 2: beyond that the extra pixels cost frame time for no visible
+ // gain on a soft glassy backdrop (graceful-degrade lever, spec §E).
+ const effectiveDpr = Math.min(nextDpr, 2);
+ if (rect.width === cssWidth && rect.height === cssHeight && effectiveDpr === dpr) {
+ return false;
+ }
+ cssWidth = rect.width;
+ cssHeight = rect.height;
+ dpr = effectiveDpr;
+ canvas.width = Math.max(1, Math.round(cssWidth * dpr));
+ canvas.height = Math.max(1, Math.round(cssHeight * dpr));
+ return true;
+ }
+
+ /**
+ * THE SCROLL + ZOOM MATH (spec §A, §B). Read this top to bottom to follow how
+ * a quarter-note-@-180-BPM becomes 0.333 s becomes N samples becomes pixels.
+ *
+ * Coordinate model:
+ * - The canvas is `cssHeight` px tall (we draw in CSS px; the ctx is scaled by
+ * dpr so 1 unit == 1 CSS px).
+ * - The "now" line is a fixed screen Y: nowY = cssHeight * NOW_ANCHOR_FROM_TOP.
+ * - Audio flows UP: time increases downward in the data, but newer audio is
+ * drawn lower and scrolls up past the now line. So:
+ * * audio BELOW the now line (screen Y > nowY) is the lead-in (not yet played)
+ * * audio ABOVE the now line (screen Y < nowY) is the trail-out (just played)
+ *
+ * Zoom -> time-span -> pixels:
+ * - `visibleSeconds` is the entire window's time span, top to bottom. At max
+ * zoom this is MIN_VISIBLE_SECONDS (0.333 s); at min zoom MAX_VISIBLE_SECONDS.
+ * - pixelsPerSecond = cssHeight / visibleSeconds. Smaller visibleSeconds =>
+ * more px per second => the same audio sweeps the window faster at a fixed
+ * playback rate. That IS the Guitar-Hero coupling: apparent scroll speed
+ * falls straight out of the zoom, with no separate speed control.
+ *
+ * Time at a given screen Y:
+ * - At nowY the time is playback.positionSeconds.
+ * - Moving DOWN by 1 px adds (1 / pixelsPerSecond) seconds (future audio).
+ * - So: timeAt(y) = now + (y - nowY) / pixelsPerSecond
+ *
+ * Sample at a given time (spec §F mapping, BucketCount-driven, never fixed-2048):
+ * - sampleIndex = round(time * samplesPerSecond), where
+ * samplesPerSecond = datum.samples.length / datum.durationSeconds.
+ * - Out-of-range indices (before 0 or past the end) draw as zero amplitude,
+ * which is what gives the "scrolls in from empty / out to empty" behaviour
+ * at the very start and end of the mix (spec §A) with no special-casing.
+ */
+ function draw(): void {
+ const w = cssWidth;
+ const h = cssHeight;
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // draw in CSS px, crisp on HiDPI
+ ctx.clearRect(0, 0, w, h);
+
+ if (!datum || h <= 0 || w <= 0) return;
+
+ const now = playback.positionSeconds;
+ const nowY = h * NOW_ANCHOR_FROM_TOP;
+ const pixelsPerSecond = h / visibleSeconds;
+ const samplesPerSecond = datum.samplesPerSecond;
+ const sampleCount = datum.samples.length;
+
+ // We draw one screen row per pixel of height (a few hundred points) — smooth
+ // at every zoom with no stair-stepping, cheap enough for 60fps.
+ const centreX = w / 2;
+ // Max half-width of the ribbon (mirrored silhouette), with a small margin.
+ const maxHalfWidth = (w / 2) * 0.92;
+
+ // Build the mirrored closed path: down the right edge (top->bottom), then back
+ // up the left edge (bottom->top). amplitude maps loudness [0,1] to half-width.
+ ctx.beginPath();
+
+ // Right edge, top to bottom.
+ for (let y = 0; y <= h; y++) {
+ const t = now + (y - nowY) / pixelsPerSecond; // time at this screen row
+ const amp = sampleAt(t);
+ const x = centreX + amp * maxHalfWidth;
+ if (y === 0) ctx.moveTo(x, y);
+ else ctx.lineTo(x, y);
+ }
+ // Left edge, bottom to top (mirror).
+ for (let y = h; y >= 0; y--) {
+ const t = now + (y - nowY) / pixelsPerSecond;
+ const amp = sampleAt(t);
+ const x = centreX - amp * maxHalfWidth;
+ ctx.lineTo(x, y);
+ }
+ ctx.closePath();
+
+ // ── Glassy gradient fill (spec §C). ─────────────────────────────────────
+ // Vertical gradient brightest at the now line, dimming toward both edges —
+ // this is the optional luminosity cue that reinforces the playhead without a
+ // hard played/unplayed boundary. Stops come from the live MudBlazor palette.
+ const grad = ctx.createLinearGradient(0, 0, 0, h);
+ const nowStop = clamp01(nowY / h);
+ grad.addColorStop(0, theme.edge); // top edge (trail-out), dim
+ grad.addColorStop(nowStop, theme.accent); // the "now" line, brightest
+ grad.addColorStop(1, theme.edge); // bottom edge (lead-in), dim
+
+ // Two layered draws give the frosted/lit-glass depth: a soft wide glow under
+ // a crisper core, both translucent. No backdrop-filter needed on the canvas
+ // itself (the CSS layer adds blur); this is pure standard 2D compositing.
+ ctx.globalCompositeOperation = 'source-over';
+
+ // Soft luminous halo.
+ ctx.globalAlpha = RIBBON_OPACITY * 0.6;
+ ctx.shadowColor = theme.accent;
+ ctx.shadowBlur = 24 * dpr;
+ ctx.fillStyle = grad;
+ ctx.fill();
+
+ // Crisp core on top, no shadow.
+ ctx.shadowBlur = 0;
+ ctx.globalAlpha = RIBBON_OPACITY;
+ ctx.fillStyle = grad;
+ ctx.fill();
+
+ ctx.globalAlpha = 1;
+ }
+
+ /** Loudness at an absolute mix time, or 0 outside the mix (drives scroll-in/out from empty). */
+ function sampleAt(timeSeconds: number): number {
+ if (!datum) return 0;
+ if (timeSeconds < 0 || timeSeconds >= datum.durationSeconds) return 0;
+ const idx = Math.round(timeSeconds * datum.samplesPerSecond);
+ if (idx < 0 || idx >= datum.samples.length) return 0;
+ return datum.samples[idx];
+ }
+
+ function clamp01(v: number): number {
+ return v < 0 ? 0 : v > 1 ? 1 : v;
+ }
+
+ /**
+ * The animation loop. We always keep ONE rAF scheduled while not disposed so the
+ * canvas stays correctly sized and a single still slice is shown when paused —
+ * but we only redraw the moving content while playing. A backgrounded tab gets
+ * rAF throttled by the browser automatically (spec §E "cool idle"); on top of
+ * that we skip the expensive redraw when not playing, so a paused/foregrounded
+ * mix also stays cheap.
+ */
+ let lastDrewWhilePaused = false;
+ function frame(): void {
+ if (disposed) return;
+
+ const resized = syncCanvasSize();
+
+ if (playback.isPlaying) {
+ // Playback position is pushed in from Blazor each tick; redraw every frame
+ // so the scroll is smooth between ticks (position is interpolated upstream).
+ draw();
+ lastDrewWhilePaused = false;
+ } else if (resized || !lastDrewWhilePaused) {
+ // Paused/stopped: draw the still slice once (and again only if the canvas
+ // resized). Holding the scroll on pause falls out of position being held.
+ draw();
+ lastDrewWhilePaused = true;
+ }
+
+ rafId = requestAnimationFrame(frame);
+ }
+
+ rafId = requestAnimationFrame(frame);
+
+ return {
+ setDatum(samplesBase64: string, durationSeconds: number): void {
+ if (durationSeconds <= 0 || !samplesBase64) {
+ datum = null;
+ return;
+ }
+ const samples = decodeSamples(samplesBase64);
+ datum = {
+ samples,
+ durationSeconds,
+ // samplesPerSecond from the ACTUAL datum length — never assume 2048.
+ samplesPerSecond: samples.length / durationSeconds,
+ };
+ lastDrewWhilePaused = false; // force a repaint of the new datum
+ },
+
+ setPlayback(positionSeconds: number, isPlaying: boolean): void {
+ playback = { positionSeconds, isPlaying };
+ },
+
+ setZoom(seconds: number): void {
+ // Clamp into the supported span so a stray value can't break the math.
+ visibleSeconds = Math.min(MAX_VISIBLE_SECONDS, Math.max(MIN_VISIBLE_SECONDS, seconds));
+ lastDrewWhilePaused = false; // zoom changed the still slice — repaint
+ },
+
+ refreshTheme(): void {
+ theme = readTheme();
+ lastDrewWhilePaused = false; // re-theme is visible immediately, even when paused
+ },
+
+ dispose(): void {
+ disposed = true;
+ if (rafId !== null) {
+ cancelAnimationFrame(rafId);
+ rafId = null;
+ }
+ },
+ };
+}