fix(visualizer): lay Mix datum across a 2-D R8 texture to respect GL_MAX_TEXTURE_SIZE; manual texelFetch lerp avoids row-wrap seam

This commit is contained in:
daniel-c-harvey
2026-06-15 19:28:52 -04:00
parent 61d53dacff
commit 45bf5e5d37
2 changed files with 160 additions and 25 deletions
@@ -54,6 +54,18 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable
/// </summary>
[Parameter] public double PlaybackPosition { get; set; }
// Bridge-level diagnostics. Mirrors the JS-side DEBUG flag in MixVisualizer.ts: when true the
// datum-fetch / subscription / playback-coupling seams log to the browser console (prefixed
// `[MixVisualizer]`, same as the JS logs so the two interleave into one timeline). These pinpoint
// which upstream link is broken when the ribbon stays blank — set false once confirmed healthy.
private const bool Debug = true;
private const string Tag = "[MixVisualizer]";
private static void DebugLog(string message)
{
if (Debug) Console.WriteLine($"{Tag} {message}");
}
private ElementReference _canvas;
private IJSObjectReference? _module;
private IJSObjectReference? _handle;
@@ -81,12 +93,19 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable
protected override async Task OnParametersSetAsync()
{
// Subscribe to the player's multicast side-channel once, to re-render on position/play ticks.
// Log whether the cascade is even present: a null PlayerService here means the visualizer
// never couples to playback (no StateChanged events ever reach OnPlayerStateChanged).
if (PlayerService is not null && !ReferenceEquals(PlayerService, _subscribedService))
{
if (_subscribedService is not null)
_subscribedService.StateChanged -= OnPlayerStateChanged;
PlayerService.StateChanged += OnPlayerStateChanged;
_subscribedService = PlayerService;
DebugLog($"subscribed to player StateChanged. ReleaseId={ReleaseId}, TrackId={TrackId?.ToString() ?? "null"}.");
}
else if (PlayerService is null)
{
DebugLog($"NO player cascade — playback will never couple. ReleaseId={ReleaseId}, TrackId={TrackId?.ToString() ?? "null"}.");
}
// ReleaseId is the only fetch input; fetch once per id. Position/zoom/theme changes re-render
@@ -95,11 +114,13 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable
if (_loadedReleaseId == ReleaseId) return;
_loadedReleaseId = ReleaseId;
DebugLog($"fetching mix waveform datum for ReleaseId={ReleaseId}…");
var result = await ReleaseData.GetMixWaveform(ReleaseId);
if (result is { Success: true, Value: { } profile } && profile.BucketCount > 0 && profile.Data.Length > 0)
{
_profile = profile;
_hasDatum = true;
DebugLog($"datum fetch OK — {profile.BucketCount} buckets, base64 length {profile.Data.Length}.");
}
else
{
@@ -107,6 +128,9 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable
// renders its content over a plain background.
_profile = null;
_hasDatum = false;
DebugLog(result.Success
? $"datum fetch returned EMPTY/absent (no stored datum for ReleaseId={ReleaseId}) — backdrop stays blank."
: $"datum fetch FAILED ({result.GetMessage() ?? "unknown error"}) — backdrop stays blank.");
}
// Push the (possibly new) datum to the module if it is already created.
@@ -117,6 +141,10 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable
{
// 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).
// Log the gating inputs so a "ribbon never couples" failure shows exactly why: whether the
// player is on THIS track (IsActivePlayer), and what duration/position/play-state it reports.
var currentTrackId = PlayerService?.CurrentTrack is { } ct ? ct.Id.ToString() : "null";
DebugLog($"player StateChanged — IsActivePlayer={IsActivePlayer} (player.CurrentTrack.Id={currentTrackId}, TrackId={TrackId?.ToString() ?? "null"}), player.IsPlaying={PlayerService?.IsPlaying}, player.Duration={PlayerService?.Duration?.ToString("F2") ?? "null"}.");
await PushPlaybackAsync();
StateHasChanged();
});
@@ -173,11 +201,20 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable
if (ReferenceEquals(_profile, _pushedProfile) && haveDuration == _pushedWithDuration)
return;
if (!haveDuration)
{
// The most common stuck state: a datum is loaded but no positive player duration has
// arrived, so we cannot map samples↔time and push an empty datum. Spell out which half
// is missing so the broken link is unambiguous in the console.
DebugLog($"datum push deferred (empty) — profile={(_profile is null ? "null" : "loaded")}, playerDuration={PlayerDurationSeconds?.ToString("F2") ?? "null"} (needs IsActivePlayer + duration>0).");
}
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);
DebugLog($"datum push (REAL) — base64 length {_profile!.Data.Length}, duration {PlayerDurationSeconds!.Value:F2}s.");
await _handle.InvokeVoidAsync("setDatum", _profile.Data, PlayerDurationSeconds.Value);
}
else
{
@@ -190,12 +227,17 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable
private async Task PushPlaybackAsync()
{
if (_handle is null) return;
if (_handle is null)
{
DebugLog("PushPlayback skipped — module handle not created yet.");
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();
DebugLog($"setPlayback → position={CurrentPositionSeconds:F2}s, isPlaying={IsPlaying}.");
await _handle.InvokeVoidAsync("setPlayback", CurrentPositionSeconds, IsPlaying);
}