Phase 21.3: seek-back-past-window refill + clean refill-failure recovery

Seek-back past the retained tail reuses the existing seek-beyond-buffer
Range path (per-path resolver). A failed refill now halts the scheduler
into a paused-but-loaded state (AC6) instead of a silent false end.
This commit is contained in:
daniel-c-harvey
2026-06-23 23:43:17 -04:00
parent 121983b19d
commit af4cb186f3
5 changed files with 370 additions and 13 deletions
+55 -6
View File
@@ -432,18 +432,29 @@ export class AudioPlayer {
return { success: false, error: 'Invalid seek position' };
}
// bufferStart is the absolute track time at which buffers[0] begins. Under Phase 21.1
// partial eviction this is the start of the RETAINED BACK-WINDOW TAIL — eviction advances
// playbackOffset as it drops played buffers off the front — so [bufferStart, bufferEnd] is
// exactly the window currently held in memory.
const bufferStart = this.scheduler.getPlaybackOffset();
const bufferEnd = this.scheduler.getTotalDuration() + bufferStart;
// Position must be within [bufferStart, bufferEnd] to use buffered content.
// A lower-bound check is required: after a seek-beyond-buffer, bufferStart is
// set to the prior seek position. Seeking to a position below bufferStart would
// produce a negative bufferRelativePosition in seekWithinBuffer, silently
// clamping to position 0 of the offset buffer instead of the requested time.
// The window-miss test for BOTH directions, and the 21.3 refill trigger for backward seeks.
// Position must be within [bufferStart, bufferEnd] to resolve from the retained buffers:
// - position >= bufferStart : UC3 — seek back within the retained back-window. Served from
// buffer with NO network refetch. (The lower bound is load-bearing: after eviction or a
// prior seek-beyond-buffer, bufferStart > 0, and a target below it would otherwise produce
// a negative bufferRelativePosition in seekWithinBuffer, silently clamping to position 0.)
// - position < bufferStart : UC4 — seek back PAST the retained tail (the window was evicted).
// Falls through to seekBeyondBuffer, which is the existing Range path run toward an EARLIER
// offset. This is the 21.3 window-miss refill: "a seek the listener didn't initiate" reuses
// the same per-path resolver + reinit a forward seek-beyond-buffer uses, no new mechanism.
// - position > bufferEnd : UC2/UC5 — forward seek beyond buffer, unchanged.
if (position >= bufferStart && position <= bufferEnd) {
return this.seekWithinBuffer(position);
} else {
// Seeking outside buffered window - signal C# to fetch new stream
// Seeking outside the retained window - signal C# to fetch a new stream from the resolved
// offset (earlier for a back-past-window refill, later for a forward seek).
return this.seekBeyondBuffer(position);
}
}
@@ -580,6 +591,44 @@ export class AudioPlayer {
}
}
/**
* Recover the player into a clean, paused-but-loaded state after a window-miss REFILL failed
* (Phase 21.3 / AC6). A refill is "a seek the listener didn't initiate"; when its Range fetch or
* reinit fails mid-stream, the pre-seek loop has already been cancelled and drained, but the
* scheduler is still holding stale pre-seek buffers and is still `isActive_`. Left alone it would
* play the retained tail to exhaustion and fire `onPlaybackEnded` — a SILENT FALSE END (the
* "wedged playing with a starved scheduler" AC6 forbids).
*
* The recovery mirrors `PlaybackScheduler.playFromPosition`'s end-of-buffer recovery in spirit:
* stop pretending to play. We stop all sources and clear the buffers for a seek (clearForSeek
* keeps no stale audio but is ready to accept a fresh continuation), set the offset to the
* requested seek position, and leave the player paused there. The track stays loaded so the
* listener can retry the seek or pick another track — no new transport control, only a recoverable
* stop (C4). A subsequent seek to the same target re-enters seekBeyondBuffer cleanly because the
* offset names the seek position and the scheduler is empty (so it routes to a fresh fetch).
*
* @param seekPosition The seek target the failed refill was aiming for; becomes the resume anchor.
*/
recoverFromFailedRefill(seekPosition: number): AudioResult {
try {
this.stopProgressTracking();
// Halt the starved scheduler and drop the stale pre-seek buffers so no false end can fire.
this.scheduler.clearForSeek();
this.scheduler.setPlaybackOffset(seekPosition);
// Paused-but-loaded: not playing, not mid-seek-stream. pausePosition anchors a retry.
this.isPlaying = false;
this.isPaused = true;
this.pausePosition = seekPosition;
this.streamingStarted = false;
this.streamingCompleted = false;
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
// ==================== Volume ====================
setVolume(volume: number): AudioResult {