Playback review
First passage explains how reading controls work together on an article page. @@ -34,7 +34,7 @@ async function fixture({ resume = false } = {}) {
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(`
First passage explains how reading controls work together on an article page. @@ -34,7 +34,7 @@ async function fixture({ resume = false } = {}) {