/** * 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 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; 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 { return player as unknown as Record; } // 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`); }