diff --git a/extension/content.ts b/extension/content.ts index 2faff7d..c31543c 100644 --- a/extension/content.ts +++ b/extension/content.ts @@ -37,6 +37,7 @@ type WordTiming = { }; type ChunkAlignment = { + sectionIndex: number; offset: number; segments: WordTiming[]; }; @@ -1080,7 +1081,7 @@ async function startPlayback( recordClientEvent("highlight_api_unavailable"); } - const persistProgress = (completed = false): void => { + const currentReadingOffset = (): number => { if (alignmentNeedsMapping) { rebuildWordRanges(); alignmentNeedsMapping = false; } const seconds = getAudibleFrame() / SAMPLE_RATE; let current: { start: number; offset: number } | undefined; @@ -1088,13 +1089,20 @@ async function startPlayback( if (word.start > seconds) break; current = word; } - if (current && seconds > 0) checkpoint = startOffset + current.offset; + return startOffset + (current?.offset ?? 0); + }; + const persistProgress = (completed = false): void => { + checkpoint = currentReadingOffset(); void sendExtensionMessage<{ error?: string }>({ type: "reading_progress", id: reading.id, sessionId, offset: checkpoint, completed }) .then((result) => { if (result.error) setPlayerStatus(controls, result.error); }) .catch(() => setPlayerStatus(controls, "Reading progress could not be saved. Refresh this tab to reconnect.")); }; const pageHide = (): void => { cleanup(); }; + const visibilityChange = (): void => { + if (document.visibilityState === "hidden" && !playbackComplete) persistProgress(); + }; window.addEventListener("pagehide", pageHide); + document.addEventListener("visibilitychange", visibilityChange); const cleanup = (recordStop = true): void => { if (stopped) { @@ -1103,6 +1111,7 @@ async function startPlayback( if (!playbackComplete) persistProgress(); window.removeEventListener("pagehide", pageHide); + document.removeEventListener("visibilitychange", visibilityChange); stopped = true; document.removeEventListener("dblclick", onArticleDoubleClick); setCurrentPlaybackSpeed = null; @@ -1194,11 +1203,7 @@ async function startPlayback( if (stopped) return; const frame = pendingSeekFrame ?? getAudibleFrame(); const currentSeconds = frame / SAMPLE_RATE; - let sourceOffset = startOffset; - for (const word of locatedWords) { - if (word.start > currentSeconds) break; - if (word.sourceOffset !== null) sourceOffset = startOffset + word.sourceOffset; - } + const sourceOffset = currentReadingOffset(); const resumePaused = context.state === "suspended"; cleanup(false); if (stopCurrentPlayback) stopCurrentPlayback = null; @@ -1224,10 +1229,8 @@ async function startPlayback( // Recover from an unexpected worker restart at the current word. Preserve // pause state and the original source so later double-clicks can seek backward. - if (alignmentNeedsMapping) rebuildWordRanges(); const seconds = getAudibleFrame() / SAMPLE_RATE; - const currentWord = [...locatedWords].reverse().find((word) => word.start <= seconds && word.sourceOffset !== null); - const sourceOffset = startOffset + (currentWord?.sourceOffset ?? 0); + const sourceOffset = currentReadingOffset(); const resumePaused = context.state === "suspended"; cleanup(false); void startPlayback(controls, readingSource, source, selectionRange, { @@ -1496,15 +1499,23 @@ async function startPlayback( }; const rebuildWordRanges = (): void => { - const segments = Array.from(alignmentsByChunk.values()).flatMap((snapshot) => - snapshot.segments.map((segment) => ({ - text: segment.text, - start: snapshot.offset + segment.start, - end: snapshot.offset + segment.end, - })), - ).sort((left, right) => left.start - right.start); - locatedWords = mapSentenceTimings(playbackSource, spoken, segments); - readingOffsets = mapReadingOffsets(spoken, segments); + locatedWords = []; + readingOffsets = []; + let alignmentWordCount = 0; + for (const section of sections) { + const segments = Array.from(alignmentsByChunk.values()).filter((snapshot) => snapshot.sectionIndex === section.index) + .flatMap((snapshot) => snapshot.segments.map((segment) => ({ + text: segment.text, + start: snapshot.offset + segment.start, + end: snapshot.offset + segment.end, + }))).sort((left, right) => left.start - right.start); + const sectionSpoken = { text: section.text, sourceOffsets: spoken.sourceOffsets.slice(section.start, section.end) }; + locatedWords.push(...mapSentenceTimings(playbackSource, sectionSpoken, segments)); + readingOffsets.push(...mapReadingOffsets(sectionSpoken, segments)); + alignmentWordCount += segments.length; + } + locatedWords.sort((left, right) => left.start - right.start); + readingOffsets.sort((left, right) => left.start - right.start); currentSentenceKey = ""; const mappedWords = locatedWords.filter((word) => word.ranges.length); const mismatch = locatedWords.find((word) => !word.ranges.length); @@ -1515,7 +1526,7 @@ async function startPlayback( if (!didReportHighlightMapping) { recordClientEvent("highlight_mapping", undefined, { mapped_words: mappedWords.length, - alignment_words: segments.length, + alignment_words: alignmentWordCount, }); didReportHighlightMapping = true; } @@ -1623,6 +1634,7 @@ async function startPlayback( queueAudio(message.audio_base64, sectionIndex); if (typeof message.chunk_seq === "number" && message.alignment?.segments?.length) { alignmentsByChunk.set(`${sectionIndex}:${message.chunk_seq}`, { + sectionIndex, offset: typeof message.chunk_audio_offset_sec === "number" ? message.chunk_audio_offset_sec : 0, segments: message.alignment.segments, }); diff --git a/extension/reading-list-store.ts b/extension/reading-list-store.ts index db96f08..1c20afe 100644 --- a/extension/reading-list-store.ts +++ b/extension/reading-list-store.ts @@ -29,13 +29,12 @@ export function readingProgress(item: ReadingItem): { percent: number; remaining }; } -// Resume from the start of the current sentence so no unfinished words are lost. export function resumeOffset(text: string, offset: number): number { - const segmenter = new Intl.Segmenter(undefined, { granularity: "sentence" }); + const segmenter = new Intl.Segmenter(undefined, { granularity: "word" }); let start = 0; - for (const sentence of segmenter.segment(text)) { - if (sentence.index > offset) break; - start = sentence.index; + for (const word of segmenter.segment(text)) { + if (word.index > offset) break; + if (word.isWordLike) start = word.index; } return start; } diff --git a/tests/PLAYBACK_VALIDATION.md b/tests/PLAYBACK_VALIDATION.md index b0a4998..4f9eb7f 100644 --- a/tests/PLAYBACK_VALIDATION.md +++ b/tests/PLAYBACK_VALIDATION.md @@ -40,3 +40,19 @@ Actual worker lifecycle behavior and live Fish Audio still need a check in the u After merging the newer saved-reading, voice-switching, and auto-scroll changes from `main`, the JavaScript suite runs with Node's default isolation so Chrome mocks remain local to each test file. Additional playback regressions cover cached seeking after a saved resume, seeking backward into earlier article text, and reconnecting at the absolute article offset while preserving pause state. + +## Accurate saved reading after a paragraph jump + +Validated on 2026-10-04 on branch `codex/fix-resume-reading`. + +`npm run check`, `npm run build`, `node --experimental-strip-types --test tests/*.test.mjs`, both Python bridge tests, and `git diff --check` passed. +The JavaScript suite contains 104 passing tests. +New regressions cover jumping two paragraphs ahead and reopening the article at the audible word, resuming without repeating its sentence, saving a rewind to the beginning, saving before a hidden tab throttles animation frames, and exact later-section checkpoints when earlier audio has no word timestamps. + +A temporary fixture ran the built content script in isolated Google Chrome with real Web Audio, synthetic PCM, a controlled extension port, and the bundled reading-list store persisted across reloads. +It verified jumping from the first paragraph to a word two paragraphs ahead, pausing and saving there, reloading the page, resuming from the exact saved word, and highlighting the jumped-to paragraph. +Rewinding the resumed audio to its beginning and resuming again in the same tab preserved its original article offset. +The fixture used separate local ports so the running Fish Audio bridge was unaffected. + +These checks do not establish installed-extension worker behavior or live Fish Audio playback. +Load this worktree's `dist/` in Chrome, reload the extension, refresh the article, and repeat the paragraph-jump and resume flow for a live provider check. diff --git a/tests/content-playback.test.mjs b/tests/content-playback.test.mjs index 1fd125b..a593057 100644 --- a/tests/content-playback.test.mjs +++ b/tests/content-playback.test.mjs @@ -20,7 +20,7 @@ const voices = [ ]; const tick = () => new Promise(resolve => setImmediate(resolve)); -async function fixture({ resume = false } = {}) { +async function fixture({ resume = false, savedReads = [] } = {}) { const dom = new JSDOM(`Playback review

Playback review

First passage explains how reading controls work together on an article page. @@ -34,7 +34,7 @@ async function fixture({ resume = false } = {}) {

`, { url: "https://example.com/article", runScripts: "outside-only", pretendToBeVisual: true }); const { window } = dom; const source = createArticleSource(window.document); - const reads = []; + const reads = structuredClone(savedReads); const messages = []; const ports = []; const contexts = []; @@ -127,7 +127,7 @@ async function fixture({ resume = false } = {}) { port.emit({ event: "audio", section_index: 0, section_start_frame: 0, chunk_seq: 0, audio_base64: Buffer.alloc(seconds * 44_100 * 2).toString("base64"), - alignment: { segments: start.text.trim().split(/\s+/).map((text, index) => ({ text, start: index * 0.1, end: (index + 1) * 0.1 })) }, + alignment: { segments: start.sections[0].text.trim().split(/\s+/).map((text, index) => ({ text, start: index * 0.1, end: (index + 1) * 0.1 })) }, }); if (finish) port.emit({ event: "finish" }); }; @@ -299,3 +299,100 @@ test("connection recovery after saved resume retains the absolute word offset an f.advance(0.25); assert.equal(f.followed.at(-1).paragraph, "second"); }); + +test("reopening a saved read starts at its current word without repeating the sentence", async t => { + const f = await fixture({ resume: true }); + t.after(f.close); + const wordOffset = f.source.text.indexOf("passage", f.source.text.indexOf("Second passage")); + await f.click("article-button"); + assert.equal(f.ports[0].sent.find(message => message.type === "start").text, f.source.text.slice(wordOffset)); + await f.click("stop-button"); + assert.equal(f.reads[0].offset, wordOffset); + await f.click("article-button"); + assert.equal(f.ports[1].sent.find(message => message.type === "start").text, f.source.text.slice(wordOffset)); +}); + +test("rewinding to the beginning saves zero even before another word is audible", async t => { + const f = await fixture(); + t.after(f.close); + await f.click("article-button"); + f.audio(); + f.advance(1.25); + await f.click("pause-button"); + assert.ok(f.reads[0].offset > 0); + await f.click("rewind-button"); + await f.click("stop-button"); + assert.equal(f.reads[0].offset, 0); + await f.click("article-button"); + assert.equal(f.ports[1].sent.find(message => message.type === "start").text, f.source.text); +}); + +test("hiding the tab saves the audible word before animation frames are throttled", async t => { + const f = await fixture(); + t.after(f.close); + await f.click("article-button"); + f.audio(); + f.advance(1.25); + assert.equal(f.reads[0].offset, 0, "periodic progress has not run yet"); + Object.defineProperty(f.window.document, "visibilityState", { value: "hidden" }); + f.window.document.dispatchEvent(new f.window.Event("visibilitychange")); + await tick(); + assert.ok(f.reads[0].offset > 0); + const saves = f.messages.filter(message => message.type === "reading_progress").length; + await f.click("stop-button"); + f.window.document.dispatchEvent(new f.window.Event("visibilitychange")); + await tick(); + assert.equal(f.messages.filter(message => message.type === "reading_progress").length, saves + 1); +}); + +test("jumping two paragraphs ahead resumes at the audible word after reopening the article", async t => { + const f = await fixture(); + t.after(f.close); + await f.click("article-button"); + f.audio(); + f.advance(0.25); + const paragraph = f.window.document.getElementById("third"); + const range = f.window.document.createRange(); + range.setStart(paragraph.firstChild, 6); + range.setEnd(paragraph.firstChild, 13); + f.window.getSelection().addRange(range); + paragraph.dispatchEvent(new f.window.MouseEvent("dblclick", { bubbles: true })); + await tick(); + const jumpedOffset = f.source.text.indexOf("passage", f.source.text.indexOf("Third passage")); + assert.equal(f.ports[1].sent.find(message => message.type === "start").text, f.source.text.slice(jumpedOffset)); + f.audio(); + f.advance(0.36); + await f.click("stop-button"); + const expectedOffset = f.source.text.indexOf("enough", jumpedOffset); + assert.equal(f.reads[0].offset, expectedOffset); + const reopened = await fixture({ savedReads: f.reads }); + t.after(reopened.close); + assert.equal(reopened.shadow.getElementById("article-button").textContent, "Resume read"); + await reopened.click("article-button"); + assert.equal(reopened.ports[0].sent.find(message => message.type === "start").text, f.source.text.slice(expectedOffset)); + reopened.audio(); + reopened.advance(0.25); + assert.equal(reopened.followed.at(-1).paragraph, "third"); +}); + +test("later sections keep exact word checkpoints when earlier audio has no alignment", async t => { + const f = await fixture(); + t.after(f.close); + await f.click("article-button"); + const port = f.ports[0]; + const start = port.sent.find(message => message.type === "start"); + port.emit({ event: "audio", section_index: 0, section_start_frame: 0, + audio_base64: Buffer.alloc(4 * 44_100 * 2).toString("base64") }); + const section = start.sections[1]; + const words = [...section.text.matchAll(/\S+/g)]; + port.emit({ event: "audio", section_index: 1, section_start_frame: 4 * 44_100, + chunk_seq: 0, chunk_audio_offset_sec: 4, + audio_base64: Buffer.alloc(4 * 44_100 * 2).toString("base64"), + alignment: { segments: words.map((word, index) => ({ text: word[0], start: index * 0.1, end: (index + 1) * 0.1 })) } }); + f.advance(4.3); + await f.click("pause-button"); + assert.equal(f.reads[0].offset, section.start + words[2].index); + await f.click("stop-button"); + await f.click("article-button"); + assert.equal(f.ports[1].sent.find(message => message.type === "start").text, f.source.text.slice(section.start + words[2].index)); +}); diff --git a/tests/reading-list.test.mjs b/tests/reading-list.test.mjs index f80568f..64d6c6a 100644 --- a/tests/reading-list.test.mjs +++ b/tests/reading-list.test.mjs @@ -61,9 +61,11 @@ test("only playback completion marks a read complete; replay resets it", () => { assert.equal(item.offset, 0); }); -test("resume repeats the unfinished sentence, including at sentence boundaries", () => { +test("resume repeats only the unfinished word, including at word boundaries", () => { assert.equal(resumeOffset(article.text, 0), 0); - assert.equal(resumeOffset(article.text, 23), 16); + assert.equal(resumeOffset(article.text, 20), 16); + assert.equal(resumeOffset(article.text, 26), 23); + assert.equal(resumeOffset(article.text, 23), 23); assert.equal(resumeOffset(article.text, 33), 33); });