Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 32 additions & 20 deletions extension/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type WordTiming = {
};

type ChunkAlignment = {
sectionIndex: number;
offset: number;
segments: WordTiming[];
};
Expand Down Expand Up @@ -1080,21 +1081,28 @@ 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;
for (const word of [...sectionOffsets.values(), ...readingOffsets].sort((a, b) => a.start - b.start)) {
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) {
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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, {
Expand Down Expand Up @@ -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);
Expand All @@ -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;
}
Expand Down Expand Up @@ -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,
});
Expand Down
9 changes: 4 additions & 5 deletions extension/reading-list-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
16 changes: 16 additions & 0 deletions tests/PLAYBACK_VALIDATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
103 changes: 100 additions & 3 deletions tests/content-playback.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(`<!doctype html><title>Playback review</title><article>
<h1>Playback review</h1>
<p id="first">First passage explains how reading controls work together on an article page.
Expand All @@ -34,7 +34,7 @@ async function fixture({ resume = false } = {}) {
</article>`, { 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 = [];
Expand Down Expand Up @@ -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" });
};
Expand Down Expand Up @@ -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));
});
6 changes: 4 additions & 2 deletions tests/reading-list.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
Loading