feat(public): scrolling Canvas 2D Mix visualizer — windowed, playback-coupled, zoomable, read-only (8.K W2)

This commit is contained in:
daniel-c-harvey
2026-06-14 18:20:32 -04:00
parent c608fa345a
commit 2d0a565765
8 changed files with 698 additions and 132 deletions
@@ -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. *@
<div class="mix-waveform-bg @(_profile is null ? "mix-waveform-bg--empty" : null)">
@if (_profile is not null)
<div class="mix-waveform-bg">
<canvas @ref="_canvas" class="mix-waveform-canvas"></canvas>
@* Viewing control only — never a seek surface. Hidden until a datum is present. *@
@if (_hasDatum)
{
<svg class="mix-waveform-svg"
viewBox="0 0 @ViewBoxWidth 100"
preserveAspectRatio="none"
role="img"
aria-label="Waveform">
<defs>
<clipPath id="@_clipId">
<path d="@_silhouettePath" />
</clipPath>
</defs>
@* Base silhouette. *@
<path d="@_silhouettePath" class="mix-waveform-fill" />
@* Played-portion wash: a full-height rect clipped to the silhouette, width tracking
PlaybackPosition. Clamped to [0, 1]. *@
<rect x="0" y="0"
width="@(ClampedPosition * ViewBoxWidth)" height="100"
class="mix-waveform-played"
clip-path="url(#@_clipId)" />
</svg>
<div class="mix-waveform-zoom">
<MudSlider T="double"
Value="@ZoomFraction"
ValueChanged="@OnZoomFractionChanged"
Min="0"
Max="1"
Step="0.001"
Size="Size.Small"
Color="Color.Primary"
aria-label="Waveform zoom" />
</div>
}
</div>
@@ -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;
/// <summary>
/// Renders a Mix release's stored loudness profile as a full-page background silhouette. Standalone
/// and reusable: give it a <see cref="ReleaseId"/> 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
/// <see cref="ReleaseId"/> 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. <see cref="PlaybackPosition"/> 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
/// <see cref="PlaybackPosition"/> parameter is the composability fallback for hosts that have no
/// player cascade (e.g. an embed) and want to drive position themselves.
/// </summary>
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<MixWaveformVisualizer> 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; }
/// <summary>The Mix release whose waveform datum to fetch and render.</summary>
[Parameter] public required long ReleaseId { get; set; }
/// <summary>
/// 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).
/// </summary>
[Parameter] public long? TrackId { get; set; }
/// <summary>
/// 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.
/// </summary>
[Parameter] public double PlaybackPosition { get; set; }
[Parameter] public EventCallback<double> 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;
/// <summary>
/// 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.
/// </summary>
[Parameter] public EventCallback<double> 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<IJSObjectReference>(
"import", "./js/visualizer/MixVisualizer.js");
_handle = await _module.InvokeAsync<IJSObjectReference>("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. ───────────────────────────
/// <summary>
/// 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.
/// </summary>
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. ─
/// <summary>True only when the cascaded player is loaded with THIS mix's track.</summary>
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;
}
}
}
@@ -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;
}
@@ -0,0 +1,36 @@
namespace DeepDrftPublic.Client.Controls;
/// <summary>
/// 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.
/// </summary>
public static class MixZoomMapping
{
/// <summary>Shortest span (max zoom): one quarter note at 180 BPM = 60/180 s. Hard anchor.</summary>
public const double MinVisibleSeconds = 60.0 / 180.0;
/// <summary>Longest span (min zoom). Tunable.</summary>
public const double MaxVisibleSeconds = 30.0;
/// <summary>Slider position [0, 1] -> visible seconds. 0 = zoomed out, 1 = zoomed in.</summary>
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);
}
/// <summary>Visible seconds -> slider position [0, 1]. Inverse of <see cref="FractionToSeconds"/>.</summary>
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);
}
}
+3 -2
View File
@@ -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. *@
<MixWaveformVisualizer ReleaseId="@release.Id" />
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. *@
<MixWaveformVisualizer ReleaseId="@release.Id" TrackId="@ViewModel.Track?.Id" />
<div class="mix-detail-foreground">
<ReleaseDetailScaffold Title="@release.Title"
@@ -0,0 +1,20 @@
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// 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).
/// </summary>
public sealed class MixVisualizerZoomState
{
/// <summary>
/// Default opening window. Mirrors <c>DEFAULT_VISIBLE_SECONDS</c> in MixVisualizer.ts; keep the
/// two in sync (the TS owns the rendering anchors, this owns the C#-side session default).
/// </summary>
public const double DefaultVisibleSeconds = 10.0;
/// <summary>Visible time-span in seconds. Survives navigation; resets on fresh page load.</summary>
public double VisibleSeconds { get; set; } = DefaultVisibleSeconds;
}
+4
View File
@@ -26,6 +26,10 @@ public static class Startup
services.AddScoped<ReleaseClient>();
services.AddScoped<IReleaseDataService, ReleaseClientDataService>();
services.AddScoped<ReleaseDetailViewModel>();
// Mix visualizer zoom — scoped so it persists across navigation within a session and
// resets on a fresh page load (see MixVisualizerZoomState).
services.AddScoped<MixVisualizerZoomState>();
}
public static void ConfigureApiHttpClient(IServiceCollection services, string baseAddress)