Merge Phase 21.3 (seek-back-past-window refill + AC6 recovery) into streaming-overhaul

This commit is contained in:
daniel-c-harvey
2026-06-23 23:58:51 -04:00
6 changed files with 460 additions and 16 deletions
@@ -183,6 +183,18 @@ public class AudioInteropService : IAsyncDisposable
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);
@@ -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)
@@ -629,6 +629,15 @@ 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
@@ -649,8 +658,15 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
{
var technicalError = mediaResult.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;
}
@@ -661,8 +677,15 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
if (!reinitResult.Success)
{
_logger.LogError("Failed to reinitialize for offset streaming: {Error}", reinitResult.Error);
ErrorMessage = "Failed to seek to position";
IsSeekingBeyondBuffer = false;
// Guard: same single-writer discipline — only recover when we are still the active seek.
if (IsStillActiveSeek())
{
await RecoverFromFailedRefill(seekPosition, "Failed to seek to position");
}
else
{
_logger.LogDebug("Reinit failed on superseded seek to {Position} — newer seek owns state, skipping recovery", seekPosition);
}
return;
}
@@ -684,19 +707,55 @@ 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);
if (IsStillActiveSeek())
{
await RecoverFromFailedRefill(seekPosition, StreamingErrorHandler.GetUserFriendlyMessage(ex.Message));
}
}
}
/// <summary>
/// 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.
ErrorMessage = userFacingError;
IsPlaying = false;
IsPaused = true;
CurrentTime = seekPosition;
IsSeekingBeyondBuffer = false;
await NotifyStateChanged();
}
}
/// <summary>
/// Single method to reset all state - called by both Stop and Unload.
@@ -0,0 +1,298 @@
/**
* AudioPlayer window-miss refill tests (Phase 21.3) — the seek-dispatch TRIGGER and the AC6
* clean-failure recovery.
*
* What this pins (the genuinely-new 21.3 work):
* - The window-miss TRIGGER. AudioPlayer.seek() routes by whether the target falls inside the
* retained window [playbackOffset, playbackOffset + totalDuration]. After 21.1 partial eviction
* playbackOffset is the absolute start of the retained back-window tail, so:
* * seek back WITHIN the tail -> seekWithinBuffer, NO refetch (UC3 / AC4),
* * seek back PAST the tail -> seekBeyondBuffer with the EARLIER resolved offset (UC4 / AC5),
* using whichever resolver the active path ships (WAV calculateByteOffset; Opus
* resolveOpusByteOffset over the sidecar index),
* * seek forward past the decoded end -> seekBeyondBuffer forward, unchanged (UC2/UC5).
* - The AC6 recovery. recoverFromFailedRefill() halts the scheduler (clearForSeek), anchors the
* offset at the seek target, and leaves the player paused-but-loaded so no silent false end fires.
*
* The seek dispatch and recovery are pure given the scheduler + active decoder, so they are testable
* in Node by white-box-injecting fakes for `scheduler`, `streamDecoder`, and `opusDecoder` (the same
* private-field injection idiom the scheduler/Opus tests use). The AudioPlayer constructor itself is
* Node-safe: it builds AudioContextManager/StreamDecoder/PlaybackScheduler, none of which touch Web
* Audio until initialize(). No AudioContext, no WebCodecs.
*
* Same harness convention as the sibling tests (no runner in this repo); run a copy from the COMPILED
* output so the `.js` import specifiers resolve:
*
* dotnet build DeepDrftPublic/DeepDrftPublic.csproj
* cp DeepDrftPublic/Interop/audio/AudioPlayer.test.ts DeepDrftPublic/wwwroot/js/audio/
* node DeepDrftPublic/wwwroot/js/audio/AudioPlayer.test.ts
*
* A thrown error / non-zero exit signals failure; "ALL <n> TESTS PASSED" signals success.
* Excluded from the production tsc build via tsconfig `exclude: Interop/ ** /*.test.ts`.
*/
import { AudioPlayer } from './AudioPlayer.js';
import { parseSidecar } from './OpusSidecar.js';
import type { OpusSeekData } from './OpusSidecar.js';
// --- tiny inline harness (no dependencies) ---------------------------------------------------
let passed = 0;
const failures: string[] = [];
function test(name: string, fn: () => void): void {
try {
fn();
passed++;
} catch (e) {
failures.push(`FAIL: ${name}\n ${(e as Error).message}`);
}
}
function assertEqual(actual: unknown, expected: unknown, msg?: string): void {
if (actual !== expected) {
throw new Error(`${msg ?? 'assertEqual'}: expected ${String(expected)}, got ${String(actual)}`);
}
}
function assertTrue(cond: boolean, msg?: string): void {
if (!cond) throw new Error(msg ?? 'assertTrue failed');
}
// --- fakes -----------------------------------------------------------------------------------
/**
* A scheduler stand-in exposing only what AudioPlayer.seek / seekWithinBuffer / seekBeyondBuffer /
* recoverFromFailedRefill read or call. The retained window is [offset, offset + total]. Records the
* methods that mutate so the recovery test can assert the cleanup happened.
*/
class FakeScheduler {
private offset: number;
private total: number;
// hasBuffers reflects whether the scheduler holds decoded audio. Starts true when total > 0
// (a populated window), set to false by clearForSeek() (recovery drains the buffers).
private _hasBuffers: boolean;
public clearedForSeek = false;
public stoppedAllSources = false;
public offsetSetTo: number | null = null;
constructor(offset: number, total: number) {
this.offset = offset;
this.total = total;
this._hasBuffers = total > 0;
}
getPlaybackOffset(): number { return this.offset; }
getTotalDuration(): number { return this.total; }
hasBuffers(): boolean { return this._hasBuffers; }
stopAllSources(): void { this.stoppedAllSources = true; }
// seekWithinBuffer calls playFromPosition only when wasPlaying; isPlaying is false in these
// unit constructions, so it is never invoked — present for completeness.
playFromPosition(_position: number): void { /* no-op */ }
clearForSeek(): void { this.clearedForSeek = true; this._hasBuffers = false; }
setPlaybackOffset(o: number): void { this.offset = o; this.offsetSetTo = o; }
}
/** A StreamDecoder stand-in for the WAV path: a format is parsed and byte math is identity-scaled. */
class FakeStreamDecoder {
private hasFormat: boolean;
private bytesPerSecond: number;
public requestedOffsetFor: number | null = null;
constructor(hasFormat: boolean, bytesPerSecond: number) { this.hasFormat = hasFormat; this.bytesPerSecond = bytesPerSecond; }
getFormatInfo(): unknown { return this.hasFormat ? { ok: true } : null; }
calculateByteOffset(position: number): number {
this.requestedOffsetFor = position;
return Math.round(position * this.bytesPerSecond);
}
}
function makePlayer(): AudioPlayer {
// Constructor is Node-safe (no Web Audio until initialize()).
return new AudioPlayer();
}
/** Inject the seek-relevant private fields and put the player in a loaded/streaming/playing state. */
function arm(
player: AudioPlayer,
opts: {
scheduler: FakeScheduler;
duration: number;
streamDecoder?: FakeStreamDecoder;
opusDecoder?: object | null;
sidecar?: OpusSeekData | null;
}
): void {
const priv = player as unknown as Record<string, unknown>;
priv.scheduler = opts.scheduler;
priv.duration = opts.duration;
priv.isStreamingMode = true;
priv.isPlaying = false; // keep dispatch pure (no real playFromPosition needed)
if (opts.streamDecoder) priv.streamDecoder = opts.streamDecoder;
priv.opusDecoder = opts.opusDecoder ?? null;
priv.activeOpusSidecar = opts.sidecar ?? null;
}
/** Read back private fields the recovery sets. */
function priv(player: AudioPlayer): Record<string, unknown> {
return player as unknown as Record<string, unknown>;
}
// A minimal real sidecar (parsed) so the Opus resolver returns a deterministic page offset.
// Index: t=0 -> byte 4096, t=1s -> byte 9000 (granule uses 48 kHz + preSkip).
function makeOpusSidecar(): OpusSeekData {
const setup = [0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64];
const SEEK_INDEX_HEADER_SIZE = 24;
const SEEK_POINT_SIZE = 16;
const preSkip = 312;
const points = [
{ granule: preSkip, byteOffset: 4096 }, // t = 0
{ granule: preSkip + 48000, byteOffset: 9000 }, // t = 1 s
];
const total = 4 + setup.length + SEEK_INDEX_HEADER_SIZE + points.length * SEEK_POINT_SIZE;
const bytes = new Uint8Array(total);
const view = new DataView(bytes.buffer);
view.setUint32(0, setup.length, true);
bytes.set(setup, 4);
let p = 4 + setup.length;
const writeU64 = (off: number, v: number) => {
view.setUint32(off, v >>> 0, true);
view.setUint32(off + 4, Math.floor(v / 0x100000000), true);
};
writeU64(p, 500_000);
view.setFloat64(p + 8, 100, true);
view.setUint32(p + 16, points.length, true);
view.setUint16(p + 20, preSkip, true);
p += SEEK_INDEX_HEADER_SIZE;
for (const pt of points) { writeU64(p, pt.granule); writeU64(p + 8, pt.byteOffset); p += SEEK_POINT_SIZE; }
const parsed = parseSidecar(bytes);
if (!parsed) throw new Error('test setup: sidecar failed to parse');
return parsed;
}
// --- TRIGGER: within-window vs past-tail vs forward ------------------------------------------
// UC3 / AC4: a backward seek INTO the retained tail resolves from buffer — NO seekBeyondBuffer,
// NO refetch signal. Window is [30, 60); target 40 is inside.
test('seek back within retained tail resolves in-buffer (no refetch) — AC4', () => {
const player = makePlayer();
const scheduler = new FakeScheduler(30, 30); // retained window [30, 60)
arm(player, { scheduler, duration: 120, streamDecoder: new FakeStreamDecoder(true, 1000) });
const result = player.seek(40);
assertEqual(result.success, true, 'seek succeeds');
assertEqual(result.seekBeyondBuffer ?? false, false, 'within-window seek does NOT signal a refetch');
// No clearForSeek / no offset request — the retained window served it.
assertEqual(scheduler.clearedForSeek, false, 'no clearForSeek for an in-buffer seek');
});
// UC4 / AC5 (WAV): a backward seek PAST the retained tail signals a refill at the EARLIER resolved
// offset, using the WAV resolver. Window [30, 60); target 10 is before the tail.
test('seek back past retained tail refetches at the WAV-resolved earlier offset — AC5', () => {
const player = makePlayer();
const scheduler = new FakeScheduler(30, 30); // retained window [30, 60)
const wav = new FakeStreamDecoder(true, 2000); // 2000 bytes/sec
arm(player, { scheduler, duration: 120, streamDecoder: wav });
const result = player.seek(10); // earlier than the retained tail start (30)
assertEqual(result.success, true, 'seek succeeds');
assertEqual(result.seekBeyondBuffer, true, 'past-tail back seek signals a refill (window miss)');
assertEqual(wav.requestedOffsetFor, 10, 'WAV resolver consulted for the EARLIER target');
assertEqual(result.byteOffset, 20000, 'refill offset is the WAV-resolved earlier byte offset');
});
// UC4 / AC5 (Opus): the same window miss on the Opus path uses resolveOpusByteOffset over the
// sidecar index (the live seek), not WAV byte math. Target 0.3 s resolves to the t=0 page (4096).
test('seek back past retained tail refetches at the Opus index-resolved offset — AC5', () => {
const player = makePlayer();
const scheduler = new FakeScheduler(30, 30); // retained window [30, 60)
arm(player, {
scheduler,
duration: 100,
opusDecoder: {}, // presence routes seekBeyondBuffer down the Opus branch
sidecar: makeOpusSidecar(),
});
const result = player.seek(0.3); // earlier than the retained tail (30) -> window miss
assertEqual(result.success, true, 'seek succeeds');
assertEqual(result.seekBeyondBuffer, true, 'past-tail back seek signals a refill on Opus too');
assertEqual(result.byteOffset, 4096, 'Opus index resolved the t=0 page start for the earlier target');
// The landing time of the resolved page is captured for the decoder lead-trim (AC9 reuse).
assertEqual(priv(player)._seekLandingTime, 0, 'landing time of the resolved page captured for lead-trim');
});
// UC2/UC5: a forward seek past the decoded end still routes to seekBeyondBuffer forward, unchanged.
test('forward seek past decoded end still routes to seekBeyondBuffer (unchanged)', () => {
const player = makePlayer();
const scheduler = new FakeScheduler(30, 30); // decoded [30, 60)
const wav = new FakeStreamDecoder(true, 1500);
arm(player, { scheduler, duration: 120, streamDecoder: wav });
const result = player.seek(90); // past the decoded end (60)
assertEqual(result.seekBeyondBuffer, true, 'forward-beyond-buffer still signals a fetch');
assertEqual(wav.requestedOffsetFor, 90, 'forward target resolved through the same WAV resolver');
assertEqual(result.byteOffset, 135000, 'forward offset is the resolved later byte offset');
});
// --- AC6: clean-failure recovery -------------------------------------------------------------
// A failed refill must leave the player recoverable: scheduler halted (clearForSeek), offset anchored
// at the seek target, paused-but-loaded — never a starved "playing" scheduler that fires a false end.
test('recoverFromFailedRefill halts the scheduler and leaves a paused-but-loaded state — AC6', () => {
const player = makePlayer();
const scheduler = new FakeScheduler(30, 30);
arm(player, { scheduler, duration: 120, streamDecoder: new FakeStreamDecoder(true, 1000) });
// Simulate the pre-failure "playing" state the drained pre-seek loop leaves behind.
priv(player).isPlaying = true;
priv(player).isPaused = false;
priv(player).streamingStarted = true;
const result = player.recoverFromFailedRefill(15);
assertEqual(result.success, true, 'recovery succeeds');
assertTrue(scheduler.clearedForSeek, 'stale buffers dropped (no false end can fire)');
assertEqual(scheduler.offsetSetTo, 15, 'offset anchored at the seek target for a retry');
assertEqual(priv(player).isPlaying, false, 'not playing after recovery');
assertEqual(priv(player).isPaused, true, 'paused after recovery');
assertEqual(priv(player).pausePosition, 15, 'pause anchor is the seek target');
assertEqual(priv(player).streamingStarted, false, 'streaming flagged not-started for a clean retry');
});
// --- AC6 retry contract: same-target seek after recovery refetches -------------------------
// After recoverFromFailedRefill the scheduler is empty (clearForSeek was called). A seek to
// the SAME position (seekPosition == playbackOffset) must route to seekBeyondBuffer — not
// seekWithinBuffer, which would be a silent no-op against the degenerate [P,P] empty window.
test('same-target seek after recovery routes to seekBeyondBuffer (AC6 retry)', () => {
const player = makePlayer();
const wav = new FakeStreamDecoder(true, 1000);
// Start with a populated window [30, 60), then simulate recovery at position 15:
// clearForSeek empties the scheduler; setPlaybackOffset anchors it to 15.
const scheduler = new FakeScheduler(30, 30);
arm(player, { scheduler, duration: 120, streamDecoder: wav });
// Drive recovery state manually (the same state recoverFromFailedRefill leaves).
player.recoverFromFailedRefill(15);
// At this point: scheduler.hasBuffers() == false, playbackOffset == 15, totalDuration == 0.
// A seek to 15 (the recovery anchor) must refetch, not silently resolve from the empty window.
const result = player.seek(15);
assertEqual(result.success, true, 'seek succeeds after recovery');
assertEqual(result.seekBeyondBuffer, true, 'same-target seek after recovery signals a refetch (AC6 retry)');
assertEqual(wav.requestedOffsetFor, 15, 'WAV resolver used for the retry offset');
});
// AC4 not regressed: a seek within a POPULATED retained window still resolves from buffer.
// This is the same test as the existing AC4 test but named explicitly to confirm the
// hasBuffers() guard does not affect the populated case.
test('seek within populated retained window still resolves in-buffer — AC4 not regressed', () => {
const player = makePlayer();
// Populated window [30, 60) — hasBuffers() starts true (total=30 > 0).
const scheduler = new FakeScheduler(30, 30);
arm(player, { scheduler, duration: 120, streamDecoder: new FakeStreamDecoder(true, 1000) });
const result = player.seek(45); // inside [30, 60)
assertEqual(result.success, true, 'seek succeeds');
assertEqual(result.seekBeyondBuffer ?? false, false, 'populated in-window seek does NOT signal a refetch');
assertEqual(scheduler.clearedForSeek, false, 'scheduler not cleared for an in-buffer seek (no refetch)');
});
// --- run -------------------------------------------------------------------------------------
if (failures.length > 0) {
console.error(failures.join('\n'));
console.error(`\n${failures.length} FAILED, ${passed} passed`);
process.exit(1);
} else {
console.log(`ALL ${passed} TESTS PASSED`);
}
+63 -7
View File
@@ -432,18 +432,36 @@ 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.
if (position >= bufferStart && position <= bufferEnd) {
// The window-miss test for BOTH directions, and the 21.3 refill trigger for backward seeks.
// Position must be within [bufferStart, bufferEnd] AND the scheduler must hold buffers to
// resolve from the retained window:
// - position >= bufferStart AND hasBuffers : 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.
// - !hasBuffers (degenerate [P,P] window post-recovery): the window check above would
// spuriously route ANY target to seekWithinBuffer (bufferStart==bufferEnd==seekPosition
// after recoverFromFailedRefill). Force seekBeyondBuffer so a same-target retry actually
// refetches (AC6 retry contract). The !hasBuffers guard only fires in the degenerate case —
// a populated retained window has buffers and is unaffected (AC4 not regressed).
if (position >= bufferStart && position <= bufferEnd && this.scheduler.hasBuffers()) {
return this.seekWithinBuffer(position);
} else {
// Seeking outside buffered window - signal C# to fetch new stream
// Seeking outside the retained window, or to any position in an empty scheduler —
// signal C# to fetch a new stream from the resolved offset.
return this.seekBeyondBuffer(position);
}
}
@@ -580,6 +598,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 {
+9
View File
@@ -131,6 +131,15 @@ const DeepDrftAudio = {
return player.reinitializeFromOffset(totalStreamLength, seekPosition);
},
// Phase 21.3 / AC6: recover into a clean paused-but-loaded state after a window-miss refill
// (seek-back past the retained tail) failed its Range fetch or reinit. Prevents the starved
// scheduler from firing a silent false end; leaves the track loaded so a retry is possible.
recoverFromFailedRefill: (playerId: string, seekPosition: number): AudioResult => {
const player = audioPlayers.get(playerId);
if (!player) return { success: false, error: 'Player not found' };
return player.recoverFromFailedRefill(seekPosition);
},
setVolume: (playerId: string, volume: number): AudioResult => {
const player = audioPlayers.get(playerId);
if (!player) return { success: false, error: 'Player not found' };