feat(public): scrolling Canvas 2D Mix visualizer — windowed, playback-coupled, zoomable, read-only (8.K W2)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user