Files
deepdrft/DeepDrftPublic/Interop/audio/AudioPlayer.test.ts
T
daniel-c-harvey b93881cd66 21.3 review fixes: guard superseded-seek failures; restore post-recovery retry
C6/AC8: IsStillActiveSeek() predicate guards all three SeekBeyondBuffer
failure exits, so a superseded seek never recovers over a newer seek's
state. AC6: empty scheduler routes to seekBeyondBuffer so a same-target
retry (seek or play) refetches instead of no-oping.
2026-06-23 23:55:28 -04:00

299 lines
15 KiB
TypeScript

/**
* 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`);
}