diff --git a/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor b/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor index c3eb75d..191045e 100644 --- a/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor +++ b/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor @@ -1,35 +1,28 @@ @namespace DeepDrftPublic.Client.Controls -@* Full-page background waveform for a Mix release. Deliberately NOT the player-bar peak-bar idiom - (SpectrumVisualizer / LevelMeterFab own that): this renders a single continuous mirrored - silhouette filling the viewport behind the detail content. Fetches its own datum from - api/release/{id}/mix/waveform. The played portion is washed with the progress overlay driven by - PlaybackPosition; the click-to-seek seam (OnSeek + bindable PlaybackPosition) is wired here for a - future wave even though click handling does not ship yet. *@ +@* Full-page scrolling Mix waveform background (Phase 9, 8.K). A windowed slice of the mix's loudness + datum scrolls bottom-to-top, coupled to playback; a zoom slider controls the visible time-span (and + so the apparent scroll speed, Guitar-Hero style). Strictly read-only: it self-fetches its datum from + ReleaseId, takes playback as one-way input only, and never seeks or writes back. The rAF loop and all + scroll/zoom/compositing math live in the MixVisualizer.ts interop module; this component is a thin + bridge that feeds it datum + playback + zoom + theme. Deliberately NOT the player-bar peak-bar idiom. *@ -
- @if (_profile is not null) +
+ + + @* Viewing control only — never a seek surface. Hidden until a datum is present. *@ + @if (_hasDatum) { - - - - - - - - @* Base silhouette. *@ - - - @* Played-portion wash: a full-height rect clipped to the silhouette, width tracking - PlaybackPosition. Clamped to [0, 1]. *@ - - +
+ +
}
diff --git a/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor.cs b/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor.cs index c886803..ee2c6c8 100644 --- a/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor.cs +++ b/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor.cs @@ -1,132 +1,270 @@ -using System.Globalization; -using System.Text; using DeepDrftModels.DTOs; +using DeepDrftPublic.Client.Common; using DeepDrftPublic.Client.Services; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Logging; +using Microsoft.JSInterop; namespace DeepDrftPublic.Client.Controls; /// -/// Renders a Mix release's stored loudness profile as a full-page background silhouette. Standalone -/// and reusable: give it a and it fetches its own datum. Visually distinct -/// from the player-bar spectrum/level idiom by design — this is a single continuous mirrored wave, -/// not discrete peak bars. +/// Full-page scrolling Mix waveform background. Standalone and reusable: give it a +/// and it fetches its own loudness datum. The rendering itself — a windowed, +/// bottom-to-top, playback-coupled scroll with a glassy theme-aware gradient — lives in the +/// MixVisualizer.ts interop module; this component is the bridge that feeds it datum, playback +/// position, zoom, and theme, and owns the module lifecycle. +/// +/// Strictly read-only (spec §D): no seek, no two-way write-back. is a +/// one-way input. The live playback signal on the Mix detail page comes from the cascaded player +/// service (which also supplies the mix duration needed for the time↔sample mapping); the +/// parameter is the composability fallback for hosts that have no +/// player cascade (e.g. an embed) and want to drive position themselves. /// -public partial class MixWaveformVisualizer : ComponentBase +public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable { [Inject] public required IReleaseDataService ReleaseData { get; set; } + [Inject] public required IJSRuntime JS { get; set; } + [Inject] public required MixVisualizerZoomState ZoomState { get; set; } [Inject] public required ILogger Logger { get; set; } + // Live playback + the mix duration come from the cascaded streaming player when present. The + // cascade is IsFixed, so we subscribe to its multicast StateChanged side-channel to learn about + // position/play-state ticks (same pattern as WaveformSeeker / SpectrumVisualizer). + [CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; } + + // Live dark-mode state. Toggling re-themes the gradient without a reload: the cascade re-renders + // us, and OnAfterRender pushes fresh palette colours into the module. + [CascadingParameter] public DarkModeSettings? DarkMode { get; set; } + /// The Mix release whose waveform datum to fetch and render. [Parameter] public required long ReleaseId { get; set; } /// - /// Normalized playback head in [0, 1]. Two-way bindable so a future click-to-seek can write back - /// through it; today it is read-only input that drives the played-portion wash. The seam exists - /// now so wiring click-to-seek later is a pure addition, not a signature change. + /// The id of this mix's playable track. Used to gate the cascaded player as the live source: we + /// only couple to playback when the player is on THIS track, so a different track playing + /// elsewhere leaves this backdrop at its at-rest slice instead of scrolling to the wrong audio. + /// Null leaves the visualizer in the at-rest state (no player coupling). + /// + [Parameter] public long? TrackId { get; set; } + + /// + /// Normalized playback head in [0, 1]. One-way input only — the component never writes back. + /// Used as the position source for hosts with no cascaded player (composability fallback); + /// when a matching player is cascaded, its live position takes precedence. /// [Parameter] public double PlaybackPosition { get; set; } - [Parameter] public EventCallback PlaybackPositionChanged { get; set; } + private ElementReference _canvas; + private IJSObjectReference? _module; + private IJSObjectReference? _handle; + + private IStreamingPlayerService? _subscribedService; + private WaveformProfileDto? _profile; + private long? _loadedReleaseId; + private bool _hasDatum; + + // The profile reference last sent to the module, plus whether it went with a real duration. + // Tracked so a per-tick playback push never re-decodes the (up to ~1.2 MB) datum in JS — we only + // push the datum when its identity or duration-availability actually changes. + private WaveformProfileDto? _pushedProfile; + private bool _pushedWithDuration; + + // Theme last pushed to the module, so we only re-push on an actual change. + private bool? _lastIsDark; /// - /// Fired when the user seeks by interacting with the waveform. Unused until click-to-seek ships; - /// present now to lock the seek seam into the public contract. + /// Slider position in [0, 1]. 0 = most zoomed-out (MaxVisibleSeconds), 1 = most zoomed-in + /// (MinVisibleSeconds). Derived from the session-persisted seconds via the log mapping below. /// - [Parameter] public EventCallback OnSeek { get; set; } - - // Fixed SVG coordinate width. The path is computed in this space, then stretched to the - // viewport via preserveAspectRatio="none". - private const int ViewBoxWidth = 1000; - - private readonly string _clipId = $"mix-wf-clip-{Guid.NewGuid():N}"; - - private WaveformProfileDto? _profile; - private string _silhouettePath = string.Empty; - private long? _loadedReleaseId; - - private double ClampedPosition => Math.Clamp(PlaybackPosition, 0d, 1d); + private double ZoomFraction => MixZoomMapping.SecondsToFraction(ZoomState.VisibleSeconds); protected override async Task OnParametersSetAsync() { - // ReleaseId is the only fetch input; fetch once per id. A PlaybackPosition update re-renders - // but must not refetch — and a release with no datum must not refetch either, so the guard - // keys on the fetched id, not on whether a profile came back. - if (_loadedReleaseId == ReleaseId) - return; + // Subscribe to the player's multicast side-channel once, to re-render on position/play ticks. + if (PlayerService is not null && !ReferenceEquals(PlayerService, _subscribedService)) + { + if (_subscribedService is not null) + _subscribedService.StateChanged -= OnPlayerStateChanged; + PlayerService.StateChanged += OnPlayerStateChanged; + _subscribedService = PlayerService; + } + // ReleaseId is the only fetch input; fetch once per id. Position/zoom/theme changes re-render + // but must not refetch, and a release with no datum must not refetch either — so the guard + // keys on the fetched id, not on whether a profile came back. + if (_loadedReleaseId == ReleaseId) return; _loadedReleaseId = ReleaseId; var result = await ReleaseData.GetMixWaveform(ReleaseId); - if (result is { Success: true, Value: { } profile } && profile.BucketCount > 0) + if (result is { Success: true, Value: { } profile } && profile.BucketCount > 0 && profile.Data.Length > 0) { _profile = profile; - try - { - _silhouettePath = BuildSilhouettePath(profile); - } - catch (Exception ex) - { - Logger.LogWarning(ex, "MixWaveformVisualizer: failed to decode waveform profile for release {ReleaseId}; rendering empty backdrop.", ReleaseId); - _profile = null; - _silhouettePath = string.Empty; - } + _hasDatum = true; } else { - // No datum (not generated yet, or not a Mix) — leave the background empty; the detail - // page still renders its content over a plain backdrop. + // No datum (not generated yet, or not a Mix) — empty backdrop; the detail page still + // renders its content over a plain background. _profile = null; - _silhouettePath = string.Empty; + _hasDatum = false; + } + + // Push the (possibly new) datum to the module if it is already created. + await PushDatumAsync(); + } + + private void OnPlayerStateChanged() => InvokeAsync(async () => + { + // Position/play-state changed: push it to the module (cheap; no re-fetch, no full re-render + // needed for the canvas itself, but StateHasChanged keeps the slider/visibility in sync). + await PushPlaybackAsync(); + StateHasChanged(); + }); + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + try + { + _module = await JS.InvokeAsync( + "import", "./js/visualizer/MixVisualizer.js"); + _handle = await _module.InvokeAsync("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..77ece8a --- /dev/null +++ b/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts @@ -0,0 +1,448 @@ +/** + * 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; + + // ── ResizeObserver: one-shot redraw when the container changes while idle. ──── + // + // While the rAF loop is running (playing), syncCanvasSize() catches resizes each + // frame. While idle (paused/stopped), we use a ResizeObserver instead — it fires + // only when the element actually changes size, which is far cheaper than a 60fps + // tick. On each observation we do a single one-shot redraw. + const resizeObserver = new ResizeObserver(() => { + if (!playback.isPlaying && !disposed) { + // Loop is not running; draw one still frame reflecting the new size. + redrawOnce(); + } + // If the loop IS running, syncCanvasSize() inside frame() will catch it next + // tick — no action needed here. + }); + resizeObserver.observe(canvas); + + /** + * 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; + } + + // ── rAF loop lifecycle (spec §E: cool when paused/backgrounded). ───────────── + // + // DESIGN: The loop runs ONLY while playing. When paused or stopped, no frames + // are scheduled. The still slice stays correct via one-shot redraws triggered by + // the handle methods (setZoom, refreshTheme, setDatum) and by ResizeObserver. + // + // Start/stop contract: + // startLoop() — schedules the first frame if not already running. Safe to call + // redundantly; the rafId guard prevents double-loops. + // stopLoop() — cancels any pending frame. The current frame callback will see + // playback.isPlaying === false and will NOT reschedule itself, so + // this is belt-and-suspenders for the dispose() path. + // redrawOnce() — draw one still frame synchronously (no rAF scheduling). Used + // by setZoom/refreshTheme/setDatum/ResizeObserver while idle. + + /** + * Draw one still frame immediately, without scheduling a new rAF. Syncs the + * canvas size first so zoom/theme/datum/resize changes are reflected correctly + * even when the loop is not running. + */ + function redrawOnce(): void { + if (disposed) return; + syncCanvasSize(); + draw(); + } + + /** + * Start the rAF loop. No-op if already running or disposed — the rafId guard + * ensures at most one loop is live at any time. + */ + function startLoop(): void { + if (disposed || rafId !== null) return; + rafId = requestAnimationFrame(frame); + } + + /** + * Stop the rAF loop. Safe to call when already stopped. + */ + function stopLoop(): void { + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + } + } + + /** + * The animation loop. Runs only while playing. Each frame: + * 1. Syncs the canvas backing-store size (cheap no-op when nothing changed). + * 2. Redraws the scrolling waveform with the current playback position. + * 3. Reschedules itself — unless playback has stopped since this frame was + * queued, in which case it draws one final still frame and exits the loop. + * + * A backgrounded tab gets rAF throttled by the browser automatically. On top of + * that, the loop does not run at all when paused — so a foregrounded-but-paused + * mix burns no rAF budget (spec §E acceptance criterion). + */ + function frame(): void { + if (disposed) { + rafId = null; + return; + } + + syncCanvasSize(); + draw(); + + if (playback.isPlaying) { + // Still playing — schedule the next frame. + rafId = requestAnimationFrame(frame); + } else { + // Playback stopped between the time this frame was queued and now. + // We already drew the final still frame above; exit the loop. + rafId = null; + } + } + + // Kick off one still frame on creation so the canvas is not blank while idle + // before the first play command arrives. + redrawOnce(); + + 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, + }; + // New datum changes what is drawn — refresh the still slice immediately. + // If playing, the running loop will pick it up on the next frame automatically. + if (!playback.isPlaying) redrawOnce(); + }, + + setPlayback(positionSeconds: number, isPlaying: boolean): void { + const wasPlaying = playback.isPlaying; + playback = { positionSeconds, isPlaying }; + + if (isPlaying && !wasPlaying) { + // Transition: paused/stopped → playing. Start the rAF loop. + startLoop(); + } else if (!isPlaying && wasPlaying) { + // Transition: playing → paused/stopped. The current in-flight frame + // will draw the final still position and exit the loop on its own + // (frame() checks playback.isPlaying before rescheduling). We do NOT + // call stopLoop() here — that would cancel the in-flight frame before + // it draws, leaving a stale or blank canvas. Let the frame run out. + } + // If isPlaying unchanged (position-only update), the running loop (if any) + // will redraw on the next frame automatically; no action needed. + }, + + 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)); + // Zoom changes the still slice — redraw immediately when idle. + // If playing, the running loop will pick up the new value on the next frame. + if (!playback.isPlaying) redrawOnce(); + }, + + refreshTheme(): void { + theme = readTheme(); + // Theme change is immediately visible — redraw the still slice when idle. + // If playing, the running loop will pick up the new theme on the next frame. + if (!playback.isPlaying) redrawOnce(); + }, + + dispose(): void { + disposed = true; + stopLoop(); + resizeObserver.disconnect(); + }, + }; +}