feat(p12-w2): track-cardinal high-res waveform fetch + bridge rewire

Add GET api/track/{trackEntryKey}/waveform/high-res (+ proxy), ITrackDataService.GetTrackWaveform; rewire visualizer to resolve the current track's EntryKey and re-fetch on track change. Retire the client mix-waveform read path.
This commit is contained in:
daniel-c-harvey
2026-06-17 11:12:26 -04:00
parent ec3989c354
commit a19a734757
13 changed files with 216 additions and 74 deletions
@@ -8,21 +8,24 @@ using Microsoft.JSInterop;
namespace DeepDrftPublic.Client.Controls;
/// <summary>
/// Full-page scrolling waveform background. Standalone and reusable: give it a
/// <see cref="ReleaseEntryKey"/> 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
/// WaveformVisualizer.ts interop module; this component is the bridge that feeds it datum, playback
/// position, zoom, and theme, and owns the module lifecycle.
/// Scrolling waveform visualizer, track-cardinal (phase-12 §4/§5). It renders the high-res loudness
/// datum of whatever track is currently playing/selected: the datum is the track's, not the release's,
/// so the fetch resolves the current track's <c>EntryKey</c> (the playing track when this is the active
/// player, else the host-supplied <see cref="TrackEntryKey"/>) and re-fetches when that track identity
/// changes — not when the release changes. The release (<see cref="ReleaseEntryKey"/>) is only addressing
/// context. The rendering itself — a windowed, bottom-to-top, playback-coupled scroll with a glassy
/// theme-aware gradient — lives in the WaveformVisualizer.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.
/// one-way input. The live playback signal comes from the cascaded player service (which also supplies
/// the track 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 WaveformVisualizer : ComponentBase, IAsyncDisposable
{
[Inject] public required IReleaseDataService ReleaseData { get; set; }
[Inject] public required ITrackDataService TrackData { get; set; }
[Inject] public required IJSRuntime JS { get; set; }
[Inject] public required WaveformVisualizerControlState ControlState { get; set; }
[Inject] public required ILogger<WaveformVisualizer> Logger { get; set; }
@@ -36,17 +39,30 @@ public partial class WaveformVisualizer : ComponentBase, IAsyncDisposable
// us, and OnAfterRender pushes fresh palette colours into the module.
[CascadingParameter] public DarkModeSettings? DarkMode { get; set; }
/// <summary>The opaque public EntryKey of the Mix release whose waveform datum to fetch and render.</summary>
/// <summary>
/// The opaque public EntryKey of the host release. Addressing context only (phase-12 §4) — the datum
/// is fetched per-track, not per-release. Carried for diagnostics and host identity; it no longer
/// drives the datum fetch.
/// </summary>
[Parameter] public required string ReleaseEntryKey { get; set; }
/// <summary>
/// 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).
/// The id of the host's selected/default 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 visualizer 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>
/// The EntryKey of the host's selected/default track — the datum to render when no matching player
/// is active (e.g. a Mix detail page at rest, before playback starts). When the cascaded player is on
/// this visualizer's track (<see cref="IsActivePlayer"/>), the playing track's EntryKey takes
/// precedence so the datum follows live playback (a multi-track Cut, the NowPlaying card). Null with
/// no active player leaves the visualizer blank.
/// </summary>
[Parameter] public string? TrackEntryKey { 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);
@@ -72,7 +88,13 @@ public partial class WaveformVisualizer : ComponentBase, IAsyncDisposable
private IStreamingPlayerService? _subscribedService;
private WaveformProfileDto? _profile;
private string? _loadedReleaseKey;
// The track EntryKey the loaded datum belongs to. The fetch-once guard keys on the current track's
// identity (phase-12 §4), not the release, so the datum re-fetches when the playing/selected track
// changes while the release stays fixed (a multi-track Cut, the NowPlaying card). Null until the
// first fetch; an in-flight fetch is tracked separately so concurrent ticks don't double-fetch.
private string? _loadedTrackKey;
private string? _fetchingTrackKey;
// Whether we are subscribed to the shared control state's Changed event. The controls row (a
// sibling component) mutates ControlState and raises Changed; we push the affected uniforms.
@@ -118,14 +140,60 @@ public partial class WaveformVisualizer : ComponentBase, IAsyncDisposable
DebugLog($"NO player cascade — playback will never couple. ReleaseEntryKey={ReleaseEntryKey}, TrackId={TrackId?.ToString() ?? "null"}.");
}
// ReleaseEntryKey is the only fetch input; fetch once per key. 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 key, not on whether a profile came back.
if (_loadedReleaseKey == ReleaseEntryKey) return;
_loadedReleaseKey = ReleaseEntryKey;
// Fetch the current track's datum if its identity changed since the last fetch (parameter set
// can change TrackEntryKey; the player side comes through OnPlayerStateChanged).
await EnsureDatumForCurrentTrackAsync();
}
/// <summary>
/// The EntryKey of the track whose datum to render: the live playing track when this visualizer is
/// the active player, otherwise the host's selected/default <see cref="TrackEntryKey"/>. This is the
/// single source of "which track's datum" — both the fetch key and what re-arms the fetch-once guard.
/// </summary>
private string? CurrentTrackKey =>
IsActivePlayer ? PlayerService!.CurrentTrack!.EntryKey : TrackEntryKey;
/// <summary>
/// Fetch the current track's high-res datum, but only when the track identity changed since the last
/// fetch (phase-12 §4 — the guard re-arms on track change, not release change). Idempotent and
/// re-entrancy-guarded: callable from both OnParametersSetAsync (TrackEntryKey changed) and
/// OnPlayerStateChanged (the playing track changed) without double-fetching. A track with no stored
/// datum leaves the visualizer blank; the guard keys on the fetched key, not on whether a datum came
/// back, so a not-yet-backfilled track does not refetch on every tick.
/// </summary>
private async Task EnsureDatumForCurrentTrackAsync()
{
var trackKey = CurrentTrackKey;
// Nothing to fetch (no active player and no selected track): clear any stale datum and disarm.
if (string.IsNullOrEmpty(trackKey))
{
if (_loadedTrackKey is not null || _profile is not null)
{
_loadedTrackKey = null;
_profile = null;
await PushDatumAsync();
}
return;
}
// Already loaded (or loading) this exact track — don't refetch.
if (trackKey == _loadedTrackKey || trackKey == _fetchingTrackKey) return;
_fetchingTrackKey = trackKey;
DebugLog($"fetching high-res waveform datum for trackEntryKey={trackKey}…");
var result = await TrackData.GetTrackWaveform(trackKey);
// The current track may have advanced again while this fetch was in flight; if so, discard this
// result and let the newer track's fetch (already armed via _fetchingTrackKey) win.
if (_fetchingTrackKey != trackKey)
{
DebugLog($"discarding stale datum fetch for trackEntryKey={trackKey} — current track moved on.");
return;
}
_fetchingTrackKey = null;
_loadedTrackKey = trackKey;
DebugLog($"fetching mix waveform datum for ReleaseEntryKey={ReleaseEntryKey}…");
var result = await ReleaseData.GetMixWaveform(ReleaseEntryKey);
if (result is { Success: true, Value: { } profile } && profile.BucketCount > 0 && profile.Data.Length > 0)
{
_profile = profile;
@@ -133,12 +201,12 @@ public partial class WaveformVisualizer : ComponentBase, IAsyncDisposable
}
else
{
// No datum (not generated yet, or not a Mix) — empty backdrop; the detail page still
// No datum (track not yet backfilled, or transport error) — blank visualizer; the host still
// renders its content over a plain background.
_profile = null;
DebugLog(result.Success
? $"datum fetch returned EMPTY/absent (no stored datum for ReleaseEntryKey={ReleaseEntryKey}) — backdrop stays blank."
: $"datum fetch FAILED ({result.GetMessage() ?? "unknown error"}) — backdrop stays blank.");
? $"datum fetch returned EMPTY/absent (no stored high-res datum for trackEntryKey={trackKey}) — visualizer stays blank."
: $"datum fetch FAILED ({result.GetMessage() ?? "unknown error"}) — visualizer stays blank.");
}
// Push the (possibly new) datum to the module if it is already created.
@@ -153,6 +221,10 @@ public partial class WaveformVisualizer : ComponentBase, IAsyncDisposable
// 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"}.");
// The playing track may have changed (a multi-track Cut advancing, the NowPlaying card following
// playback) — re-fetch its datum if so. EnsureDatumForCurrentTrackAsync is guarded, so a tick that
// didn't change the track is a cheap no-op.
await EnsureDatumForCurrentTrackAsync();
await PushPlaybackAsync();
StateHasChanged();
});