From 561f4a500af9dfc92715c79038ab8de66b22ec55 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 9 Jun 2026 07:07:57 -0400 Subject: [PATCH] =?UTF-8?q?docs:=20close=20Phase=204.1=20and=204.2=20?= =?UTF-8?q?=E2=80=94=20move=20to=20COMPLETED.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- COMPLETED.md | 40 +++++++++++++++++++++++++++++++++ DeepDrftPublic.Client/CLAUDE.md | 1 + PLAN.md | 31 ------------------------- 3 files changed, 41 insertions(+), 31 deletions(-) diff --git a/COMPLETED.md b/COMPLETED.md index b5fc6f9..b569317 100644 --- a/COMPLETED.md +++ b/COMPLETED.md @@ -6,6 +6,46 @@ Newest entries at the top. Group by phase/wave header (mirroring `PLAN.md` / `CM --- +## Phase 4.1 — HTTP Range + CDN caching + +**Status:** Fully landed on 2026-06-09 (implementation complete, all acceptance criteria met, merged to dev branch `p4-w1-range-streaming`). + +- **What:** Today's `?offset=` query parameter defeats HTTP caching — a CDN sees `?offset=1234567` as a distinct URL from the un-offset request. The architecture re-invents byte-range on top of a custom query param. Move the player's transport to standard HTTP `Range` headers against one canonical URL. +- **Why it matters:** Material once the site has real listener traffic. Also relevant to non-WAV formats (1.2) where decoder-side seek is cheaper natively. +- **Chosen approach (design pass 2026-06-09): Option A1 — Range headers in the JS fetch, keep the custom `AudioBuffer` decoder.** Rejected Option B (`MediaElementAudioSourceNode`): it surrenders early-playback (the `minBuffersForPlayback` start-as-soon-as-buffered behaviour, a listed quality feature) and forces a redesign of the waveform-seek and early-play UX, while delivering no caching benefit beyond what the HTTP layer already gives. Also rejected A2 (synthesised header delivered over Range): keeping `WavOffsetService` on the hot path means each `bytes=X-` request produces a distinct synthesised prefix that can't share cache lineage with the canonical `bytes=0-` object, defeating half the caching win. A1 makes the cached object the *real file*, so every Range request is a true sub-range of one entity. Key enabling insight: `StreamDecoder` already synthesises a per-segment 44-byte header internally for every `decodeAudioData` call (`createWavFile`), so a Range continuation only needs to *retain* the parsed `WavHeader` and feed raw PCM — it does not need a header in the network stream. +- **Shape (implementation direction):** + - **Server (`DeepDrftAPI/Controllers/TrackController.cs` ~L407):** flip `enableRangeProcessing: false → true` on the no-offset seekable `FileStream` path; ASP.NET Core slices natively and emits `206` + `Content-Range`. Leave the `?offset=` / `WavOffsetService` branch reachable but off the player hot path — its removal is a clean follow-up commit, not part of this change. + - **Proxy (`DeepDrftPublic/Controllers/TrackProxyController.cs` ~L175):** forward the incoming `Range` request header upstream; pass through upstream status (`206`/`200`/`416`) and the `Content-Range` / `Accept-Ranges` / `Content-Length` response headers verbatim. The proxy is a transparent relay — it does **not** slice the (non-seekable) upstream stream. Keep `ResponseHeadersRead` + `RegisterForDispose`. + - **Client transport (`DeepDrftPublic.Client/Clients/TrackMediaClient`):** send `Range: bytes={byteOffset}-` instead of the `?offset=` query param (`byteOffset == 0` → `bytes=0-`, single code path). Confirm `TrackMediaResponse.ContentLength` carries the 206 remaining-length for continuations and full length for the initial request. + - **JS decoder (`StreamDecoder.ts` — the real work):** add a continuation mode. Replace `reinitializeForOffset` (which nulls `wavHeader` and re-parses) with a `reinitializeForRangeContinuation(remainingByteLength)` that **retains** the parsed `WavHeader`, resets `rawChunks`/`totalRawBytes`/`processedBytes`/`streamComplete`, and routes incoming bytes straight to `addRawData` (the existing `if (!this.wavHeader)` branch already does this when the header is set). Add an `isContinuation` flag so `updateStreamCompleteFlag()` uses `totalRawBytes` **without** the `+ headerSize` addend on continuations. `createWavFile`, the decode pipeline, and the spectrum/level tap are all unchanged. + - **`AudioPlayer.ts` / `index.ts`:** keep the public `reinitializeFromOffset` interop name (so `AudioInteropService` and the C# caller are untouched); internally call the continuation reinit. C# `StreamingAudioPlayerService.SeekBeyondBuffer` is otherwise unchanged. +- **Acceptance criteria:** + 1. Initial load sends `Range: bytes=0-`; server responds `206`/`200` with `Accept-Ranges: bytes`; time-to-first-audio unchanged (early playback after `minBuffersForPlayback`). + 2. Seek-beyond-buffer sends `Range: bytes=X-` (block-aligned, file-absolute X) with **no `?offset=` anywhere**; server responds `206` + `Content-Range`; audio resumes with no click/pop and no header bytes leaking into PCM. + 3. Displayed total duration is unchanged across a seek (original full-track duration, not remaining-segment). + 4. A track seeked-near-end then played out fires the end callback exactly once (continuation `streamComplete` math correct). + 5. Spectrum visualiser and `LevelMeterFab` behave identically pre/post on a loud master (−3 dBFS). + 6. Same-URL invariant: two different-offset requests hit an identical URL differing only in the `Range` header (verifiable in the network panel; live CDN cache-hit verification is out of scope — no CDN in dev). + 7. No `MediaElement` introduced; the `AudioBufferSourceNode` graph remains the playback path. +- **Constraints (non-obvious):** + - **Range offset is file-absolute, not audio-relative.** The old `?offset=` contract was audio-data-relative (`WavOffsetService` added `HeaderSize` server-side). The Range offset must be `header.headerSize + blockAlignedAudioOffset`. Omitting `headerSize` lands the seek ~44 bytes early — audible click + position drift. **Most likely bug; verify first.** + - Only the *continuation* skips header parse; the initial `bytes=0-` response still flows through `tryParseHeader` unchanged. Don't let the continuation flag bleed into initial load. + - Proxy must pass `Accept-Ranges` / `Content-Range` (and a `416`) through verbatim — stripping them blinds the browser and any future CDN. + - A1 preserves the multi-format (1.2) seam: the decoder stays the format integration point; the "retain format, skip header, treat bytes as frame data" pattern generalises (frame-boundary alignment differs per format). Add no new WAV-specific coupling in the transport/proxy layers beyond what already exists. + +--- + +## Phase 4.2 — Server-side stream from disk (no buffer materialisation) + +**Status:** Resolved as a consequence of Phase 4.1 landing on 2026-06-09. No separate implementation required. + +- **What:** The no-offset path **already** streams from disk — `TrackController` (~L390) takes `mediaStream.Stream` (a `FileStream` from `LoadResourceStreamAsync`), reads `streamLength` from `.Length`, and hands ownership to `File(...)`; no `LoadResourceAsync` buffer materialisation on the default path. The remaining buffer materialisation is **only** the legacy `?offset=` branch (~L414): `GetAudioBinaryAsync` loads the full `AudioBinary` into memory because `WavOffsetService` reslices over the in-memory buffer. +- **Why it matters:** Scaling ceiling on the offset path specifically. Once 4.1 (A1) lands, the offset branch is off the player hot path, so its buffer cost stops mattering in practice. +- **Shape:** Resolved for the default path. The only outstanding work is retiring the offset branch entirely — which is the 4.1 follow-up commit (remove the `?offset=` server branch, `WavOffsetService`, and the now-unused `ConcatStream`). No separate work item beyond that cleanup. +- **Outcome:** With Phase 4.1 landing and Range headers replacing the `?offset=` query param as the transport mechanism, the offset branch is now definitively off the player's hot path. Buffer materialisation on that dormant code path is no longer a scaling concern. 4.2 is closed; the offset-branch cleanup is a follow-up housekeeping item, not a blocker. + +--- + ## Phase 2.4 — Interactivity-gap loading guard on dead-during-prerender controls **Status:** Fully landed on 2026-06-08 (implementation complete, reviewed and merged to dev). diff --git a/DeepDrftPublic.Client/CLAUDE.md b/DeepDrftPublic.Client/CLAUDE.md index 871e48f..7c53dc0 100644 --- a/DeepDrftPublic.Client/CLAUDE.md +++ b/DeepDrftPublic.Client/CLAUDE.md @@ -17,6 +17,7 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream - `TracksGallery.razor`: Responsive grid of `TrackCard` items (MudBlazor `MudGrid` with breakpoints). Fully controlled by parent; derives active-track state from cascaded player service. - `AppNavLink.razor`: Nav link with active-page highlight. - `AudioPlayerProvider.razor`: Cascading host for `IStreamingPlayerService`. Everything inside it gets the player via `[CascadingParameter]`. + - `StreamNowButton.razor`: Reusable streaming-trigger button. Fetches a random track, warms the AudioContext (Safari gesture requirement), and starts streaming via `IStreamingPlayerService`. Accepts `ButtonClass` and `ButtonLabel` for distinct visual presentations; `OnStreamStarted` EventCallback for post-stream side effects (e.g., mobile menu close). - `AudioPlayerBar.razor`: Dock UI at the bottom (play/pause/seek/volume). - `AudioPlayerBar/PlayerControls.razor`: Play/pause/stop buttons in the transport zone. Renders via ``. - `AudioPlayerBar/PlayStateIcon.razor`: Icon button encapsulating service subscription + transport-state icon selection. Injects `IPlayerService`, subscribes to `StateChanged`, calls `PlaybackIcons.Resolve()` to determine icon and active state. diff --git a/PLAN.md b/PLAN.md index 5613491..30b2f65 100644 --- a/PLAN.md +++ b/PLAN.md @@ -117,37 +117,6 @@ These follow from `CONTEXT.md §5`. Direction is strongly implied but no specifi ## Phase 4 — Infrastructure / delivery -### 4.1 HTTP Range + CDN caching - -- **What:** Today's `?offset=` query parameter defeats HTTP caching — a CDN sees `?offset=1234567` as a distinct URL from the un-offset request. The architecture re-invents byte-range on top of a custom query param. Move the player's transport to standard HTTP `Range` headers against one canonical URL. -- **Why it matters:** Material once the site has real listener traffic. Also relevant to non-WAV formats (1.2) where decoder-side seek is cheaper natively. -- **Chosen approach (design pass 2026-06-09): Option A1 — Range headers in the JS fetch, keep the custom `AudioBuffer` decoder.** Rejected Option B (`MediaElementAudioSourceNode`): it surrenders early-playback (the `minBuffersForPlayback` start-as-soon-as-buffered behaviour, a listed quality feature) and forces a redesign of the waveform-seek and early-play UX, while delivering no caching benefit beyond what the HTTP layer already gives. Also rejected A2 (synthesised header delivered over Range): keeping `WavOffsetService` on the hot path means each `bytes=X-` request produces a distinct synthesised prefix that can't share cache lineage with the canonical `bytes=0-` object, defeating half the caching win. A1 makes the cached object the *real file*, so every Range request is a true sub-range of one entity. Key enabling insight: `StreamDecoder` already synthesises a per-segment 44-byte header internally for every `decodeAudioData` call (`createWavFile`), so a Range continuation only needs to *retain* the parsed `WavHeader` and feed raw PCM — it does not need a header in the network stream. -- **Shape (implementation direction):** - - **Server (`DeepDrftAPI/Controllers/TrackController.cs` ~L407):** flip `enableRangeProcessing: false → true` on the no-offset seekable `FileStream` path; ASP.NET Core slices natively and emits `206` + `Content-Range`. Leave the `?offset=` / `WavOffsetService` branch reachable but off the player hot path — its removal is a clean follow-up commit, not part of this change. - - **Proxy (`DeepDrftPublic/Controllers/TrackProxyController.cs` ~L175):** forward the incoming `Range` request header upstream; pass through upstream status (`206`/`200`/`416`) and the `Content-Range` / `Accept-Ranges` / `Content-Length` response headers verbatim. The proxy is a transparent relay — it does **not** slice the (non-seekable) upstream stream. Keep `ResponseHeadersRead` + `RegisterForDispose`. - - **Client transport (`DeepDrftPublic.Client/Clients/TrackMediaClient`):** send `Range: bytes={byteOffset}-` instead of the `?offset=` query param (`byteOffset == 0` → `bytes=0-`, single code path). Confirm `TrackMediaResponse.ContentLength` carries the 206 remaining-length for continuations and full length for the initial request. - - **JS decoder (`StreamDecoder.ts` — the real work):** add a continuation mode. Replace `reinitializeForOffset` (which nulls `wavHeader` and re-parses) with a `reinitializeForRangeContinuation(remainingByteLength)` that **retains** the parsed `WavHeader`, resets `rawChunks`/`totalRawBytes`/`processedBytes`/`streamComplete`, and routes incoming bytes straight to `addRawData` (the existing `if (!this.wavHeader)` branch already does this when the header is set). Add an `isContinuation` flag so `updateStreamCompleteFlag()` uses `totalRawBytes` **without** the `+ headerSize` addend on continuations. `createWavFile`, the decode pipeline, and the spectrum/level tap are all unchanged. - - **`AudioPlayer.ts` / `index.ts`:** keep the public `reinitializeFromOffset` interop name (so `AudioInteropService` and the C# caller are untouched); internally call the continuation reinit. C# `StreamingAudioPlayerService.SeekBeyondBuffer` is otherwise unchanged. -- **Acceptance criteria:** - 1. Initial load sends `Range: bytes=0-`; server responds `206`/`200` with `Accept-Ranges: bytes`; time-to-first-audio unchanged (early playback after `minBuffersForPlayback`). - 2. Seek-beyond-buffer sends `Range: bytes=X-` (block-aligned, file-absolute X) with **no `?offset=` anywhere**; server responds `206` + `Content-Range`; audio resumes with no click/pop and no header bytes leaking into PCM. - 3. Displayed total duration is unchanged across a seek (original full-track duration, not remaining-segment). - 4. A track seeked-near-end then played out fires the end callback exactly once (continuation `streamComplete` math correct). - 5. Spectrum visualiser and `LevelMeterFab` behave identically pre/post on a loud master (−3 dBFS). - 6. Same-URL invariant: two different-offset requests hit an identical URL differing only in the `Range` header (verifiable in the network panel; live CDN cache-hit verification is out of scope — no CDN in dev). - 7. No `MediaElement` introduced; the `AudioBufferSourceNode` graph remains the playback path. -- **Constraints (non-obvious):** - - **Range offset is file-absolute, not audio-relative.** The old `?offset=` contract was audio-data-relative (`WavOffsetService` added `HeaderSize` server-side). The Range offset must be `header.headerSize + blockAlignedAudioOffset`. Omitting `headerSize` lands the seek ~44 bytes early — audible click + position drift. **Most likely bug; verify first.** - - Only the *continuation* skips header parse; the initial `bytes=0-` response still flows through `tryParseHeader` unchanged. Don't let the continuation flag bleed into initial load. - - Proxy must pass `Accept-Ranges` / `Content-Range` (and a `416`) through verbatim — stripping them blinds the browser and any future CDN. - - A1 preserves the multi-format (1.2) seam: the decoder stays the format integration point; the "retain format, skip header, treat bytes as frame data" pattern generalises (frame-boundary alignment differs per format). Add no new WAV-specific coupling in the transport/proxy layers beyond what already exists. - -### 4.2 Server-side stream from disk (no buffer materialisation) - -- **What:** The no-offset path **already** streams from disk — `TrackController` (~L390) takes `mediaStream.Stream` (a `FileStream` from `LoadResourceStreamAsync`), reads `streamLength` from `.Length`, and hands ownership to `File(...)`; no `LoadResourceAsync` buffer materialisation on the default path. The remaining buffer materialisation is **only** the legacy `?offset=` branch (~L414): `GetAudioBinaryAsync` loads the full `AudioBinary` into memory because `WavOffsetService` reslices over the in-memory buffer. -- **Why it matters:** Scaling ceiling on the offset path specifically. Once 4.1 (A1) lands, the offset branch is off the player hot path, so its buffer cost stops mattering in practice. -- **Shape:** Resolved for the default path. The only outstanding work is retiring the offset branch entirely — which is the 4.1 follow-up commit (remove the `?offset=` server branch, `WavOffsetService`, and the now-unused `ConcatStream`). No separate work item beyond that cleanup. - ### 4.3 Dual-write rollback / dead-letter log - **What:** If content-side write succeeds and SQL-side write fails, audio is orphaned in the vault. No compensating mechanism exists.