Merge p12-w2-t1-track-fetch into dev (12.B2: track-cardinal high-res waveform fetch + bridge rewire)

This commit is contained in:
daniel-c-harvey
2026-06-17 11:22:25 -04:00
13 changed files with 216 additions and 74 deletions
+11 -6
View File
@@ -67,12 +67,17 @@ public class ReleaseController : ControllerBase
}
// GET api/release/{entryKey}/mix/waveform (unauthenticated)
// Serves the high-res waveform datum for a Mix release as base64. Mirrors GET api/track/{id}/waveform
// but reads the Mix's track datum from the track-waveforms vault. 404 when the release is not a Mix,
// carries no waveform key,
// or no datum is stored. Public read — addresses by the opaque EntryKey, not the int PK (§3e). The
// {entryKey} string segment cannot collide with the ApiKey-gated POST {id:long}/mix/waveform (different
// verb + constraint). Declared before the shorter "{entryKey}" route for clarity.
// Serves the high-res waveform datum for a Mix release as base64, reading the Mix's track datum from
// the track-waveforms vault. 404 when the release is not a Mix, carries no waveform key, or no datum
// is stored. Public read — addresses by the opaque EntryKey, not the int PK (§3e). The {entryKey}
// string segment cannot collide with the ApiKey-gated POST {id:long}/mix/waveform (different verb +
// constraint). Declared before the shorter "{entryKey}" route for clarity.
//
// LEGACY (phase-12 §5b): the visualizer no longer fetches through this release-addressed route — it
// resolves the current track's datum via the track-cardinal GET api/track/{trackEntryKey}/waveform/
// high-res. This endpoint is retained as a thin transitional delegate (it serves the identical datum,
// since a Mix is single-track) and has no client caller today; remove it once nothing depends on the
// release-addressed shape.
[HttpGet("{entryKey}/mix/waveform")]
public async Task<ActionResult> GetMixWaveform(string entryKey, CancellationToken ct = default)
{
@@ -577,6 +577,31 @@ public class TrackController : ControllerBase
});
}
// GET api/track/{trackId}/waveform/high-res (unauthenticated)
// Track-cardinal high-res datum fetch (phase-12 §5b): returns the per-track high-res waveform datum
// from the track-waveforms vault, base64-encoded, keyed by EntryKey. This is what the lava visualizer
// fetches for whatever track is currently playing/selected — the release is only addressing context.
// Distinct from GET {trackId}/waveform (the 512-bucket player-bar profile in the default vault): the
// "high-res" suffix selects the duration-derived TrackWaveforms datum. 404 when no high-res datum is
// stored (a track not yet backfilled — the visualizer blanks gracefully). Declared before the
// parameterized PUT "{trackId}" route so the literal "waveform/high-res" segment wins.
[HttpGet("{trackId}/waveform/high-res")]
public async Task<ActionResult> GetHighResWaveform(string trackId)
{
var bytes = await _waveformProfileService.GetProfileAsync(trackId, VaultConstants.TrackWaveforms);
if (bytes is null)
{
_logger.LogInformation("No high-res waveform datum for track: {TrackId}", trackId);
return NotFound();
}
return Ok(new WaveformProfileDto
{
BucketCount = bytes.Length,
Data = Convert.ToBase64String(bytes),
});
}
// POST api/track/{trackId}/waveform ([ApiKeyAuthorize])
// Admin backfill: compute and store a waveform profile for an existing track from its vault
// audio. trackId is the EntryKey. 404 when no audio is stored under that key; 500 when the
@@ -105,8 +105,9 @@ public class UnifiedReleaseService
/// <see cref="WaveformResolution"/>), store it in the TrackWaveforms vault under the track's
/// EntryKey, then point the release's Mix satellite at that same key. The datum key equals the
/// track's EntryKey — the Mix is single-track. Under the per-track model (phase-12 §5) this is the
/// same datum every track now carries; the Mix satellite link is kept so the existing public
/// <c>GET api/release/{entryKey}/mix/waveform</c> read path keeps resolving until 12.B2 rewires it.
/// same datum every track now carries. The visualizer fetches it via the track-cardinal
/// <c>GET api/track/{trackEntryKey}/waveform/high-res</c> (12.B2); the Mix satellite link and the
/// legacy release-addressed read path are retained transitionally and no longer feed the visualizer.
/// </summary>
public async Task<Result> TriggerMixWaveformAsync(long releaseId, CancellationToken ct)
{
@@ -83,27 +83,4 @@ public class ReleaseClient
? ApiResult<ReleaseDto>.CreatePassResult(release)
: ApiResult<ReleaseDto>.CreateFailResult("Failed to deserialize response");
}
/// <summary>
/// Fetches the high-res waveform datum for a Mix release, addressed by its public EntryKey. A 404
/// means no datum is stored (not yet generated, or not a Mix) — a valid state, so it returns a pass
/// result with a null value. Any other non-success status is a genuine failure.
/// </summary>
public async Task<ApiResult<WaveformProfileDto?>> GetMixWaveform(string entryKey)
{
var response = await _http.GetAsync($"api/release/{Uri.EscapeDataString(entryKey)}/mix/waveform");
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return ApiResult<WaveformProfileDto?>.CreatePassResult(null);
if (!response.IsSuccessStatusCode)
return ApiResult<WaveformProfileDto?>.CreateFailResult($"HTTP {(int)response.StatusCode}");
var json = await response.Content.ReadAsStringAsync();
var profile = JsonSerializer.Deserialize<WaveformProfileDto>(json, JsonOptions);
return profile is not null
? ApiResult<WaveformProfileDto?>.CreatePassResult(profile)
: ApiResult<WaveformProfileDto?>.CreateFailResult("Failed to deserialize response");
}
}
@@ -129,6 +129,33 @@ public class TrackClient
: ApiResult<List<GenreSummaryDto>>.CreateFailResult("Failed to deserialize response");
}
/// <summary>
/// Fetches the per-track high-res waveform datum, addressed by the track's EntryKey (phase-12 §5b).
/// A 404 means no high-res datum is stored (a track not yet backfilled) — a valid state, so it
/// returns a pass result with a null value and the visualizer blanks gracefully. Any other
/// non-success status is a genuine failure.
/// </summary>
public async Task<ApiResult<WaveformProfileDto?>> GetTrackWaveform(string trackEntryKey)
{
var response = await _http.GetAsync($"api/track/{Uri.EscapeDataString(trackEntryKey)}/waveform/high-res");
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return ApiResult<WaveformProfileDto?>.CreatePassResult(null);
if (!response.IsSuccessStatusCode)
return ApiResult<WaveformProfileDto?>.CreateFailResult($"HTTP {(int)response.StatusCode}");
var json = await response.Content.ReadAsStringAsync();
var profile = JsonSerializer.Deserialize<WaveformProfileDto>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
return profile is not null
? ApiResult<WaveformProfileDto?>.CreatePassResult(profile)
: ApiResult<WaveformProfileDto?>.CreateFailResult("Failed to deserialize response");
}
public async Task<ApiResult<TrackDto>> GetTrack(string entryKey)
{
var response = await _http.GetAsync($"api/track/meta/by-key/{Uri.EscapeDataString(entryKey)}");
@@ -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();
});
+5 -2
View File
@@ -35,8 +35,11 @@ else
@* Full-page waveform sits behind the scaffold content. The scaffold's container is positioned
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. *@
<WaveformVisualizer ReleaseEntryKey="@release.EntryKey" TrackId="@ViewModel.Track?.Id" />
playback only when the player is on this mix's track; TrackEntryKey is the datum to render at rest
(before playback) — the mix's single track, so the lava shows immediately on page load (§4). *@
<WaveformVisualizer ReleaseEntryKey="@release.EntryKey"
TrackId="@ViewModel.Track?.Id"
TrackEntryKey="@ViewModel.Track?.EntryKey" />
<div class="mix-detail-foreground">
<MudContainer MaxWidth="MaxWidth.Large" Class="mix-detail-container">
@@ -24,11 +24,4 @@ public interface IReleaseDataService
/// <summary>Single release resolved by its opaque public EntryKey, with both metadata satellites (nulls for non-matching media).</summary>
Task<ApiResult<ReleaseDto>> GetByEntryKey(string entryKey);
/// <summary>
/// The Mix waveform datum for a release addressed by its public EntryKey. Success with a value
/// when present; success with a null value when no datum is stored (a valid state, not a failure);
/// failure on any other transport error.
/// </summary>
Task<ApiResult<WaveformProfileDto?>> GetMixWaveform(string entryKey);
}
@@ -30,6 +30,14 @@ public interface ITrackDataService
Task<ApiResult<TrackDto>> GetTrack(string trackId);
/// <summary>
/// The per-track high-res waveform datum, addressed by the track's EntryKey (phase-12 §5b — the
/// datum is the track's; the release is only addressing context). Success with a value when a
/// high-res datum is stored; success with a null value when none is (a not-yet-backfilled track —
/// a valid state, not a failure, the visualizer blanks); failure on any other transport error.
/// </summary>
Task<ApiResult<WaveformProfileDto?>> GetTrackWaveform(string trackEntryKey);
/// <summary>
/// Fetches a random track from the public library for instant play. Success with a value on a
/// hit; success with a null value when the library is empty (a valid state, not a failure);
@@ -31,7 +31,4 @@ public class ReleaseClientDataService : IReleaseDataService
public Task<ApiResult<ReleaseDto>> GetByEntryKey(string entryKey)
=> _releaseClient.GetByEntryKey(entryKey);
public Task<ApiResult<WaveformProfileDto?>> GetMixWaveform(string entryKey)
=> _releaseClient.GetMixWaveform(entryKey);
}
@@ -39,6 +39,9 @@ public class TrackClientDataService : ITrackDataService
public Task<ApiResult<TrackDto>> GetTrack(string trackId)
=> _trackClient.GetTrack(trackId);
public Task<ApiResult<WaveformProfileDto?>> GetTrackWaveform(string trackEntryKey)
=> _trackClient.GetTrackWaveform(trackEntryKey);
public Task<ApiResult<TrackDto?>> GetRandomTrack()
=> _trackClient.GetRandom();
}
@@ -47,11 +47,6 @@ public class ReleaseProxyController : ControllerBase
return await RelayJson(query, "release list");
}
/// <summary>Proxies the Mix waveform datum, addressed by the release's opaque EntryKey. A 404 (no datum stored) passes through verbatim.</summary>
[HttpGet("{entryKey}/mix/waveform")]
public async Task<ActionResult> GetMixWaveform(string entryKey, CancellationToken ct = default)
=> await RelayJson($"api/release/{Uri.EscapeDataString(entryKey)}/mix/waveform", $"release {entryKey} mix waveform", ct);
/// <summary>Proxies a single release, addressed by its opaque EntryKey. A 404 (no such release) passes through verbatim.</summary>
[HttpGet("{entryKey}")]
public async Task<ActionResult> GetReleaseByEntryKey(string entryKey, CancellationToken ct = default)
@@ -319,4 +319,40 @@ public class TrackProxyController : ControllerBase
return Content(json, "application/json");
}
}
/// <summary>
/// Proxies a track's high-res waveform datum (JSON) from DeepDrftAPI — the per-track datum the lava
/// visualizer fetches for the current track (phase-12 §5b). Unauthenticated, same posture as the
/// 512-bucket profile forward above; the "high-res" suffix selects the TrackWaveforms datum. Small
/// JSON, buffered and relayed; a 404 (no high-res datum stored — track not yet backfilled) passes
/// through so the visualizer blanks gracefully.
/// </summary>
[HttpGet("{trackId}/waveform/high-res")]
public async Task<ActionResult> GetHighResWaveform(string trackId, CancellationToken ct = default)
{
var path = $"api/track/{Uri.EscapeDataString(trackId)}/waveform/high-res";
HttpResponseMessage upstream;
try
{
upstream = await _upstream.GetAsync(path, HttpCompletionOption.ResponseHeadersRead, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Upstream call to DeepDrftAPI track/{TrackId}/waveform/high-res failed", trackId);
return StatusCode(502, "Upstream unavailable");
}
using (upstream)
{
if (!upstream.IsSuccessStatusCode)
{
_logger.LogWarning("DeepDrftAPI track/{TrackId}/waveform/high-res returned {Status}", trackId, (int)upstream.StatusCode);
return StatusCode((int)upstream.StatusCode);
}
var json = await upstream.Content.ReadAsStringAsync(ct);
return Content(json, "application/json");
}
}
}