From a19a734757fdc2ab4b803a3695a1988bf4620260 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Wed, 17 Jun 2026 11:12:26 -0400 Subject: [PATCH] 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. --- DeepDrftAPI/Controllers/ReleaseController.cs | 17 ++- DeepDrftAPI/Controllers/TrackController.cs | 25 ++++ DeepDrftAPI/Services/UnifiedReleaseService.cs | 5 +- .../Clients/ReleaseClient.cs | 23 ---- DeepDrftPublic.Client/Clients/TrackClient.cs | 27 ++++ .../Controls/WaveformVisualizer.razor.cs | 124 ++++++++++++++---- DeepDrftPublic.Client/Pages/MixDetail.razor | 7 +- .../Services/IReleaseDataService.cs | 7 - .../Services/ITrackDataService.cs | 8 ++ .../Services/ReleaseClientDataService.cs | 3 - .../Services/TrackClientDataService.cs | 3 + .../Controllers/ReleaseProxyController.cs | 5 - .../Controllers/TrackProxyController.cs | 36 +++++ 13 files changed, 216 insertions(+), 74 deletions(-) diff --git a/DeepDrftAPI/Controllers/ReleaseController.cs b/DeepDrftAPI/Controllers/ReleaseController.cs index d963e1a..caa887f 100644 --- a/DeepDrftAPI/Controllers/ReleaseController.cs +++ b/DeepDrftAPI/Controllers/ReleaseController.cs @@ -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 GetMixWaveform(string entryKey, CancellationToken ct = default) { diff --git a/DeepDrftAPI/Controllers/TrackController.cs b/DeepDrftAPI/Controllers/TrackController.cs index cfb1985..e70907e 100644 --- a/DeepDrftAPI/Controllers/TrackController.cs +++ b/DeepDrftAPI/Controllers/TrackController.cs @@ -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 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 diff --git a/DeepDrftAPI/Services/UnifiedReleaseService.cs b/DeepDrftAPI/Services/UnifiedReleaseService.cs index 5db1d48..93ff858 100644 --- a/DeepDrftAPI/Services/UnifiedReleaseService.cs +++ b/DeepDrftAPI/Services/UnifiedReleaseService.cs @@ -105,8 +105,9 @@ public class UnifiedReleaseService /// ), 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 - /// GET api/release/{entryKey}/mix/waveform read path keeps resolving until 12.B2 rewires it. + /// same datum every track now carries. The visualizer fetches it via the track-cardinal + /// GET api/track/{trackEntryKey}/waveform/high-res (12.B2); the Mix satellite link and the + /// legacy release-addressed read path are retained transitionally and no longer feed the visualizer. /// public async Task TriggerMixWaveformAsync(long releaseId, CancellationToken ct) { diff --git a/DeepDrftPublic.Client/Clients/ReleaseClient.cs b/DeepDrftPublic.Client/Clients/ReleaseClient.cs index 515f1c1..6b853b0 100644 --- a/DeepDrftPublic.Client/Clients/ReleaseClient.cs +++ b/DeepDrftPublic.Client/Clients/ReleaseClient.cs @@ -83,27 +83,4 @@ public class ReleaseClient ? ApiResult.CreatePassResult(release) : ApiResult.CreateFailResult("Failed to deserialize response"); } - - /// - /// 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. - /// - public async Task> GetMixWaveform(string entryKey) - { - var response = await _http.GetAsync($"api/release/{Uri.EscapeDataString(entryKey)}/mix/waveform"); - - if (response.StatusCode == System.Net.HttpStatusCode.NotFound) - return ApiResult.CreatePassResult(null); - - if (!response.IsSuccessStatusCode) - return ApiResult.CreateFailResult($"HTTP {(int)response.StatusCode}"); - - var json = await response.Content.ReadAsStringAsync(); - var profile = JsonSerializer.Deserialize(json, JsonOptions); - - return profile is not null - ? ApiResult.CreatePassResult(profile) - : ApiResult.CreateFailResult("Failed to deserialize response"); - } } diff --git a/DeepDrftPublic.Client/Clients/TrackClient.cs b/DeepDrftPublic.Client/Clients/TrackClient.cs index aa7adf9..d503374 100644 --- a/DeepDrftPublic.Client/Clients/TrackClient.cs +++ b/DeepDrftPublic.Client/Clients/TrackClient.cs @@ -129,6 +129,33 @@ public class TrackClient : ApiResult>.CreateFailResult("Failed to deserialize response"); } + /// + /// 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. + /// + public async Task> 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.CreatePassResult(null); + + if (!response.IsSuccessStatusCode) + return ApiResult.CreateFailResult($"HTTP {(int)response.StatusCode}"); + + var json = await response.Content.ReadAsStringAsync(); + var profile = JsonSerializer.Deserialize(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + return profile is not null + ? ApiResult.CreatePassResult(profile) + : ApiResult.CreateFailResult("Failed to deserialize response"); + } + public async Task> GetTrack(string entryKey) { var response = await _http.GetAsync($"api/track/meta/by-key/{Uri.EscapeDataString(entryKey)}"); diff --git a/DeepDrftPublic.Client/Controls/WaveformVisualizer.razor.cs b/DeepDrftPublic.Client/Controls/WaveformVisualizer.razor.cs index 91ff5eb..3592e28 100644 --- a/DeepDrftPublic.Client/Controls/WaveformVisualizer.razor.cs +++ b/DeepDrftPublic.Client/Controls/WaveformVisualizer.razor.cs @@ -8,21 +8,24 @@ using Microsoft.JSInterop; namespace DeepDrftPublic.Client.Controls; /// -/// Full-page scrolling waveform background. Standalone and reusable: give it a -/// 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 EntryKey (the playing track when this is the active +/// player, else the host-supplied ) and re-fetches when that track identity +/// changes — not when the release changes. The release () 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. 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 -/// 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 parameter +/// is the composability fallback for hosts that have no player cascade (e.g. an embed) and want to drive +/// position themselves. /// 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 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; } - /// The opaque public EntryKey of the Mix release whose waveform datum to fetch and render. + /// + /// 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. + /// [Parameter] public required string ReleaseEntryKey { get; set; } /// - /// 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). /// [Parameter] public long? TrackId { get; set; } + /// + /// 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 (), 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. + /// + [Parameter] public string? TrackEntryKey { get; set; } + /// /// 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(); + } + + /// + /// 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 . This is the + /// single source of "which track's datum" — both the fetch key and what re-arms the fetch-once guard. + /// + private string? CurrentTrackKey => + IsActivePlayer ? PlayerService!.CurrentTrack!.EntryKey : TrackEntryKey; + + /// + /// 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. + /// + 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(); }); diff --git a/DeepDrftPublic.Client/Pages/MixDetail.razor b/DeepDrftPublic.Client/Pages/MixDetail.razor index fdd5c7d..454658f 100644 --- a/DeepDrftPublic.Client/Pages/MixDetail.razor +++ b/DeepDrftPublic.Client/Pages/MixDetail.razor @@ -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. *@ - + 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). *@ +
diff --git a/DeepDrftPublic.Client/Services/IReleaseDataService.cs b/DeepDrftPublic.Client/Services/IReleaseDataService.cs index b1db0bc..67dacf4 100644 --- a/DeepDrftPublic.Client/Services/IReleaseDataService.cs +++ b/DeepDrftPublic.Client/Services/IReleaseDataService.cs @@ -24,11 +24,4 @@ public interface IReleaseDataService /// Single release resolved by its opaque public EntryKey, with both metadata satellites (nulls for non-matching media). Task> GetByEntryKey(string entryKey); - - /// - /// 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. - /// - Task> GetMixWaveform(string entryKey); } diff --git a/DeepDrftPublic.Client/Services/ITrackDataService.cs b/DeepDrftPublic.Client/Services/ITrackDataService.cs index 0df3693..f0e77b5 100644 --- a/DeepDrftPublic.Client/Services/ITrackDataService.cs +++ b/DeepDrftPublic.Client/Services/ITrackDataService.cs @@ -30,6 +30,14 @@ public interface ITrackDataService Task> GetTrack(string trackId); + /// + /// 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. + /// + Task> GetTrackWaveform(string trackEntryKey); + /// /// 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); diff --git a/DeepDrftPublic.Client/Services/ReleaseClientDataService.cs b/DeepDrftPublic.Client/Services/ReleaseClientDataService.cs index 4f85502..ad2f9aa 100644 --- a/DeepDrftPublic.Client/Services/ReleaseClientDataService.cs +++ b/DeepDrftPublic.Client/Services/ReleaseClientDataService.cs @@ -31,7 +31,4 @@ public class ReleaseClientDataService : IReleaseDataService public Task> GetByEntryKey(string entryKey) => _releaseClient.GetByEntryKey(entryKey); - - public Task> GetMixWaveform(string entryKey) - => _releaseClient.GetMixWaveform(entryKey); } diff --git a/DeepDrftPublic.Client/Services/TrackClientDataService.cs b/DeepDrftPublic.Client/Services/TrackClientDataService.cs index 4d99282..7cda0ec 100644 --- a/DeepDrftPublic.Client/Services/TrackClientDataService.cs +++ b/DeepDrftPublic.Client/Services/TrackClientDataService.cs @@ -39,6 +39,9 @@ public class TrackClientDataService : ITrackDataService public Task> GetTrack(string trackId) => _trackClient.GetTrack(trackId); + public Task> GetTrackWaveform(string trackEntryKey) + => _trackClient.GetTrackWaveform(trackEntryKey); + public Task> GetRandomTrack() => _trackClient.GetRandom(); } diff --git a/DeepDrftPublic/Controllers/ReleaseProxyController.cs b/DeepDrftPublic/Controllers/ReleaseProxyController.cs index 5e4f28d..5466fed 100644 --- a/DeepDrftPublic/Controllers/ReleaseProxyController.cs +++ b/DeepDrftPublic/Controllers/ReleaseProxyController.cs @@ -47,11 +47,6 @@ public class ReleaseProxyController : ControllerBase return await RelayJson(query, "release list"); } - /// Proxies the Mix waveform datum, addressed by the release's opaque EntryKey. A 404 (no datum stored) passes through verbatim. - [HttpGet("{entryKey}/mix/waveform")] - public async Task GetMixWaveform(string entryKey, CancellationToken ct = default) - => await RelayJson($"api/release/{Uri.EscapeDataString(entryKey)}/mix/waveform", $"release {entryKey} mix waveform", ct); - /// Proxies a single release, addressed by its opaque EntryKey. A 404 (no such release) passes through verbatim. [HttpGet("{entryKey}")] public async Task GetReleaseByEntryKey(string entryKey, CancellationToken ct = default) diff --git a/DeepDrftPublic/Controllers/TrackProxyController.cs b/DeepDrftPublic/Controllers/TrackProxyController.cs index 054d681..104d2be 100644 --- a/DeepDrftPublic/Controllers/TrackProxyController.cs +++ b/DeepDrftPublic/Controllers/TrackProxyController.cs @@ -319,4 +319,40 @@ public class TrackProxyController : ControllerBase return Content(json, "application/json"); } } + + /// + /// 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. + /// + [HttpGet("{trackId}/waveform/high-res")] + public async Task 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"); + } + } }