b93881cd66
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.
299 lines
15 KiB
TypeScript
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`);
|
|
}
|