Merge streaming-overhaul into dev (Opus low-data streaming, windowed streaming, HW-accel-off stabilization)

This commit is contained in:
daniel-c-harvey
2026-06-26 11:14:59 -04:00
97 changed files with 10086 additions and 591 deletions
+1 -1
View File
@@ -51,7 +51,7 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
- `StreamingAudioPlayerService`: Production implementation. Chunked stream from `TrackMediaClient`, adaptive 1664 KB buffer, early-playback, **seek-beyond-buffer** via offset request to the content API.
- `AudioInteropService`: JS interop wrapper over `window.DeepDrftAudio`. Manages `DotNetObjectReference` lifetimes for progress, end-of-playback, spectrum callbacks.
- Dark-mode services: `DarkModeServiceBase` (cookie name constant), `DarkModeCookieService` (JS cookie read/write).
- `WaveformVisualizerControlState`: Scoped session-persistent holder for the visualizer's **eight** continuous control positions, **two subsystem on/off toggles** (Phase 15), and one **Theater-Mode flag** (Phase 20): `ScrollSpeed`, `GradientRotationSpeed`, `LavaGravity`, `LavaHeat`, `FluidAmount` (wax count/volume), `FluidViscosity` (cohesion — the second half of the Phase 10 "bubbles" split; `BlobDensity` is gone), `CollisionStrength`, `WaveformWidth`, `LavaEnabled` (bool, default `true`), `WaveformEnabled` (bool, default `true`), `TheaterMode` (bool, default `false``DefaultTheaterMode`). Each has a matching `Default*` const. `Changed` event is the decoupling seam — controls mutate state + raise `Changed`; the bridge (`WaveformVisualizer`) subscribes and pushes the affected uniform or subsystem-enable; the Theater observers (the three detail pages and `AudioPlayerBar`) subscribe to react to `TheaterMode`. **`CoerceTheaterMode()`**: enforces the invariant that Theater Mode cannot remain on when both subsystems are off — called from `WaveformVisualizerControls.ToggleLava`/`ToggleWaveform` **before** `NotifyChanged()` so all observers see a consistent, coerced state in the same `Changed` cycle. `TheaterMode` is a page-chrome presentation flag; the visualizer bridge ignores it. Scoped DI so state survives SPA nav within a session and resets on fresh page load. **Phase 20 Wave 2 — playing-release predicates** live in `ReleaseDetailBase` / `CutDetailBase` (not in this state holder): `IsThisReleasePlaying` (`PlayerService?.CurrentTrack?.Release?.EntryKey == EntryKey`), `IsContentHidden` (`TheaterMode && IsThisReleasePlaying`), `ShowTheaterToggle` (`(LavaEnabled || WaveformEnabled) && IsThisReleasePlaying`). Both base classes also subscribe to `IStreamingPlayerService.StateChanged` (idempotent, reference-guarded, disposed) so the predicates re-evaluate live when playback moves between releases.
- `WaveformVisualizerControlState`: Scoped session-persistent holder for the visualizer's **eight** continuous control positions, **two subsystem on/off toggles** (Phase 15), and one **Theater-Mode flag** (Phase 20): `ScrollSpeed`, `GradientRotationSpeed`, `LavaGravity`, `LavaHeat`, `FluidAmount` (wax count/volume), `FluidViscosity` (cohesion — the second half of the Phase 10 "bubbles" split; `BlobDensity` is gone), `CollisionStrength`, `WaveformWidth`, `LavaEnabled` (bool, default `true`), `WaveformEnabled` (bool, default `true`), `TheaterMode` (bool, default `false``DefaultTheaterMode`). Each has a matching `Default*` const. `Changed` event is the decoupling seam — controls mutate state + raise `Changed`; the bridge (`WaveformVisualizer`) subscribes and pushes the affected uniform or subsystem-enable; the Theater observers (the three detail pages and `AudioPlayerBar`) subscribe to react to `TheaterMode`. **`CoerceTheaterMode()`**: enforces the invariant that Theater Mode cannot remain on when both subsystems are off — called from `WaveformVisualizerControls.ToggleLava`/`ToggleWaveform` **before** `NotifyChanged()` so all observers see a consistent, coerced state in the same `Changed` cycle. **`ApplyCapabilityDefault(bool hardwareAccelerated)`**: one-time scoped capability default (guarded by `_capabilityDefaultApplied`; never re-applies on SPA navigation, never overrides an explicit in-session toggle). When `hardwareAccelerated` is false (positive software-renderer match from `hwAccel.ts`'s `UNMASKED_RENDERER_WEBGL` probe, or total WebGL failure) sets `LavaEnabled = false` while leaving `WaveformEnabled` at its default on, then calls `CoerceTheaterMode()` + `NotifyChanged()` once so all observers see the default in a single cycle. Called by the visualizer bridge on first interactive render once JS interop (the HW-accel probe via `detectHardwareAcceleration()` exported from `WaveformVisualizer.ts`) is available; a no-op when HW accel is present. `TheaterMode` is a page-chrome presentation flag; the visualizer bridge ignores it. Scoped DI so state survives SPA nav within a session and resets on fresh page load. **Phase 20 Wave 2 — playing-release predicates** live in `ReleaseDetailBase` / `CutDetailBase` (not in this state holder): `IsThisReleasePlaying` (`PlayerService?.CurrentTrack?.Release?.EntryKey == EntryKey`), `IsContentHidden` (`TheaterMode && IsThisReleasePlaying`), `ShowTheaterToggle` (`(LavaEnabled || WaveformEnabled) && IsThisReleasePlaying`). Both base classes also subscribe to `IStreamingPlayerService.StateChanged` (idempotent, reference-guarded, disposed) so the predicates re-evaluate live when playback moves between releases.
- `PlayTracker`: Per-session play-session tracker (Phase 16 wave 16.1). Opens on playback start, advances a high-water position on each progress tick (from `StreamingAudioPlayerService` — not the HTTP layer, so seek-beyond-buffer re-fetches are the same play), closes on track-switch / stop / organic-end / page-unload. Engagement floor: ≥3 s OR ≥5% of duration. Three-bucket classification (`partial`/`sampled`/`complete`). Emits at most one event per session via `IPlayEventSink`. No player or JS dependency — testable against a fake sink.
- `ShareTracker`: Per-session share tracker (Phase 16 wave 16.1). Called by `SharePopover` after a successful clipboard write; applies a 60-second per-(target, channel) debounce. Sends via `BeaconInterop`. Scoped so debounce memory resets on fresh page load. **Wave 16.3:** injects `IAnonIdProvider`; attaches `_anonId.Current` to `ShareEventDto.AnonId` (omitted when null).
- `BeaconInterop`: `navigator.sendBeacon` JS interop wrapper (Phase 16 wave 16.1). Fires JSON payloads to `api/event/{play,share}` fire-and-forget. Also wires a page-unload handler that flushes any pending play event when the page is torn down.
@@ -2,6 +2,8 @@ using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
using Microsoft.AspNetCore.Components.WebAssembly.Http;
using Microsoft.Extensions.DependencyInjection;
using NetBlocks.Models;
@@ -18,13 +20,24 @@ public class TrackMediaResponse : IDisposable
/// </summary>
public string ContentType { get; }
/// <summary>
/// The total file length in bytes, parsed from the 206 response's <c>Content-Range:
/// bytes start-end/TOTAL</c> header (Phase 21 Direction B). Null when the server returned
/// 200 (no Content-Range) — callers fall back to <see cref="ContentLength"/> as the total.
/// This is the EOF boundary the segment loop advances its cursor toward, and the full
/// logical length the JS decoder must see (so a bounded segment's small Content-Length
/// never trips the decoder's byte-count completion early).
/// </summary>
public long? TotalLength { get; }
private readonly HttpResponseMessage _response;
public TrackMediaResponse(Stream stream, long contentLength, string contentType, HttpResponseMessage response)
public TrackMediaResponse(Stream stream, long contentLength, string contentType, long? totalLength, HttpResponseMessage response)
{
Stream = stream;
ContentLength = contentLength;
ContentType = contentType;
TotalLength = totalLength;
_response = response;
}
@@ -45,24 +58,61 @@ public class TrackMediaClient
}
/// <summary>
/// Fetches the WAV stream for a track via an HTTP Range request starting at a
/// Fetches the audio stream for a track via an HTTP Range request starting at a
/// file-absolute byte offset. <paramref name="byteOffset"/> is the position from
/// the start of the file on disk (including the WAV header) — callers seeking into
/// audio data must add the header size themselves. The cancellation token aborts
/// the in-flight server connection rather than leaving the server draining bytes
/// into a dead socket.
/// the start of the file on disk (including any container/header bytes) — callers
/// seeking into audio data must add the header size themselves. The cancellation
/// token aborts the in-flight server connection rather than leaving the server
/// draining bytes into a dead socket.
/// <para>
/// <paramref name="byteEnd"/> (Phase 21 Direction B) bounds the request to a single
/// segment: when set, the Range header is <c>bytes={byteOffset}-{byteEnd}</c> (inclusive),
/// so the browser holds at most ~one segment of raw bytes regardless of file size — the
/// network-memory bound this phase exists for. When null the request is open-ended
/// (<c>bytes={byteOffset}-</c>), the pre-Direction-B behaviour. Either way the response's
/// <c>Content-Range</c> total is surfaced via <see cref="TrackMediaResponse.TotalLength"/>
/// so the caller knows the EOF boundary and the full logical length the decoder must see.
/// </para>
/// <para>
/// <paramref name="format"/> selects the delivery rendering (Phase 18): the default
/// <see cref="AudioFormat.Lossless"/> sends no <c>format</c> query param, so existing
/// callers hit the byte-identical pre-Phase-18 endpoint; <see cref="AudioFormat.Opus"/>
/// requests the low-data Ogg Opus artifact, which the server resolves and falls back to
/// lossless when absent (C2). The response <see cref="TrackMediaResponse.ContentType"/>
/// reports the format actually served, so the JS decoder dispatches on the real bytes.
/// </para>
/// </summary>
public async Task<ApiResult<TrackMediaResponse>> GetTrackMedia(
string trackId,
long byteOffset = 0,
long? byteEnd = null,
AudioFormat format = AudioFormat.Lossless,
CancellationToken cancellationToken = default)
{
try
{
// Same URL for every seek — only the Range header differs. byteOffset 0 is
// Same URL for every fetch — only the Range header differs. byteOffset 0 is
// not special-cased: "bytes=0-" requests the whole file from the start.
using var request = new HttpRequestMessage(HttpMethod.Get, $"api/track/{trackId}");
request.Headers.Range = new RangeHeaderValue(byteOffset, null);
// Lossless omits the format param entirely so the request is byte-identical to
// the pre-Phase-18 endpoint; only Opus appends ?format=opus.
var uri = format == AudioFormat.Lossless
? $"api/track/{trackId}"
: $"api/track/{trackId}?format={format.ToString().ToLowerInvariant()}";
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
// Bounded (byteEnd set) → "bytes=start-end" so the server returns a finite 206
// slice and the browser buffers only that segment; open-ended (byteEnd null) →
// "bytes=start-". The server honours both via File(..., enableRangeProcessing: true),
// which parses the full RFC 7233 range grammar and slices accordingly.
request.Headers.Range = new RangeHeaderValue(byteOffset, byteEnd);
// Stream the response body incrementally instead of buffering it whole (Phase 21.4 fix).
// In Blazor WebAssembly the HttpClient is backed by the browser fetch API; without this the
// browser buffers the ENTIRE body before the response stream yields a byte. With Direction B
// each request is already bounded to one segment, so the body is small regardless — but
// streaming still lets us read it incrementally and is harmless on the SSR server-to-server
// path (SocketsHttpHandler ignores the unknown option). Kept for both the initial and the
// seek/refill paths since both share this method.
request.SetBrowserResponseStreamingEnabled(true);
// Use HttpCompletionOption.ResponseHeadersRead to get stream immediately
var response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
@@ -72,11 +122,15 @@ public class TrackMediaClient
// Default to WAV when the server omits the header — the only format shipping
// today — so the JS factory always receives a usable media type.
var contentType = response.Content.Headers.ContentType?.MediaType ?? "audio/wav";
// Content-Range "bytes start-end/TOTAL" carries the full file length on a 206; on a 200
// there is no Content-Range, so TotalLength is null and callers use ContentLength.
var totalLength = response.Content.Headers.ContentRange?.Length;
var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
// TrackMediaResponse takes ownership of both stream and response;
// do NOT dispose response here — the caller disposes via TrackMediaResponse.Dispose().
return ApiResult<TrackMediaResponse>.CreatePassResult(new TrackMediaResponse(stream, contentLength, contentType, response));
return ApiResult<TrackMediaResponse>.CreatePassResult(
new TrackMediaResponse(stream, contentLength, contentType, totalLength, response));
}
catch (Exception e)
{
@@ -115,4 +169,33 @@ public class TrackMediaClient
return ApiResult<WaveformProfileDto>.CreateFailResult(e.Message);
}
}
/// <summary>
/// Fetches a track's Opus seek/setup sidecar — the combined OpusHead/OpusTags setup header plus the
/// granule→byte seek index (Phase 18). The caller (18.5 player wiring) fetches this once on track load
/// and parses it into the JS-side OpusSeekData before issuing any Opus seek. A 404 means no Opus
/// artifact / sidecar exists for the track (legacy row, not backfilled, or transcode failed); callers
/// treat that as "this track has no Opus seek data — stay on lossless" rather than an error, so it
/// surfaces as a fail result with a stable message rather than throwing (mirrors GetWaveformProfileAsync).
/// </summary>
public async Task<ApiResult<byte[]>> GetOpusSidecarAsync(string trackId, CancellationToken cancellationToken = default)
{
try
{
var response = await _http.GetAsync($"api/track/{trackId}/opus/seekdata", cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return ApiResult<byte[]>.CreateFailResult("No Opus sidecar available");
}
response.EnsureSuccessStatusCode();
var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken);
return ApiResult<byte[]>.CreatePassResult(bytes);
}
catch (Exception e)
{
return ApiResult<byte[]>.CreateFailResult(e.Message);
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Components;
namespace DeepDrftPublic.Client.Common;
/// <summary>
/// The single public-site listener-settings object (Phase 18 wave 18.6, §4a). The generalized analogue of
/// <see cref="DarkModeSettings"/>: one scoped holder for every remembered listener preference, seeded at
/// server prerender, carried into WASM via <see cref="PersistentState"/>, and persisted to a cookie on
/// change. Today it carries one preference — streaming quality; tomorrow dark mode (and whatever follows)
/// folds in here as another property without disturbing the menu that reads it.
/// <para>
/// Built design-for-adaptability per §4a: a new preference is a new <c>[PersistentState]</c> property here
/// plus a new <see cref="Components.SettingsItem"/> in the menu — not a rewire. Dark mode is intentionally
/// <em>not</em> migrated in now (it keeps its own <see cref="DarkModeSettings"/> seam); this object is shaped
/// so that consolidation is later a merge of two identical seams, not a reconciliation of two different ones.
/// </para>
/// </summary>
public class PublicSiteSettings
{
/// <summary>
/// The listener's streaming-quality preference. Defaults to <see cref="StreamQuality.LowData"/> (Opus,
/// capability-gated — OQ2). Seeded from the <c>streamQuality</c> cookie at prerender; persisted on change
/// by the client cookie service. The player reads this to decide which <c>?format=</c> to request, but
/// the capability gate and C2 fallback still apply on top, so a <see cref="StreamQuality.LowData"/>
/// preference never forces an unplayable stream.
/// </summary>
[PersistentState]
public StreamQuality StreamQuality { get; set; } = StreamQuality.LowData;
}
@@ -0,0 +1,12 @@
using Microsoft.AspNetCore.Components;
namespace DeepDrftPublic.Client.Common;
/// <summary>
/// One entry in the public-site Settings menu (Phase 18 wave 18.6, §4a). The settings-item abstraction the
/// menu renders instead of a hard-coded control list: a <see cref="Label"/> plus a <see cref="Control"/>
/// fragment bound to a persisted preference. Adding a future tenant (e.g. dark mode) is appending one of
/// these — not rewiring the menu. The control fragment owns its own binding to <see cref="PublicSiteSettings"/>
/// and its own persistence call, so each item is self-contained and the menu stays preference-agnostic.
/// </summary>
public sealed record SettingsItem(string Label, RenderFragment Control);
@@ -0,0 +1,18 @@
namespace DeepDrftPublic.Client.Common;
/// <summary>
/// The listener's streaming-quality preference (Phase 18 wave 18.6, §4). This is the user's <em>intent</em>,
/// not the wire format that ultimately gets served: <see cref="LowData"/> means "give me Opus if you can,"
/// but the player still capability-gates and C2-falls-back to lossless when Opus can't play (a browser that
/// can't decode Ogg Opus, or a track with no Opus artifact). It is therefore deliberately distinct from
/// <c>DeepDrftModels.Enums.AudioFormat</c> (the delivery rendering resolved per request): one is the
/// remembered preference, the other is what a given stream request actually asks for.
/// </summary>
public enum StreamQuality
{
/// <summary>Bandwidth-friendly Opus (capability-gated; the default before any choice — OQ2).</summary>
LowData,
/// <summary>The lossless WAV path, always playable everywhere.</summary>
Lossless
}
@@ -1,3 +1,4 @@
using DeepDrftPublic.Client.Common;
using DeepDrftPublic.Client.Services;
using DeepDrftPublic.Client.Clients;
using Microsoft.AspNetCore.Components;
@@ -13,6 +14,7 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
[Inject] public required BeaconInterop Beacon { get; set; }
[Inject] public required IPlayEventSink PlayEventSink { get; set; }
[Inject] public required IAnonIdProvider AnonId { get; set; }
[Inject] public required PublicSiteSettings Settings { get; set; }
private IStreamingPlayerService? _audioPlayerService;
private QueueService? _queueService;
@@ -26,7 +28,12 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
// EnsureInitializedAsync — that path is correct because audio contexts
// require a user gesture anyway. Initializing eagerly here causes 4+
// SignalR round-trips before any content is stable.
var player = new StreamingAudioPlayerService(AudioInterop, TrackMediaClient, Logger);
// Construct the preference-aware player (Phase 18 wave 18.6): it honours the listener's streaming-
// quality choice via the ResolveStreamFormatAsync seam while inheriting the 18.5 capability gate and
// C2 fallback. PublicSiteSettings is scoped data (already prerender-seeded + WASM-bridged), so passing
// it through the constructor is cheap and carries no lifecycle — the telemetry tracker still binds
// post-construction below, exactly as before.
var player = new PreferenceAwareStreamingPlayerService(AudioInterop, TrackMediaClient, Logger, Settings);
// Phase 16: bind the play-session tracker to the player after construction, the same way the
// queue binds — the player is built with `new`, not DI, so threading telemetry through its
@@ -0,0 +1,55 @@
@using DeepDrftPublic.Client.Common
@using DeepDrftPublic.Client.Controls.Settings
@using DeepDrftPublic.Client.Services
@*
The public-site Settings menu (Phase 18 wave 18.6, §4a). An app-bar trigger opening a MudMenu that renders
a settings-item list — NOT a hard-coded control stack. Each entry is a SettingsItem (label + a control
fragment bound to a persisted preference), so a future tenant (dark mode) plugs in as a new list entry, not
a menu rewire. Today the list holds one item: the streaming-quality toggle.
The MudMenu items carry OnClick="@(() => {})" + OnTouch so a click inside a control row does not dismiss the
menu (MudMenu auto-closes on item activation otherwise), keeping the radio group usable.
*@
<div class="dd-accent-icon">
<MudMenu Icon="@Icons.Material.Filled.Settings"
Color="Color.Inherit"
AnchorOrigin="Origin.BottomRight"
TransformOrigin="Origin.TopRight"
AriaLabel="Settings"
Class="dd-settings-menu">
<div class="dd-settings-panel">
<div class="dd-settings-heading">Settings</div>
@foreach (var item in _items)
{
<div class="dd-settings-item">
<div class="dd-settings-item-label">@item.Label</div>
@item.Control
</div>
}
</div>
</MudMenu>
</div>
@code {
// The active player, cascaded by AudioPlayerProvider. SettingsMenu sits in the app bar inside the
// provider, so it receives the cascade here — but the MudMenu PANEL content below is portaled to
// <MudPopoverProvider> (outside the provider), so a cascade cannot reach it. We thread the player into
// the setting as an explicit parameter instead: an explicit value captured in the item fragment flows
// into portaled content fine, where a [CascadingParameter] would land null.
[CascadingParameter] public IStreamingPlayerService? Player { get; set; }
// The settings-item list. Built once; adding a preference is appending one SettingsItem with its control
// fragment — the menu body above renders whatever is here without knowing what each item is. The item
// fragment reads Player at render time (when the menu opens), so it picks up the cascaded instance even
// though the list itself is initialized before the cascade is set.
private readonly List<SettingsItem> _items;
public SettingsMenu()
{
_items =
[
new SettingsItem("Streaming quality", @<StreamQualitySetting Player="@Player" />)
];
}
}
@@ -0,0 +1,97 @@
@using DeepDrftPublic.Client.Common
@using DeepDrftPublic.Client.Services
@*
The streaming-quality control (Phase 18 wave 18.6, §4) — the first occupant of the Settings menu. Binds
the listener's choice to PublicSiteSettings.StreamQuality and persists it via the cookie seam. Honest
capability gate (OQ2 / AC7): on a browser that cannot decode Ogg Opus the Low-data option still selects,
but a note tells the listener the effective stream is lossless — we never let the choice silently imply a
format that can't play.
*@
<div class="dd-setting-control">
<MudRadioGroup T="StreamQuality" Value="_quality" ValueChanged="OnQualityChanged">
<MudRadio T="StreamQuality" Value="StreamQuality.LowData" Color="Color.Primary" Dense="true">
Low-data (Opus)
</MudRadio>
<MudRadio T="StreamQuality" Value="StreamQuality.Lossless" Color="Color.Primary" Dense="true">
Lossless (WAV)
</MudRadio>
</MudRadioGroup>
@if (_opusUnavailable && _quality == StreamQuality.LowData)
{
<div class="dd-setting-note">
This browser can't decode Opus — you'll stream lossless.
</div>
}
<div class="d-flex flex-align-right">
<MudButton Disabled="!IsApplyEnabled"
Color="Color.Primary"
Variant="Variant.Filled"
OnClick="@ApplyStreamQualitySetting">
APPLY
</MudButton>
</div>
</div>
@code {
[Inject] public required PublicSiteSettings Settings { get; set; }
[Inject] public required SettingsCookieService CookieService { get; set; }
[Inject] public required AudioInteropService AudioInterop { get; set; }
// The active player, threaded in from SettingsMenu (which reads it off the AudioPlayerProvider cascade).
// It is an explicit [Parameter], NOT a [CascadingParameter], because this control renders inside the
// MudMenu panel, which MudBlazor portals to <MudPopoverProvider> — outside AudioPlayerProvider's cascade
// scope, so a cascade would land null here. Null during prerender or when no provider is present — Apply
// then just persists the preference, with no live track to restart.
[Parameter] public IStreamingPlayerService? Player { get; set; }
private StreamQuality _quality;
public bool IsApplyEnabled => _quality != Settings.StreamQuality;
// Null until the capability probe runs (post-render JS interop). false → can decode Opus; true → cannot.
private bool _opusUnavailable;
protected override void OnInitialized()
{
// Read the current preference (already seeded at prerender + bridged into WASM).
_quality = Settings.StreamQuality;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
// Capability probe is JS interop — only valid once interactive. Surfaces the honest note when the
// browser can't decode Ogg Opus, so a Low-data pick reads as "effectively lossless" rather than
// silently failing. The player applies the same gate independently; this is purely the UI honesty.
var canDecodeOpus = await AudioInterop.CanDecodeOggOpus();
if (canDecodeOpus == _opusUnavailable)
{
_opusUnavailable = !canDecodeOpus;
StateHasChanged();
}
}
private async Task OnQualityChanged(StreamQuality quality)
{
_quality = quality;
}
private async Task ApplyStreamQualitySetting(MouseEventArgs arg)
{
// Persist the choice first so the cookie + in-memory PublicSiteSettings.StreamQuality both reflect
// the new value BEFORE the restart: the reload re-resolves the delivery format via
// PreferenceAwareStreamingPlayerService, which reads PublicSiteSettings.StreamQuality fresh.
await CookieService.SetStreamQualityAsync(_quality);
// Switch the currently-playing track to the new format immediately, preserving the listener's
// position. No-op inside the player when nothing is loaded — the new preference then simply
// applies to the next track played. Fire-and-forget: the reload runs the streaming loop for the
// life of the track, so awaiting it would pin this handler open; UI updates flow through the
// player's state notifications, not this await.
_ = Player?.ReloadPreservingPositionAsync();
}
}
@@ -265,6 +265,24 @@ public partial class WaveformVisualizer : ComponentBase, IAsyncDisposable
return;
}
// Apply the hardware-capability default ONCE per session before seeding the controls: if the
// browser has no WebGL hardware acceleration, the lava subsystem (which software-renders on
// the main thread and starves audio decode) defaults off while the waveform stays on. The
// probe lives in JS (it needs a real WebGL context); the scoped state guards the one-time
// application, so a remounted visualizer never re-applies and a later explicit toggle is
// never clobbered. Sequenced before PushControlsAsync so the seed already carries the
// corrected enables; ApplyCapabilityDefault also raises Changed for the controls UI.
try
{
var hardwareAccelerated = await _module.InvokeAsync<bool>("detectHardwareAcceleration");
ControlState.ApplyCapabilityDefault(hardwareAccelerated);
}
catch (JSException ex)
{
// A probe failure must not regress the HW-accel majority: leave the defaults (lava on).
Logger.LogWarning(ex, "WaveformVisualizer: hardware-acceleration probe failed; leaving lava at its default.");
}
// Seed the module with the current state now that it exists. All control values (the eight
// dials + the two Phase 15 subsystem enables) come from the shared (session-persisted) state,
// so a mix opened mid-session seeds the module with the knob/toggle positions the listener
@@ -1,5 +1,6 @@
@using DeepDrftPublic.Client.Common
@using DeepDrftPublic.Client.Controls
@using DeepDrftPublic.Client.Controls.Settings
@using DeepDrftPublic.Client.Services
@* Desktop Menu *@
@@ -42,6 +43,7 @@
<div class="dd-nav-actions">
<StreamNowButton ButtonClass="dd-nav-cta" ButtonLabel="Stream Now &#9654;"/>
<SettingsMenu />
<MudIconButton Icon="@(DarkLightModeButtonIcon)" Color="Color.Inherit" OnClick="@DarkModeToggle"/>
</div>
</nav>
@@ -74,7 +76,8 @@
@onclick="ToggleMobileMenu">
<span></span><span></span><span></span>
</button>
<SettingsMenu />
<MudIconButton Icon="@(DarkLightModeButtonIcon)" Color="Color.Inherit" OnClick="@DarkModeToggle"/>
</div>
+14 -2
View File
@@ -42,6 +42,7 @@
@code {
private string _audioPlayerClass = "minimized";
private const string DarkModeKey = "darkMode";
private const string StreamQualityKey = "streamQuality";
private bool _isDarkMode = false;
private bool? _lastAppliedDarkMode = null;
private PersistingComponentStateSubscription _persistingSubscription;
@@ -49,6 +50,7 @@
[Inject] public required PersistentComponentState PersistentState { get; set; }
[Inject] public required DarkModeSettings DarkModeSettings { get; set; }
[Inject] public required PublicSiteSettings PublicSiteSettings { get; set; }
[Inject] public required IJSRuntime JS { get; set; }
protected override void OnInitialized()
@@ -66,8 +68,17 @@
_isDarkMode = DarkModeSettings.IsDarkMode;
}
// Restore the prerender-seeded streaming-quality preference (Phase 18 wave 18.6). Same bridge dark
// mode uses: the server SettingsService seeded PublicSiteSettings from the streamQuality cookie, and
// this carries it into WASM so the client boots already knowing the preference (no re-read flash, no
// wrong default before the first stream).
if (PersistentState.TryTakeFromJson<StreamQuality>(StreamQualityKey, out var restoredQuality))
{
PublicSiteSettings.StreamQuality = restoredQuality;
}
// Register to persist state when prerendering completes
_persistingSubscription = PersistentState.RegisterOnPersisting(PersistDarkMode);
_persistingSubscription = PersistentState.RegisterOnPersisting(PersistState);
}
// Sync dark mode class on <body> so portaled MudBlazor elements (popovers, menus, selects)
@@ -91,9 +102,10 @@
// Theme wrapper class for CSS targeting
private string ThemeWrapperClass => _isDarkMode ? "deepdrft-theme-dark" : "deepdrft-theme-light";
private Task PersistDarkMode()
private Task PersistState()
{
PersistentState.PersistAsJson(DarkModeKey, _isDarkMode);
PersistentState.PersistAsJson(StreamQualityKey, PublicSiteSettings.StreamQuality);
return Task.CompletedTask;
}
@@ -70,6 +70,37 @@ public class AudioInteropService : IAsyncDisposable
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.initializeStreaming", playerId, totalStreamLength, contentType);
}
/// <summary>
/// Probes whether this browser can stream-decode Ogg Opus via WebCodecs (<c>AudioDecoder</c> +
/// <c>codec:'opus'</c>; Safari &lt; 16.4 / older Firefox cannot). Phase 18 capability gate (OQ2): the
/// player only requests Opus when this returns true, otherwise it stays on the universal lossless path
/// (AC7 — no listener ever gets silence over a codec gap). Probe failures degrade to <c>false</c>
/// (assume incapable) so an interop error can never silence playback.
/// </summary>
public async Task<bool> CanDecodeOggOpus()
{
try
{
return await _jsRuntime.InvokeAsync<bool>("DeepDrftAudio.canDecodeOggOpus");
}
catch
{
return false;
}
}
/// <summary>
/// Hands the raw Opus seek/setup sidecar bytes (setup header + granule→byte seek index) to the JS player
/// so the next Opus stream's decoder has them BEFORE init (the 18.4 set-before-init contract). The player
/// parses and stashes them; <see cref="InitializeStreaming"/> applies them when it builds the Opus decoder.
/// Must be called before <see cref="InitializeStreaming"/> on an Opus stream. Returns the parse result —
/// a failure means the bytes were not a valid sidecar, and the caller falls back to lossless.
/// </summary>
public async Task<AudioOperationResult> SetOpusSidecar(string playerId, byte[] sidecarBytes)
{
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.setOpusSidecar", playerId, sidecarBytes);
}
public async Task<StreamingResult> ProcessStreamingChunk(string playerId, byte[] audioChunk)
{
return await InvokeJsAsync<StreamingResult>("DeepDrftAudio.processStreamingChunk", playerId, audioChunk);
@@ -115,6 +146,18 @@ public class AudioInteropService : IAsyncDisposable
return await InvokeJsAsync<SeekResult>("DeepDrftAudio.seek", playerId, position);
}
/// <summary>
/// Resolve the file-absolute byte offset to begin a stream at <paramref name="position"/> with no
/// active playback or buffered audio — the "load at timestamp" seam (Phase 18 wave 18.6 format switch).
/// Returns <see cref="SeekResult.ByteOffset"/> on success; <see cref="AudioOperationResult.Success"/> is
/// false when the decoder cannot yet resolve an offset (e.g. a WAV stream whose header has not been
/// parsed), so the caller can feed header bytes and retry.
/// </summary>
public async Task<SeekResult> ResolveStreamOffsetAsync(string playerId, double position)
{
return await InvokeJsAsync<SeekResult>("DeepDrftAudio.resolveStreamOffset", playerId, position);
}
// New methods for seek-beyond-buffer support
public async Task<double> GetBufferedDuration(string playerId)
{
@@ -128,11 +171,42 @@ public class AudioInteropService : IAsyncDisposable
}
}
/// <summary>
/// Phase 21.2a back-pressure poll: ask whether the scheduler is still over its forward
/// high/low-water band. The read loop calls this only WHILE already throttled, to learn when it
/// may resume reading — the steady-state loop reads the piggybacked <c>ProductionPaused</c> flag
/// off each chunk result instead. Defaults to false on any interop failure so a torn-down player
/// never wedges a loop that is exiting anyway.
/// </summary>
public async Task<bool> IsProductionPaused(string playerId)
{
try
{
return await _jsRuntime.InvokeAsync<bool>("DeepDrftAudio.isProductionPaused", playerId);
}
catch
{
return false;
}
}
public async Task<AudioOperationResult> ReinitializeFromOffset(string playerId, long totalStreamLength, double seekPosition)
{
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.reinitializeFromOffset", playerId, totalStreamLength, seekPosition);
}
/// <summary>
/// Phase 21.3 / AC6 clean-failure recovery: after a window-miss refill (seek-back past the
/// retained tail) fails its Range fetch or reinit, halt the starved scheduler and leave the
/// player paused-but-loaded at <paramref name="seekPosition"/> so no silent false end fires and a
/// retry is possible. Routes through <see cref="InvokeJsAsync{T}"/> so an interop failure during
/// recovery still yields a failure result rather than throwing into the seek path.
/// </summary>
public async Task<AudioOperationResult> RecoverFromFailedRefill(string playerId, double seekPosition)
{
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.recoverFromFailedRefill", playerId, seekPosition);
}
public async Task<AudioOperationResult> SetVolumeAsync(string playerId, double volume)
{
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.setVolume", playerId, volume);
@@ -388,6 +462,11 @@ public class StreamingResult : AudioOperationResult
public bool HeaderParsed { get; set; }
public int BufferCount { get; set; }
public double? Duration { get; set; } // Duration in seconds calculated from WAV header
// Phase 21.2a back-pressure: true when the scheduler's forward decoded fill is over the
// high-water mark and the C# read loop should stop calling ReadAsync until it drains. Read off
// the chunk result the loop already awaits — no extra interop hop in the unthrottled steady state.
public bool ProductionPaused { get; set; }
}
public class AudioPlayerState
@@ -118,6 +118,16 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
IsPlaying = true;
IsPaused = false;
}
else if (IsPaused)
{
// Play failed while the player is paused — the scheduler may be empty after a
// failed refill (AC6 recovery). Re-issue a seek at the current position: the
// seek path routes to seekBeyondBuffer when the scheduler is empty (Phase 21.3
// fix), triggering a real refetch rather than returning "Streaming not ready".
// We return early here; Seek owns its own state mutations and NotifyStateChanged.
await Seek(CurrentTime);
return;
}
}
if (!result.Success)
@@ -128,7 +138,7 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
{
ErrorMessage = null;
}
await NotifyStateChanged();
}
catch (Exception ex)
@@ -298,7 +308,6 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
/// </summary>
protected virtual void OnPlaybackEnded() { }
protected async Task EnsureInitializedAsync()
{
if (!IsInitialized)
@@ -14,9 +14,7 @@ public class DarkModeCookieService(DarkModeSettings darkModeSetting, IJSRuntime
public async ValueTask SetDarkModeAsync(bool isDarkMode)
{
var expires = DateTime.UtcNow.AddDays(EXPIRY_DAYS).ToString("R");
await js.InvokeVoidAsync("eval",
$"document.cookie = '{COOKIE_NAME}={isDarkMode.ToString().ToLower()}; expires={expires}; path=/; SameSite=Lax'");
await js.InvokeVoidAsync("DeepDrftSettings.setCookie", COOKIE_NAME, isDarkMode.ToString().ToLower(), EXPIRY_DAYS);
darkModeSetting.IsDarkMode = isDarkMode;
}
}
@@ -91,4 +91,16 @@ public interface IStreamingPlayerService : IPlayerService
/// <see cref="IPlayerService.CurrentTrack"/> and notifies; performs no JS interop.
/// </summary>
Task StageTrack(TrackDto track);
/// <summary>
/// Re-streams the current track in the freshly-resolved delivery format while preserving the
/// listener's playback position (Phase 18 wave 18.6 — the Settings "Apply" restart). The format is
/// re-resolved on the new load via the <c>ResolveStreamFormatAsync</c> seam, so this picks up a just-
/// changed streaming-quality preference. A cross-format byte offset can only be resolved once the NEW
/// format's decoder has parsed its header, so the reload runs a fresh load from byte 0 to initialize
/// that decoder, then seeks back to the saved position through the existing seek-beyond-buffer path.
/// No-op when no track is loaded (nothing playing to switch). Safe to call while a track is playing;
/// a track switch during the brief restore window abandons the position restore.
/// </summary>
Task ReloadPreservingPositionAsync();
}
@@ -0,0 +1,49 @@
using DeepDrftModels.Enums;
using DeepDrftPublic.Client.Clients;
using DeepDrftPublic.Client.Common;
using Microsoft.Extensions.Logging;
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// The production player that honours the listener's streaming-quality preference (Phase 18 wave 18.6).
/// Extends <see cref="StreamingAudioPlayerService"/> through the single deliberately-overridable seam,
/// <see cref="StreamingAudioPlayerService.ResolveStreamFormatAsync"/>, so the rest of the streaming stack
/// (seek, telemetry, the seek-beyond-buffer format reuse) is inherited verbatim.
/// <para>
/// The override is one branch: a <see cref="StreamQuality.Lossless"/> preference returns
/// <see cref="AudioFormat.Lossless"/> immediately; anything else falls through to <c>base</c>, which keeps
/// the 18.5 invariants intact — the capability gate (AC7: a browser that can't decode Ogg Opus gets lossless)
/// and the sidecar-absent → lossless fallback (C2: a legacy / un-backfilled / failed-transcode track gets
/// lossless). So a Lossless pick always yields lossless; a Low-data pick yields Opus only when it can
/// actually play, and lossless otherwise. No path produces an unplayable stream.
/// </para>
/// </summary>
public class PreferenceAwareStreamingPlayerService : StreamingAudioPlayerService
{
private readonly PublicSiteSettings _settings;
public PreferenceAwareStreamingPlayerService(
AudioInteropService audioInterop,
TrackMediaClient trackMediaClient,
ILogger<StreamingAudioPlayerService> logger,
PublicSiteSettings settings)
: base(audioInterop, trackMediaClient, logger)
{
_settings = settings;
}
protected override async Task<AudioFormat> ResolveStreamFormatAsync(string entryKey, CancellationToken cancellationToken)
{
// Listener explicitly chose lossless — request it directly, no Opus probe / sidecar fetch needed.
if (_settings.StreamQuality == StreamQuality.Lossless)
{
return AudioFormat.Lossless;
}
// Low-data preference: defer to the base capability-gated resolution, which probes Opus support and
// the sidecar's presence and degrades to lossless when either is missing. Both 18.5 invariants are
// inherited here, not re-implemented.
return await base.ResolveStreamFormatAsync(entryKey, cancellationToken);
}
}
@@ -0,0 +1,32 @@
using DeepDrftPublic.Client.Common;
using Microsoft.JSInterop;
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// Client-side runtime writer for public-site settings (Phase 18 wave 18.6), the analogue of
/// <see cref="DarkModeCookieService"/>. Reads the current preference off the in-memory
/// <see cref="PublicSiteSettings"/> (already seeded at prerender and bridged into WASM), and writes a
/// 365-day cookie via <c>document.cookie</c> interop when the listener changes it in the Settings menu —
/// the same durable-truth seam dark mode uses, so the choice survives the session and seeds the next visit's
/// prerender (no flash).
/// </summary>
public class SettingsCookieService(PublicSiteSettings settings, IJSRuntime js) : SettingsServiceBase
{
private const int ExpiryDays = 365;
public StreamQuality GetStreamQuality() => settings.StreamQuality;
public async ValueTask SetStreamQualityAsync(StreamQuality quality)
{
if (settings.StreamQuality == quality) return;
await WriteCookieAsync(StreamQualityCookieName, FormatStreamQuality(quality));
settings.StreamQuality = quality;
}
private async ValueTask WriteCookieAsync(string name, string value)
{
await js.InvokeVoidAsync("DeepDrftSettings.setCookie", name, value, ExpiryDays);
}
}
@@ -0,0 +1,28 @@
using DeepDrftPublic.Client.Common;
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// Shared cookie contract for the public-site settings seam (Phase 18 wave 18.6), the analogue of
/// <see cref="DarkModeServiceBase"/>. Holds the cookie names and the (de)serialization for each preference
/// so the server prerender-read service and the client cookie-write service agree on one wire format —
/// the load-bearing reason this is shared rather than duplicated. Each new preference adds its cookie name
/// and a parse/format pair here, keeping the round-trip in one place.
/// </summary>
public abstract class SettingsServiceBase
{
protected const string StreamQualityCookieName = "streamQuality";
/// <summary>
/// Parses the <c>streamQuality</c> cookie value into <see cref="StreamQuality"/>, defaulting to
/// <see cref="StreamQuality.LowData"/> (the OQ2 default) for an absent, empty, or unrecognized value so
/// a missing/garbled cookie never produces a surprising preference.
/// </summary>
protected static StreamQuality ParseStreamQuality(string? cookieValue) =>
Enum.TryParse<StreamQuality>(cookieValue, ignoreCase: true, out var parsed)
? parsed
: StreamQuality.LowData;
/// <summary>Formats a <see cref="StreamQuality"/> for cookie storage (round-trips with <see cref="ParseStreamQuality"/>).</summary>
protected static string FormatStreamQuality(StreamQuality quality) => quality.ToString();
}
@@ -1,4 +1,5 @@
using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
using DeepDrftPublic.Client.Clients;
using System.Buffers;
using Microsoft.Extensions.Logging;
@@ -15,6 +16,36 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
// Adaptive chunk sizing
private const int MinBufferSize = 16 * 1024; // 16KB minimum
private const int MaxBufferSize = 64 * 1024; // 64KB maximum
// Phase 21.2a back-pressure poll interval. While the scheduler is over its forward high-water
// mark, the segment loop stops fetching the next segment and polls IsProductionPaused at this
// cadence until the fill drains below low-water. 100 ms is well under the low-water lookahead
// (seconds), so resume is prompt relative to the playhead — no starvation (AC3) — while keeping
// the poll cheap. The poll honors the loop's cancellation token, so a track switch/seek during a
// pause exits through the same drain discipline as a pause during ReadAsync (C6).
private const int BackpressurePollMs = 100;
// Phase 21 Direction B — forward Range-segment size. The forward stream is fetched as a
// sequence of bounded "bytes=cursor-(cursor+SegmentSizeBytes-1)" 206 requests, the next issued
// only when the scheduler drains below low-water. Because each request is bounded and fully
// consumed before the next is issued, the browser fetch holds AT MOST ~one segment of raw bytes
// regardless of file size — this is the network-memory bound the phase exists for (the open-ended
// single GET buffered the whole ~970 MB body in the browser even when reads were paused, the
// 21.4 finding). 4 MB balances request overhead (a 1 GB mix is ~250 segments) against memory:
// at the 30 s high-water mark a fast connection holds well under a segment of unplayed raw bytes,
// so the bound is the segment size, not the decoded window. Tunable; not magic.
private const long SegmentSizeBytes = 4 * 1024 * 1024;
// Phase 18 wave 18.6 — position-preserving format switch ("load at timestamp"). When the listener
// changes streaming quality mid-track, the new-format stream is started DIRECTLY at the saved
// position rather than from byte 0: the load resolves the byte offset for the target time in the
// freshly-initialized decoder and streams from there (never audibly playing the start). For WAV the
// byte-offset math needs the header, so the byte-0 segment is probed (fed to the decoder WITHOUT
// starting playback) until the header parses; this caps that probe so a header that never appears
// (corrupt stream) can't read unbounded. The decoder's own header-search ceiling is 256 KB, so this
// matches it. Opus needs no probe (its sidecar resolves offsets immediately after init).
private const int MaxHeaderProbeBytes = 256 * 1024;
private int _currentBufferSize = DefaultBufferSize;
private int _consecutiveSlowReads = 0;
@@ -33,6 +64,12 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
private readonly ILogger<StreamingAudioPlayerService> _logger;
private string? _currentTrackId;
// The delivery format the active load resolved to (Phase 18). Captured once per LoadTrackStreaming and
// reused by the seek-beyond-buffer re-fetch so the Range continuation requests the SAME artifact the
// initial stream did — a seek must never switch formats mid-track (the JS decoder, the cached setup
// header, and the byte offsets all belong to one artifact). Defaults to Lossless until a load resolves.
private AudioFormat _currentFormat = AudioFormat.Lossless;
// Phase 16 play-session telemetry (§2.1). The tracker observes the playback lifecycle and emits at
// most one bucketed play event per session, behind the engagement floor. Attached after construction
// by AudioPlayerProvider (the player is not DI-registered), mirroring how QueueService binds — no
@@ -129,7 +166,30 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
await NotifyStateChanged();
}
private async Task LoadTrackStreaming(TrackDto track)
/// <inheritdoc />
public async Task ReloadPreservingPositionAsync()
{
// Nothing playing → nothing to switch. The new preference simply takes effect on the next play.
if (CurrentTrack is not { } track || !IsStreamingMode) return;
// Capture the position to restore before the reload resets streaming state. Near the very start
// there is nothing worth preserving — a plain restart in the new format is simpler and avoids a
// needless seek-offset resolution.
var resumeAt = CurrentTime;
await EnsureInitializedAsync();
await _audioInterop.EnsureAudioContextReady(PlayerId);
await NotifyTrackSelected();
// Reload the same track in the newly-resolved delivery format. A near-start position restarts
// from byte 0; otherwise the load begins DIRECTLY at the saved position (no audible playback
// from the start). LoadTrackStreaming runs the whole forward segment loop, so this is the last
// meaningful await — the caller already fires this fire-and-forget.
await LoadTrackStreaming(track, startPosition: resumeAt > 1.0 ? resumeAt : null);
await NotifyStateChanged();
}
private async Task LoadTrackStreaming(TrackDto track, double? startPosition = null)
{
// Always reset to clean state before loading new track. ResetToIdle
// both cancels and awaits any in-flight streaming loop, so by the time
@@ -174,22 +234,35 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
await NotifyStateChanged();
// Pass the streaming token to the HTTP layer so a navigation/track switch
// aborts the server connection instead of leaving it draining bytes.
var mediaResult = await _trackMediaClient.GetTrackMedia(
// Resolve the delivery format for this load BEFORE requesting bytes (Phase 18, default policy
// OQ2). When Opus is chosen the sidecar is fetched and injected into the JS player here, ahead of
// InitializeStreaming, honouring the 18.4 set-before-init contract. The result is captured so the
// seek-beyond-buffer re-fetch reuses the same artifact.
_currentFormat = await ResolveStreamFormatAsync(track.EntryKey, loadCts.Token);
// Direction B: fetch the FIRST bounded segment to learn the total file length and the
// content type. The 206 Content-Range carries the total; the segment loop advances its
// cursor toward it. The decoder is initialized with the TOTAL length (not the segment
// length) so a bounded segment's small Content-Length never trips its byte-count
// completion early — segment boundaries are invisible to the decoder, which sees one
// continuous in-order byte stream. Passing the streaming token aborts the server
// connection on a navigation/track switch instead of leaving it draining bytes.
var firstSegment = await _trackMediaClient.GetTrackMedia(
track.EntryKey,
byteOffset: 0,
byteEnd: SegmentSizeBytes - 1,
format: _currentFormat,
cancellationToken: loadCts.Token);
if (!mediaResult.Success)
if (!firstSegment.Success)
{
var technicalError = mediaResult.GetMessage();
var technicalError = firstSegment.GetMessage();
_logger.LogError("Failed to get track media for {TrackId}: {Error}",
track.EntryKey, technicalError);
ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(technicalError);
return;
}
if (mediaResult.Value == null)
if (firstSegment.Value == null)
{
const string technicalError = "No audio returned from server";
_logger.LogError("No audio data returned for track {TrackId}", track.EntryKey);
@@ -197,13 +270,22 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
return;
}
using var audio = mediaResult.Value;
// Ownership of the first segment transfers to the segment loop, which disposes it (and
// every subsequent segment). No `using` here — a double dispose is avoided and the socket
// is released the moment the loop finishes consuming the segment.
var audio = firstSegment.Value;
// Initialize streaming mode with content length and media type (drives
// JS format-decoder selection).
var streamingResult = await _audioInterop.InitializeStreaming(PlayerId, audio.ContentLength, audio.ContentType);
// The total logical length the decoder must see. On a 206 the Content-Range carries it;
// a 200 (server ignored Range / file ≤ one segment) has no Content-Range, so fall back to
// the body's own Content-Length — that body IS the whole file in that case.
var totalLength = audio.TotalLength ?? audio.ContentLength;
// Initialize streaming mode with the TOTAL length and media type (drives JS
// format-decoder selection). See above: total, not segment, length.
var streamingResult = await _audioInterop.InitializeStreaming(PlayerId, totalLength, audio.ContentType);
if (!streamingResult.Success)
{
audio.Dispose();
var technicalError = $"Failed to initialize streaming: {streamingResult.Error}";
_logger.LogError("Streaming initialization failed for track {TrackId}: {Error}",
track.EntryKey, technicalError);
@@ -211,8 +293,23 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
return;
}
_activeStreamingTask = StreamAudioWithEarlyPlayback(audio, loadCts.Token);
await _activeStreamingTask;
if (startPosition is { } startAt)
{
// "Load at timestamp" (Phase 18 wave 18.6 format switch): begin the stream DIRECTLY at
// startAt rather than byte 0, so the listener never hears the track restart from the
// beginning. The byte-0 segment in hand is used only to parse the header for byte-offset
// math (WAV) — Opus resolves the offset from its sidecar with no probe — and then a fresh
// segment is fetched from the resolved offset and pumped via the shared seek/refill loop.
await StartFromPositionAsync(track.EntryKey, audio, totalLength, startAt, loadCts.Token);
}
else
{
// Forward segmentation from byte 0. The first segment is already in hand; the loop pumps
// it, then fetches subsequent bounded segments gated on the scheduler fill signal.
_activeStreamingTask = RunSegmentedStreamAsync(
track.EntryKey, audio, cursor: 0, totalLength, seekPosition: null, loadCts.Token);
await _activeStreamingTask;
}
}
catch (OperationCanceledException) when (loadCts.IsCancellationRequested)
{
@@ -233,10 +330,33 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
catch (Exception ex)
{
StreamingErrorHandler.LogError(_logger, ex, "LoadTrackStreaming", track.EntryKey);
ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(ex.Message);
LoadProgress = 0;
IsLoaded = false;
IsStreamingMode = false;
var userError = StreamingErrorHandler.GetUserFriendlyMessage(ex.Message);
// Mid-stream failure (playback was already underway): halt the JS scheduler into a clean
// paused-but-loaded state exactly as the seek path does via RecoverFromFailedRefill, rather
// than resetting to unloaded and letting the scheduler's buffered tail drain into a silent
// false end (AC6). Apply only when this load is still the active operation — a superseding
// seek owns state and has already replaced _streamingCancellation with its own CTS.
if (_streamingPlaybackStarted && ReferenceEquals(_streamingCancellation, loadCts))
{
await RecoverFromFailedRefill(CurrentTime, userError);
}
else if (ReferenceEquals(_streamingCancellation, loadCts))
{
// First-segment failure (nothing buffered / playing yet), still the active operation:
// the normal unload-to-error path is correct — nothing is in the scheduler to halt.
ErrorMessage = userError;
LoadProgress = 0;
IsLoaded = false;
IsStreamingMode = false;
}
else
{
// Superseded load: a newer seek (or track switch) has already claimed _streamingCancellation
// and owns all shared state. Writing IsLoaded/IsStreamingMode here would corrupt the live
// operation — mirror the OCE catch's identity guard and do nothing to shared state.
_logger.LogDebug("Generic throw on superseded load for track {TrackId} — newer operation owns state, skipping unload", track.EntryKey);
}
}
finally
{
@@ -250,6 +370,178 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
}
}
/// <summary>
/// Begin streaming the freshly-initialized track DIRECTLY at <paramref name="startPosition"/> instead
/// of byte 0 (Phase 18 wave 18.6 — the position-preserving format switch). The decoder has already been
/// built by <c>InitializeStreaming</c>; this resolves the file-absolute byte offset for the target time
/// and then converges onto the shared seek/refill loop (<see cref="RunSegmentedStreamAsync"/> with a
/// non-null seekPosition), which reinitializes the decoder for a header-less Range continuation and
/// starts playback at the target — so nothing is ever audibly played from the start.
/// <para>
/// Opus resolves the offset from its sidecar immediately; WAV needs its header, so the byte-0 segment
/// already in hand is fed to the decoder (WITHOUT starting playback) until the header parses, then the
/// offset resolves. The probe is bounded by <see cref="MaxHeaderProbeBytes"/>. The byte-0 segment is
/// disposed once the header is in hand; the continuation is a fresh fetch from the resolved offset.
/// </para>
/// </summary>
private async Task StartFromPositionAsync(
string trackId,
TrackMediaResponse headerSegment,
long totalLength,
double startPosition,
CancellationToken cancellationToken)
{
// Resolve the byte offset for the target time. Opus answers immediately from its sidecar; WAV
// returns failure until its header is parsed, so we probe the byte-0 segment and retry. The
// byte-0 segment is disposed once it has served its purpose (header probe / nothing for Opus),
// even if the probe throws, so its socket never leaks before the continuation fetch.
SeekResult resolved;
try
{
resolved = await _audioInterop.ResolveStreamOffsetAsync(PlayerId, startPosition);
if (!resolved.Success || !resolved.SeekBeyondBuffer)
{
await ProbeHeaderAsync(headerSegment, cancellationToken);
resolved = await _audioInterop.ResolveStreamOffsetAsync(PlayerId, startPosition);
}
}
finally
{
headerSegment.Dispose();
}
if (!resolved.Success || !resolved.SeekBeyondBuffer)
{
// Could not resolve an offset even after probing — the stream is unusable for a positioned
// start. Surface as an error rather than silently restarting from 0 (which would contradict
// the "preserve position" contract the listener invoked). The catch in LoadTrackStreaming
// settles the error state.
throw new Exception(resolved.Error ?? "Could not resolve a stream offset for the requested position");
}
// Fetch the FIRST bounded segment from the resolved offset and pump it through the shared loop
// exactly as a seek-beyond-buffer does (reinit for the header-less continuation happens inside,
// and playback starts at startPosition). Reuse the format the load already resolved to.
var byteOffset = resolved.ByteOffset;
var firstSegment = await _trackMediaClient.GetTrackMedia(
trackId,
byteOffset,
byteEnd: byteOffset + SegmentSizeBytes - 1,
format: _currentFormat,
cancellationToken: cancellationToken);
if (!firstSegment.Success || firstSegment.Value == null)
{
var technicalError = firstSegment.GetMessage() ?? "Failed to load audio from position";
_logger.LogError("Failed to get track media from offset {Offset} for {TrackId}: {Error}",
byteOffset, trackId, technicalError);
throw new Exception(technicalError);
}
var audio = firstSegment.Value;
// The absolute EOF boundary the segment loop targets. On a 206 the Content-Range carries the file
// total; on a 200 (single-segment tail) fall back to the offset plus this body's length.
var continuationTotal = audio.TotalLength ?? (byteOffset + audio.ContentLength);
// Fresh playback-start transition for the positioned stream (it has not started yet).
_streamingPlaybackStarted = false;
CanStartStreaming = false;
BufferedChunks = 0;
// Reflect the landing position immediately so the UI seek bar shows the right spot while the
// first post-offset buffers decode.
CurrentTime = startPosition;
_activeStreamingTask = RunSegmentedStreamAsync(
trackId, audio, cursor: byteOffset, continuationTotal, seekPosition: startPosition, cancellationToken);
await _activeStreamingTask;
}
/// <summary>
/// Feed bytes from the byte-0 <paramref name="segment"/> into the decoder until its header parses
/// (<see cref="HeaderParsed"/>), WITHOUT starting playback — the WAV byte-offset math needs the header
/// before <see cref="AudioInteropService.ResolveStreamOffsetAsync"/> can answer. Bounded by
/// <see cref="MaxHeaderProbeBytes"/> so a stream that never yields a header cannot read unbounded. The
/// decoded buffers this queues are dropped by the subsequent Range-continuation reinit (clearForSeek),
/// so nothing is audible and nothing leaks. Opus never reaches here (its offset resolves pre-probe).
/// </summary>
private async Task ProbeHeaderAsync(TrackMediaResponse segment, CancellationToken cancellationToken)
{
var buffer = ArrayPool<byte>.Shared.Rent(MaxBufferSize);
try
{
var probed = 0;
while (!HeaderParsed && probed < MaxHeaderProbeBytes)
{
var read = await segment.Stream.ReadAsync(buffer, 0, _currentBufferSize, cancellationToken);
if (read <= 0) break; // segment exhausted before a header — let the caller surface the failure.
probed += read;
// Slice to the exact bytes read (the pooled buffer may carry stale tail bytes).
var chunk = buffer.AsSpan(0, read).ToArray();
var result = await _audioInterop.ProcessStreamingChunk(PlayerId, chunk);
if (!result.Success)
{
throw new Exception($"Failed to process header probe chunk: {result.Error}");
}
HeaderParsed = result.HeaderParsed;
// Capture the once-only duration the header yields so the UI and play session have it.
if (result.Duration.HasValue && Duration == null)
{
Duration = result.Duration.Value;
_playTracker?.SetDuration(result.Duration.Value);
}
}
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
/// <summary>
/// Resolves which delivery format this load should request (Phase 18 default policy, OQ2): Opus when the
/// browser can decode Ogg Opus AND a sidecar exists for the track, otherwise lossless. When Opus is
/// chosen the sidecar is injected into the JS player here (set-before-init, the 18.4 contract) so the
/// decoder has its setup header + seek index before <c>InitializeStreaming</c> builds it.
/// <para>
/// This is the single, deliberately-overridable seam for the listener quality preference (wave 18.6).
/// 18.6 overrides this to honour the user's "streaming quality" toggle — returning lossless when the
/// listener picked it, and otherwise falling through to this capability-gated default. The capability
/// gate (AC7) and the sidecar-absent → lossless fallback (C2) stay here so any override inherits both:
/// a browser that cannot decode Opus, or a track with no sidecar, always lands on lossless and plays.
/// </para>
/// </summary>
protected virtual async Task<AudioFormat> ResolveStreamFormatAsync(string entryKey, CancellationToken cancellationToken)
{
// Capability gate first (AC7): never hand Ogg Opus to a browser that cannot decode it.
if (!await _audioInterop.CanDecodeOggOpus())
{
return AudioFormat.Lossless;
}
// The sidecar must be present (and parseable by the JS decoder) to seek an Opus stream. Its absence
// means the track has no Opus artifact yet (legacy / not backfilled / transcode failed) — request
// lossless rather than Opus-without-a-sidecar (the server would C2-fall-back anyway, but asking for
// lossless keeps the request honest and avoids a wasted Opus-then-fallback round-trip).
var sidecar = await _trackMediaClient.GetOpusSidecarAsync(entryKey, cancellationToken);
if (!sidecar.Success || sidecar.Value is not { Length: > 0 } sidecarBytes)
{
return AudioFormat.Lossless;
}
// Inject BEFORE InitializeStreaming (the set-before-init contract). A parse failure here means the
// bytes are not a usable sidecar — fall back to lossless so a malformed sidecar never breaks playback.
var injected = await _audioInterop.SetOpusSidecar(PlayerId, sidecarBytes);
if (!injected.Success)
{
_logger.LogWarning("Opus sidecar for {EntryKey} failed to parse ({Error}); falling back to lossless.",
entryKey, injected.Error);
return AudioFormat.Lossless;
}
return AudioFormat.Opus;
}
/// <summary>
/// Fetches and decodes the track's waveform loudness profile, then notifies state so the
/// seek zone re-renders with real bars. Best-effort: a 404 (no stored profile) or any other
@@ -311,112 +603,223 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
return profile;
}
private async Task StreamAudioWithEarlyPlayback(TrackMediaResponse audio, CancellationToken cancellationToken)
/// <summary>
/// Phase 21 Direction B — the single segmented forward read loop, shared by the initial load and
/// the seek/refill path (the convergence C1/C5 require: one cursor, one fetch mechanism, no forked
/// path). It pumps the FIRST segment (already fetched by the caller), then fetches subsequent
/// bounded <c>bytes=cursor-(cursor+SegmentSizeBytes-1)</c> 206 segments — each only AFTER the
/// scheduler drains below low-water — until the cursor reaches <paramref name="totalLength"/>.
/// Because each segment is bounded and fully consumed before the next is requested, the browser
/// holds at most ~one segment of raw bytes (the network-memory bound), while the decoder sees one
/// continuous in-order byte stream across segment boundaries (the demuxer/decoder buffer partial
/// frames/pages across the boundary exactly as for arbitrary chunks today — no per-segment reinit).
/// </summary>
/// <param name="firstSegment">The already-fetched first segment (byte <paramref name="cursor"/>).
/// Owned by this method, which disposes it; subsequent segments are fetched and disposed inline.</param>
/// <param name="cursor">File-absolute byte offset the first segment starts at (0 for a fresh load,
/// the resolved seek offset for a refill).</param>
/// <param name="totalLength">Total file length in bytes — the EOF boundary the cursor advances
/// toward. The decoder is initialized/reinitialized against this, not the per-segment length.</param>
/// <param name="seekPosition">Non-null for a seek/refill: the decoder is reinitialized for the
/// header-less Range continuation at this time before the first segment's bytes are fed (WAV
/// retains its header, Opus re-applies the cached setup + lead-trim). Null for a forward load from
/// byte 0, where the first segment carries the header and no reinit is needed.</param>
private async Task RunSegmentedStreamAsync(
string trackId,
TrackMediaResponse firstSegment,
long cursor,
long totalLength,
double? seekPosition,
CancellationToken cancellationToken)
{
byte[]? buffer = null;
var segment = firstSegment;
try
{
long totalBytesRead = 0;
buffer = ArrayPool<byte>.Shared.Rent(MaxBufferSize); // Rent larger buffer to accommodate adaptive sizing
int currentBytes;
var readTimer = System.Diagnostics.Stopwatch.StartNew();
do
// Seek/refill: reinitialize the active decoder for the header-less continuation ONCE,
// before any continuation bytes are fed. Forward-from-zero (seekPosition null) skips this
// — its first segment carries the real header the decoder parses. Done here, inside the
// single loop, so seek and forward share the same fetch+pump mechanism (no forked path).
if (seekPosition is { } resumeAt)
{
readTimer.Restart();
currentBytes = await audio.Stream.ReadAsync(buffer, 0, _currentBufferSize, cancellationToken);
readTimer.Stop();
// Adapt buffer size based on read performance
AdaptBufferSize(currentBytes, readTimer.ElapsedMilliseconds);
if (currentBytes > 0)
// The decoder byte-counts the header-less continuation against the bytes REMAINING
// from the range start to EOF (total cursor), not the absolute total — that is what
// reinitializeForRangeContinuation expects (StreamDecoder.remainingByteLength). The
// loop's own cursor still targets the absolute totalLength for EOF.
var remainingBytes = Math.Max(0, totalLength - cursor);
var reinitResult = await _audioInterop.ReinitializeFromOffset(PlayerId, remainingBytes, resumeAt);
if (!reinitResult.Success)
{
totalBytesRead += currentBytes;
// Always slice to the exact number of bytes read. The pooled buffer
// is rented at MaxBufferSize and may carry stale bytes past
// currentBytes from a prior rental — handing the full array to JS
// interop would serialise that garbage into the audio stream.
var actualBuffer = buffer.AsSpan(0, currentBytes).ToArray();
// Process chunk for streaming
var chunkResult = await _audioInterop.ProcessStreamingChunk(PlayerId, actualBuffer);
if (!chunkResult.Success)
{
var error = $"Failed to process streaming chunk: {chunkResult.Error}";
_logger.LogWarning("Chunk processing failed: {Error}", error);
throw new Exception(error);
}
// Update streaming state
CanStartStreaming = chunkResult.CanStartStreaming;
HeaderParsed = chunkResult.HeaderParsed;
BufferedChunks = chunkResult.BufferCount;
// Set duration from WAV header when available (only set once)
if (chunkResult.Duration.HasValue && Duration == null)
{
Duration = chunkResult.Duration.Value;
_logger.LogInformation("Duration set from WAV header: {Duration:F2} seconds", Duration);
// Feed the same once-only duration to the play session so it can compute the
// completion fraction at close. Safe before/after session open — SetDuration
// is a no-op when no session is open and idempotent otherwise.
_playTracker?.SetDuration(chunkResult.Duration.Value);
}
// Start playback as soon as we can
if (!_streamingPlaybackStarted && CanStartStreaming)
{
var playbackResult = await _audioInterop.StartStreamingPlayback(PlayerId);
if (playbackResult.Success)
{
_streamingPlaybackStarted = true;
IsPlaying = true;
IsPaused = false;
IsLoaded = true; // Track is loaded and ready to play (even if still downloading)
ErrorMessage = null;
// Open the play session exactly once per load, at the moment playback truly
// begins (§2.1). The _sessionOpened guard keeps the SeekBeyondBuffer re-stream
// — which re-enters this transition with _streamingPlaybackStarted reset —
// from opening a second session for the same play. Duration may already be
// known from a prior chunk, so re-feed it after opening.
if (!_sessionOpened && _currentTrackId is { } trackKey)
{
_sessionOpened = true;
_playTracker?.OnPlaybackStarted(trackKey);
if (Duration is { } d)
_playTracker?.SetDuration(d);
}
await NotifyStateChanged(); // Immediate notification for critical state change
}
else
{
var technicalError = $"Failed to start streaming playback: {playbackResult.Error}";
_logger.LogError("Failed to start playback: {Error}", technicalError);
ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(technicalError);
}
}
// Update progress
if (audio.ContentLength > 0)
{
LoadProgress = Math.Min(1.0, (double)totalBytesRead / audio.ContentLength);
}
await ThrottledNotifyStateChanged();
throw new Exception($"Failed to reinitialize for offset streaming: {reinitResult.Error}");
}
} while (currentBytes > 0);
}
// Notify the JS decoder that the stream is finished. When the server omits
// Content-Length the StreamDecoder cannot determine completion via byte counting
// alone; this explicit signal ensures the tail-decoding path (streamComplete=true)
// fires regardless of whether Content-Length was present.
buffer = ArrayPool<byte>.Shared.Rent(MaxBufferSize); // larger rental to fit adaptive sizing
var readTimer = System.Diagnostics.Stopwatch.StartNew();
// Segment loop. Each iteration fully consumes one bounded 206 body, advancing the cursor by
// the bytes received. The next segment is fetched only when the scheduler is below
// high-water (the inter-segment gate). EOF is the cursor reaching totalLength, or a short
// segment (server returned fewer bytes than requested — the final slice).
while (true)
{
long segmentBytesRead = 0;
int currentBytes;
do
{
readTimer.Restart();
currentBytes = await segment.Stream.ReadAsync(buffer, 0, _currentBufferSize, cancellationToken);
readTimer.Stop();
AdaptBufferSize(currentBytes, readTimer.ElapsedMilliseconds);
if (currentBytes > 0)
{
segmentBytesRead += currentBytes;
// Slice to the exact bytes read: the pooled buffer is rented at MaxBufferSize
// and may carry stale bytes past currentBytes from a prior rental — handing the
// full array to JS would serialise that garbage into the audio stream.
var actualBuffer = buffer.AsSpan(0, currentBytes).ToArray();
var chunkResult = await _audioInterop.ProcessStreamingChunk(PlayerId, actualBuffer);
if (!chunkResult.Success)
{
var error = $"Failed to process streaming chunk: {chunkResult.Error}";
_logger.LogWarning("Chunk processing failed: {Error}", error);
throw new Exception(error);
}
CanStartStreaming = chunkResult.CanStartStreaming;
HeaderParsed = chunkResult.HeaderParsed;
BufferedChunks = chunkResult.BufferCount;
// Set duration from header when available (only set once)
if (chunkResult.Duration.HasValue && Duration == null)
{
Duration = chunkResult.Duration.Value;
_logger.LogInformation("Duration set from header: {Duration:F2} seconds", Duration);
// Feed the once-only duration to the play session for the completion
// fraction. No-op when no session is open; idempotent otherwise.
_playTracker?.SetDuration(chunkResult.Duration.Value);
}
// Start playback as soon as we can — at the min-buffer threshold, exactly as
// before (C2: first audio is not gated on the segment boundary; the first
// segment alone clears the threshold).
if (!_streamingPlaybackStarted && CanStartStreaming)
{
await TryStartPlaybackAsync();
}
// Progress against the total file length (cursor + bytes consumed so far).
if (totalLength > 0)
{
LoadProgress = Math.Min(1.0, (double)(cursor + segmentBytesRead) / totalLength);
}
await ThrottledNotifyStateChanged();
// Per-chunk back-pressure — the bound that actually holds for high-density codecs.
// The inter-segment gate alone is matched to WAV's byte density (~24 s of audio per
// 4 MB segment) but NOT to Opus: at 320 kbps a 4 MB segment is ~100 s of decodable
// audio. The inner loop has the whole segment's bytes already in hand, so with no
// network wait to pace it, it would decode the ENTIRE segment eagerly — piling tens
// of MB of decoded f32 PCM AHEAD of a playhead that has barely moved, before the
// inter-segment gate ever runs. With HW accel off that lookahead lives in main-
// process RAM, and the byte ceiling cannot save us because nothing on this path
// polls it. So drain to low-water per chunk once the scheduler is over high-water.
//
// Gated on _streamingPlaybackStarted so this can NEVER block first audio (C2): until
// playback starts the playhead does not advance, so the forward fill would never
// drain and the loop would deadlock. The 30 s high-water sits far above the
// 6-buffer playback-start minimum, so in practice the gate is not even reached
// before playback begins — the guard is the correctness backstop, not the common
// case. Reads the piggybacked flag (no extra interop hop) to DECIDE to drain; the
// drain helper then polls IsProductionPaused — the same steady-state-reads-flag /
// throttled-state-polls split the inter-segment gate uses.
if (_streamingPlaybackStarted && chunkResult.ProductionPaused)
{
await DrainBackpressureAsync(cancellationToken);
}
}
} while (currentBytes > 0);
// Segment fully consumed; advance the cursor and release this segment's stream/socket
// before deciding whether to fetch the next. Disposing here keeps exactly one segment's
// raw bytes resident at a time.
cursor += segmentBytesRead;
segment.Dispose();
segment = null!;
// EOF: cursor reached the total file length. This is the sole forward-EOF condition.
// A short segment body (segmentBytesRead < SegmentSizeBytes) is NOT an EOF signal —
// the inner read loop fully drains the HTTP body, so a short body means the server
// sent fewer bytes than the bounded range we requested. While cursor < totalLength that
// can only be a connection drop / truncated stream, NOT the file tail — route it to
// the same clean-failure recovery as a fetch error rather than silently completing.
var reachedTotal = totalLength > 0 && cursor >= totalLength;
if (reachedTotal)
{
break;
}
// Guard: if the body was short but we haven't reached totalLength, the stream was
// truncated mid-segment (connection drop / premature close). Surface as an error so
// the scheduler is halted rather than left to drain its buffered tail into a false end.
if (segmentBytesRead < SegmentSizeBytes)
{
throw new Exception(
$"Stream truncated at byte {cursor} of {totalLength}: received {segmentBytesRead} bytes " +
$"but expected up to {SegmentSizeBytes} and have not reached EOF");
}
// Inter-segment back-pressure gate (Phase 21.2 fill signal, gating SEGMENT FETCH). Do not
// fetch the next segment while the scheduler is over high-water; wait until it drains
// below low-water. Because the browser only buffers bounded segments and we hold off
// requesting the next one, raw network memory stays at ~one segment. Shares the same
// drain helper as the per-chunk gate above. No _streamingPlaybackStarted guard is needed
// here (unlike the per-chunk gate): reaching this point means a full segment was consumed,
// which is ~24 s (WAV) / ~100 s (Opus) of audio — far past the 6-buffer playback-start
// minimum — so playback is always running by now and the fill can drain. A file that fits
// in one segment hits EOF and breaks above, never reaching this gate.
await DrainBackpressureAsync(cancellationToken);
// Fetch the next bounded segment. The end offset is clamped implicitly by the server
// (a request past EOF yields the available tail as a short slice, caught above).
var nextEnd = cursor + SegmentSizeBytes - 1;
var nextResult = await _trackMediaClient.GetTrackMedia(
trackId,
byteOffset: cursor,
byteEnd: nextEnd,
format: _currentFormat,
cancellationToken: cancellationToken);
if (!nextResult.Success || nextResult.Value == null)
{
var technicalError = nextResult.GetMessage() ?? "Failed to fetch next stream segment";
_logger.LogError("Failed to fetch segment at offset {Offset} for {TrackId}: {Error}",
cursor, trackId, technicalError);
throw new Exception(technicalError);
}
segment = nextResult.Value;
}
// Notify the JS decoder that the stream is finished. The decoder marks completion by byte
// count against the total it was initialized with; this explicit signal flushes the
// residual tail and covers the (rare) case where the total was unknown.
await _audioInterop.MarkStreamCompleteAsync(PlayerId);
// Mark as fully loaded
// Complete-without-start fallback: if the track's total decodable audio never crossed the
// start threshold (e.g. total Opus audio < 1s lead, or WAV < 6 buffers), the in-loop
// CanStartStreaming check never fired and _streamingPlaybackStarted is still false. Now that
// streamComplete is set on the JS scheduler, calling StartStreamingPlayback lets it drain
// the accumulated buffers and fires onPlaybackEnded exactly once — same transition the
// normal path uses, so session/_sessionOpened/Duration handling is identical.
if (!_streamingPlaybackStarted)
{
await TryStartPlaybackAsync();
}
LoadProgress = 1.0;
await NotifyStateChanged();
}
@@ -427,7 +830,7 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
}
catch (Exception ex)
{
StreamingErrorHandler.LogError(_logger, ex, "StreamAudioWithEarlyPlayback");
StreamingErrorHandler.LogError(_logger, ex, "RunSegmentedStreamAsync");
ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(ex.Message);
LoadProgress = 0;
IsLoaded = false;
@@ -437,6 +840,8 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
}
finally
{
// Release the last segment (if a fetch failed mid-loop it may still be held) and the buffer.
segment?.Dispose();
if (buffer != null)
{
ArrayPool<byte>.Shared.Return(buffer);
@@ -444,6 +849,46 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
}
}
/// <summary>
/// Call <c>StartStreamingPlayback</c> on the JS player and apply the resulting state transitions.
/// This is the single playback-start transition shared by the in-loop threshold path and the
/// completion-path fallback — both callers set the guard and apply session/Duration handling
/// identically so neither path diverges.
/// </summary>
private async Task TryStartPlaybackAsync()
{
var playbackResult = await _audioInterop.StartStreamingPlayback(PlayerId);
if (playbackResult.Success)
{
_streamingPlaybackStarted = true;
IsPlaying = true;
IsPaused = false;
IsLoaded = true; // loaded and ready, even while still downloading
ErrorMessage = null;
// Open the play session exactly once per load, at the moment playback
// truly begins (§2.1). The _sessionOpened guard keeps a seek/refill
// re-stream — which re-enters this transition with
// _streamingPlaybackStarted reset — from opening a second session for
// the same play. Duration may already be known, so re-feed it.
if (!_sessionOpened && _currentTrackId is { } trackKey)
{
_sessionOpened = true;
_playTracker?.OnPlaybackStarted(trackKey);
if (Duration is { } d)
_playTracker?.SetDuration(d);
}
await NotifyStateChanged(); // immediate — critical state change
}
else
{
var technicalError = $"Failed to start streaming playback: {playbackResult.Error}";
_logger.LogError("Failed to start playback: {Error}", technicalError);
ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(technicalError);
}
}
/// <summary>
/// In streaming mode, Stop fully resets to Idle state since audio data is consumed.
/// This is equivalent to Unload for streaming playback.
@@ -515,6 +960,10 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
return;
}
// Capture into a non-null local: _currentTrackId is the field a track-switch could clear, but
// this seek operates against the track loaded NOW; the segment loop needs a stable id.
var trackId = _currentTrackId;
IsSeekingBeyondBuffer = true;
// Cancel the current streaming loop AND wait for it to fully exit before
@@ -538,46 +987,68 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
await DrainActiveStreamingTaskAsync();
oldCts?.Dispose();
// Single-writer discipline (C6/AC8): all three failure exits must share the same guard.
// TrackMediaClient.GetTrackMedia swallows OperationCanceledException and returns
// Success==false, so a superseded seek lands in the media-fetch-fail branch below
// rather than in the OCE catch. Without the guard those branches would call
// RecoverFromFailedRefill — running clearForSeek + setPlaybackOffset against the player
// state the NEWER seek now owns. A local predicate keeps all three exits symmetric so a
// future exit cannot forget the check.
bool IsStillActiveSeek() => ReferenceEquals(_streamingCancellation, seekCts);
try
{
// Update UI immediately
CurrentTime = seekPosition;
await NotifyStateChanged();
// Request new stream from offset
var mediaResult = await _trackMediaClient.GetTrackMedia(
_currentTrackId,
// Request the FIRST bounded segment from the resolved offset (Direction B — converged with
// the forward path). Reuse the format the initial load resolved to (_currentFormat): an
// Opus seek must come back as Opus bytes so the cached setup header + page-aligned
// byteOffset (resolved JS-side from the Opus seek index) match the continuation; WAV resolves
// its offset from the header — one seam, format-appropriate math (AC9 / §3.4a C). The
// segment loop then continues forward segmentation from this offset exactly as a fresh load
// does from 0 — no forked fetch path (C1/C5).
var firstSegment = await _trackMediaClient.GetTrackMedia(
trackId,
byteOffset,
byteEnd: byteOffset + SegmentSizeBytes - 1,
format: _currentFormat,
cancellationToken: seekCts.Token);
if (!mediaResult.Success || mediaResult.Value == null)
if (!firstSegment.Success || firstSegment.Value == null)
{
var technicalError = mediaResult.GetMessage() ?? "Failed to load audio from position";
var technicalError = firstSegment.GetMessage() ?? "Failed to load audio from position";
_logger.LogError("Failed to get track media from offset {Offset}: {Error}", byteOffset, technicalError);
ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(technicalError);
IsSeekingBeyondBuffer = false;
// Guard: a superseded seek must NOT touch shared state. The newer seek owns teardown.
if (IsStillActiveSeek())
{
await RecoverFromFailedRefill(seekPosition, StreamingErrorHandler.GetUserFriendlyMessage(technicalError));
}
else
{
_logger.LogDebug("Media-fetch failed on superseded seek to {Position} — newer seek owns state, skipping recovery", seekPosition);
}
return;
}
using var audio = mediaResult.Value;
var audio = firstSegment.Value;
// The absolute EOF boundary the segment loop's cursor targets. On a 206 the Content-Range
// carries the file total; on a 200 (single-segment file) fall back to cursor + body length.
var totalLength = audio.TotalLength ?? (byteOffset + audio.ContentLength);
// Reinitialize JS player for offset streaming
var reinitResult = await _audioInterop.ReinitializeFromOffset(PlayerId, audio.ContentLength, seekPosition);
if (!reinitResult.Success)
{
_logger.LogError("Failed to reinitialize for offset streaming: {Error}", reinitResult.Error);
ErrorMessage = "Failed to seek to position";
IsSeekingBeyondBuffer = false;
return;
}
// Reset streaming state for new stream
// Reset streaming state for the new stream. The decoder reinit for the header-less
// continuation happens INSIDE RunSegmentedStreamAsync (seekPosition non-null), so seek and
// forward share one fetch+pump+reinit mechanism. A reinit failure there throws and lands in
// the catch below, which recovers when still the active seek — the same clean-failure path
// (AC6) the old explicit reinit branch had, now unified with the fetch-failure path.
_streamingPlaybackStarted = false;
CanStartStreaming = false;
HeaderParsed = false;
BufferedChunks = 0;
// Stream audio from offset
_activeStreamingTask = StreamAudioWithEarlyPlayback(audio, seekCts.Token);
// Stream from offset via the shared segment loop. Ownership of `audio` transfers to it.
_activeStreamingTask = RunSegmentedStreamAsync(
trackId, audio, cursor: byteOffset, totalLength, seekPosition, seekCts.Token);
await _activeStreamingTask;
IsSeekingBeyondBuffer = false;
@@ -588,22 +1059,65 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
// still the active seek — if _streamingCancellation has been replaced, a
// newer seek is in progress and owns the flag.
_logger.LogDebug("Seek beyond buffer cancelled");
if (ReferenceEquals(_streamingCancellation, seekCts))
if (IsStillActiveSeek())
{
IsSeekingBeyondBuffer = false;
}
}
catch (Exception ex)
{
// A refill fetch can fail deep into a long mix (the listener didn't initiate it). Recover
// into a clean paused-but-loaded state (AC6) rather than leaving the starved scheduler to
// fire a silent false end. Only when we are still the active seek — a superseding seek owns
// the state and the OCE catch above handles its own teardown.
_logger.LogError(ex, "Error during seek beyond buffer to position {Position}", seekPosition);
ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(ex.Message);
IsSeekingBeyondBuffer = false;
await NotifyStateChanged();
if (IsStillActiveSeek())
{
await RecoverFromFailedRefill(seekPosition, StreamingErrorHandler.GetUserFriendlyMessage(ex.Message));
}
}
}
/// <summary>
/// Single method to reset all state - called by both Stop and Unload.
/// Clean-failure recovery for a window-miss refill (Phase 21.3 / AC6). A backward seek past the
/// retained tail re-fetches via the existing Range path; that mid-stream fetch the listener did not
/// initiate can fail deep into a long mix. When it does, the pre-seek loop has already been
/// cancelled and drained, but the JS scheduler is still holding stale pre-seek buffers and still
/// "playing" — left alone it drains them and fires a silent false end (the wedged/starved state AC6
/// forbids). This halts the scheduler into a paused-but-loaded state at <paramref name="seekPosition"/>,
/// surfaces a clear error, and leaves the track loaded so the listener can retry the seek or pick
/// another track. Mirrors <c>PlaybackScheduler.playFromPosition</c>'s end-of-buffer recovery: stop
/// pretending to play.
/// </summary>
private async Task RecoverFromFailedRefill(double seekPosition, string userFacingError)
{
// Halt the starved scheduler JS-side (stop sources, drop stale buffers, anchor at the target).
// Best-effort: if even this interop fails the player is no worse off, and we still surface the
// error and settle C# state below.
var recovered = await _audioInterop.RecoverFromFailedRefill(PlayerId, seekPosition);
if (!recovered.Success)
{
_logger.LogWarning("Refill-failure recovery interop did not succeed: {Error}", recovered.Error);
}
// Settle C# into the matching recoverable state: not playing, paused at the target, still loaded
// and still in streaming mode. IsLoaded = true and IsStreamingMode = true are both load-bearing —
// the "paused-but-loaded" contract lets the listener retry the seek (Seek early-returns when
// !IsLoaded || !IsStreamingMode), resume via TogglePlayPause, or pick another track. Resetting
// either to false would wedge at least one of the three retry routes (AC6 / Phase 21.3).
ErrorMessage = userFacingError;
IsPlaying = false;
IsPaused = true;
IsLoaded = true;
IsStreamingMode = true;
CurrentTime = seekPosition;
IsSeekingBeyondBuffer = false;
await NotifyStateChanged();
}
/// <summary>
/// Single method to reset all state - called by both Stop and Unload, and as the prologue of a new
/// load.
/// </summary>
private async Task ResetToIdle()
{
@@ -653,6 +1167,7 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
_streamingPlaybackStarted = false;
IsSeekingBeyondBuffer = false;
_currentTrackId = null;
_currentFormat = AudioFormat.Lossless;
await NotifyStateChanged();
}
@@ -691,6 +1206,27 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
}
}
/// <summary>
/// Block the segment loop while the scheduler's decoded forward fill is over high-water, resuming
/// once it drains below low-water (Phase 21.2 hysteresis). Shared by the per-chunk gate (inside a
/// segment) and the inter-segment gate so both honor identical drain discipline — a guard present on
/// one path and absent on the other would let one path overshoot the memory bound.
/// <para>
/// The poll awaits on <paramref name="cancellationToken"/>, so a track switch/seek mid-wait throws
/// OCE and unwinds through the existing drain discipline (C6). UC5: a user pause freezes the playhead
/// so the fill never drains on its own — hold here until playback resumes (IsPaused clears) OR the
/// fill drains. Returns immediately when nothing is throttled (the steady-state common case).
/// </para>
/// </summary>
private async Task DrainBackpressureAsync(CancellationToken cancellationToken)
{
while (IsPaused || await _audioInterop.IsProductionPaused(PlayerId))
{
cancellationToken.ThrowIfCancellationRequested();
await Task.Delay(BackpressurePollMs, cancellationToken);
}
}
private async Task ThrottledNotifyStateChanged()
{
var now = DateTime.UtcNow;
@@ -160,6 +160,47 @@ public sealed class WaveformVisualizerControlState
/// </summary>
public event Action? Changed;
// Whether the one-time, capability-driven default has been applied this session. The default-set
// (lava off when the browser has no WebGL hardware acceleration) must run exactly once — on the
// first interactive render, before the listener has touched a toggle — so it sets the *initial
// default* and never clobbers a later explicit in-session toggle. Scoped with the rest of this
// state, so it survives SPA navigation (a remounted visualizer does not re-apply) and resets on a
// fresh page load (F5 re-probes).
private bool _capabilityDefaultApplied;
/// <summary>
/// Applies the hardware-capability default exactly once per session: when the browser reports no
/// WebGL hardware acceleration, the lava subsystem (the expensive, main-thread software-rendered
/// part that starves audio decode) defaults <c>off</c> while the waveform stays <c>on</c>. With
/// acceleration present this is a no-op — lava keeps its <see cref="DefaultLavaEnabled"/> on-state.
///
/// <para>
/// Idempotent and guarded: only the FIRST call this session has any effect, so it sets the initial
/// default and never overrides a listener's explicit toggle (the control remains fully functional —
/// a user on a software renderer may re-enable lava at their own risk). Mutates, coerces
/// Theater Mode, then raises <see cref="Changed"/> once so the controls UI, the visualizer bridge,
/// and the Theater observers all reflect the default in a single cycle. Called by the visualizer
/// bridge on first interactive render, once JS interop (the probe) is available.
/// </para>
/// </summary>
/// <param name="hardwareAccelerated">
/// The probe result — <c>true</c> when WebGL hardware acceleration is present (or the renderer is
/// unknown/masked, favoring the common case), <c>false</c> only on a positive software-renderer
/// match or total WebGL failure.
/// </param>
public void ApplyCapabilityDefault(bool hardwareAccelerated)
{
if (_capabilityDefaultApplied) return;
_capabilityDefaultApplied = true;
// Accelerated (or unknown): keep the as-shipped defaults — no observer churn.
if (hardwareAccelerated) return;
LavaEnabled = false; // the expensive subsystem off; WaveformEnabled stays at its default (true).
CoerceTheaterMode();
NotifyChanged();
}
/// <summary>
/// Enforces the Theater-Mode invariant: Theater Mode cannot remain on when both visualizer
/// subsystems are off (there is nothing to go to theater FOR). Call this after mutating
+7
View File
@@ -14,6 +14,13 @@ public static class Startup
services.AddScoped<DarkModeSettings>();
services.AddScoped<DarkModeCookieService>();
// Public-site listener settings (Phase 18 wave 18.6). PublicSiteSettings is the generalized,
// prerender-seeded preference object (today: streaming quality); SettingsCookieService writes the
// 365-day cookie at runtime. Same scoped lifetime + cookie seam as the dark-mode pair above, so the
// preference survives SPA nav within a session and seeds the next visit's prerender.
services.AddScoped<PublicSiteSettings>();
services.AddScoped<SettingsCookieService>();
// Track Client. The HTTP-backed ITrackDataService is used by both WASM and SSR
// prerender — both call DeepDrftAPI over the "DeepDrft.API" client.
services.AddScoped<TrackClient>();