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
49 changes: 49 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: CI

on:
push:
pull_request:
workflow_dispatch:

permissions:
contents: read

jobs:
extension:
name: Extension and browser tests
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "24"
cache: npm
- name: Install locked dependencies
run: npm ci
- name: Type check
run: npm run check
- name: Build extension
run: npm run build
- name: JavaScript unit tests
run: node --experimental-strip-types --test tests/*.test.mjs
- name: Browser playback tests
# Google Chrome is included in the GitHub-hosted Ubuntu runner image.
env:
CHROMIUM_PATH: /usr/bin/google-chrome
run: node tests/playback-browser.mjs

bridge:
name: Python bridge tests
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with:
python-version: "3.12"
enable-cache: true
- name: Install locked dependencies
run: uv sync --locked
- name: Python unit tests
run: uv run --no-sync python -m unittest discover -s tests -p 'test_*.py'
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ Keep the local bridge running while listening.
On an article page, choose **Listen to article** to read the article.
Select text and choose **Listen** to read only that selection.
Use **Back 15 seconds**, **Pause**, **Forward 15 seconds**, or **Stop** in the small player while audio is playing.
While listening to an article, double-click a word in the article to continue reading from that word, including while paused.
Words with cached audio seek immediately; words that have not been generated start a new stream from that point, skipping the intervening paragraphs.
You can still select a passage and choose **Listen** to read just that selection.
Paused listening sessions keep their background connection alive when you switch tabs.
If the background worker unexpectedly restarts, Readflow tries once to reconnect at the current word and preserves the pause state.
Reloading or updating the extension itself still requires refreshing an already-open article tab.
While audio plays, Readflow smoothly brings the spoken line into view only when it leaves a comfortable reading area, including inside scrollable article panels.
Scrolling or interacting with the page gives you four seconds before automatic following resumes; dragging or selecting text holds it until you release.
Automatic following rests while audio is paused or buffering and uses instant scrolling when your system requests reduced motion.
Expand All @@ -84,7 +90,7 @@ At 1× and speeds below 2×, Readflow requests one section at a time; at 2× it
The extension limits all tabs together to three active Fish requests and starts more sections as the rolling buffer drains.
Playback begins after three seconds of audio at the selected speed are ready, and waits if generation later falls behind.
Audio sections are played in their original order, and their word timestamps are offset onto one continuous article timeline.
Changing speed or seeking reprocesses cached audio locally and does not request the same text again.
Changing speed or seeking within cached audio reprocesses it locally and does not request the same text again.
Readflow converts inline ordinal math such as `$n^\text{th}$` to “nth” before sending it to Fish.
Other formulas are left as written until Readflow has a reliable spoken form for them.

Expand Down Expand Up @@ -133,10 +139,19 @@ Fish Audio events should arrive as the service generates audio and timestamp dat

## Run checks

GitHub Actions runs type checking, the production build, all JavaScript unit tests, the browser playback check, and Python bridge tests on every push and pull request.
The workflow can also be started manually from GitHub's **Actions** tab.
CI uses synthetic audio and mocked Fish connections, so it needs no Fish API key.

The TypeScript and DOM unit tests below require Node.js 22.13 or newer.

```sh
npm run check
npm run build
node --experimental-strip-types --test tests/*.test.mjs
```

For a browser playback check, build first, then run `node tests/playback-browser.mjs` with Chromium installed (or set `CHROMIUM_PATH` to its executable).
This uses synthetic audio and a controlled extension port with real Web Audio to check a 45-second frozen-tab pause, word seeking, disconnect recovery, and selected-text listening.
It requires free local ports 4179, 4180, and 9224; stop the local bridge first.
It does not test Chrome's extension service-worker lifecycle or live Fish Audio.
4 changes: 4 additions & 0 deletions extension/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from "./diagnostics-store";
import type { TextSection } from "./text-sections";
import { DEFAULT_VOICE, isFishVoice, type VoicePage } from "./voices";
import { keepSessionConnected } from "./session-connection";

type StreamEvent = {
event: "connected" | "audio" | "finish" | "error";
Expand Down Expand Up @@ -67,6 +68,7 @@ chrome.runtime.onConnect.addListener((port) => {
}

const controller = new AbortController();
const stopHeartbeat = keepSessionConnected(chrome.runtime);
let sessionController: AbortController | null = null;
let disconnected = false;
let session: TraceSession | null = null;
Expand All @@ -90,6 +92,7 @@ chrome.runtime.onConnect.addListener((port) => {
};

port.onDisconnect.addListener(() => {
stopHeartbeat();
disconnected = true;
controller.abort();
sessionController?.abort();
Expand Down Expand Up @@ -156,6 +159,7 @@ chrome.runtime.onConnect.addListener((port) => {
if (session && message.kind === "playback_started") {
session.playback_started_ms = message.clientElapsedMs;
} else if (session && message.kind === "playback_finished") {
stopHeartbeat();
finishSession(session, "finished");
} else if (session && message.kind === "stopped") {
finishSession(session, "stopped");
Expand Down
76 changes: 65 additions & 11 deletions extension/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ import { mapReadingOffsets } from "./reading-progress";
import { sentenceSpans } from "./text-map";
import { mapSentenceTimings, type SentenceTiming } from "./highlight-timeline";
import { createPageHighlighter, type PageSentenceHighlighter } from "./page-highlighter";
import { createArticleSource, createSelectionSource, sliceReadingSource, sourceRanges, type ReadingSource } from "./reading-source";
import { createArticleSource, createSelectionSource, selectedWordOffset, sliceReadingSource, sourceRanges, type ReadingSource } from "./reading-source";
import { createReadingAutoScroller } from "./auto-scroll";
import { bufferFramesForSpeed, isAudioAudible, playbackDuration, playedFrames } from "./playback-speed";
import { StreamingTimeStretch } from "./time-stretch";
import { resolveSeekTarget } from "./seek-target";
import { prepareSpokenSource } from "./spoken-text";
import { splitTextSections } from "./text-sections";
import { shortTapeTitle } from "./tape-label";
import { extensionRuntime, RECONNECT_MESSAGE, sendExtensionMessage } from "./extension-runtime";
import { connectionErrorMessage, extensionRuntime, RECONNECT_MESSAGE, sendExtensionMessage } from "./extension-runtime";
import { createVoicePicker } from "./voice-picker";
import type { FishVoice } from "./voices";

Expand Down Expand Up @@ -961,7 +961,7 @@ async function startPlayback(
readingSource: ReadingSource,
source: string,
selectionRange?: Range,
options: { sourceOffset?: number; elapsedSeconds?: number; voice?: FishVoice; resumePaused?: boolean; startBufferSeconds?: number } = {},
options: { sourceOffset?: number; elapsedSeconds?: number; voice?: FishVoice; resumePaused?: boolean; startBufferSeconds?: number; recoveryAttempts?: number } = {},
savedRead?: ReadingItem,
): Promise<void> {
const picker = voicePickers.get(controls.host);
Expand Down Expand Up @@ -1002,7 +1002,7 @@ async function startPlayback(
port = extensionRuntime().connect({ name: "readflow-tts" });
} catch {
setTransportState(controls, "error");
setPlayerStatus(controls, RECONNECT_MESSAGE);
setPlayerStatus(controls, connectionErrorMessage(typeof chrome === "undefined" ? undefined : chrome.runtime));
return;
}

Expand Down Expand Up @@ -1050,6 +1050,7 @@ async function startPlayback(
const startupBufferFrames = (): number => Math.ceil((options.startBufferSeconds ?? START_BUFFER_SECONDS) * SAMPLE_RATE * playbackRate);
let nextStart = 0;
let streamFinished = false;
let portConnected = true;
let playbackComplete = false;
let stopped = false;
let animationFrame = 0;
Expand Down Expand Up @@ -1103,6 +1104,7 @@ async function startPlayback(
if (!playbackComplete) persistProgress();
window.removeEventListener("pagehide", pageHide);
stopped = true;
document.removeEventListener("dblclick", onArticleDoubleClick);
setCurrentPlaybackSpeed = null;
if (changePlaybackVoice === changeVoiceForSession) changePlaybackVoice = null;
cancelAnimationFrame(animationFrame);
Expand Down Expand Up @@ -1212,8 +1214,26 @@ async function startPlayback(
changePlaybackVoice = changeVoiceForSession;

port.onDisconnect.addListener(() => {
const error = chrome.runtime?.lastError;
if (!stopped && (!streamFinished || error)) reportError(RECONNECT_MESSAGE);
void chrome.runtime?.lastError;
portConnected = false;
if (stopped || streamFinished) return;
if (!chrome.runtime?.id || (options.recoveryAttempts ?? 0) >= 1) {
reportError(connectionErrorMessage(chrome.runtime));
return;
}

// 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 resumePaused = context.state === "suspended";
cleanup(false);
void startPlayback(controls, readingSource, source, selectionRange, {
sourceOffset, resumePaused, voice, elapsedSeconds: elapsedBeforeSession + seconds,
recoveryAttempts: (options.recoveryAttempts ?? 0) + 1,
});
});

const finishIfReady = (): void => {
Expand Down Expand Up @@ -1432,11 +1452,41 @@ async function startPlayback(
seekTo((pendingSeekFrame ?? getAudibleFrame()) + seconds * SAMPLE_RATE);
};

const onArticleDoubleClick = (event: MouseEvent): void => {
if (stopped || source !== "Article") return;
const target = event.target instanceof Element ? event.target : null;
if (event.composedPath().includes(controls.host) || target?.closest(
"a, button, input, textarea, select, [contenteditable]:not([contenteditable='false']), [role='button'], [role='textbox']",
)) return;
const selection = window.getSelection();
if (!selection?.rangeCount) return;
const offset = selectedWordOffset(readingSource, selection.getRangeAt(0));
if (offset === null) return;
if (alignmentNeedsMapping) {
rebuildWordRanges();
alignmentNeedsMapping = false;
}
const word = locatedWords.find((candidate) => candidate.sourceOffset !== null && startOffset + candidate.sourceOffset === offset);
selection.removeAllRanges();
controls.selectionButton.hidden = true;
recordClientEvent("word_seek", word ? word.start * 1000 : undefined, { source_offset: offset });
if (word) {
// Cached words use their exact audio timestamp. Double-click also resumes a pause.
seekTo(word.start * SAMPLE_RATE);
void context.resume().catch(() => reportError("Audio playback could not resume. Try again."));
} else {
// Do not generate all skipped paragraphs just to reach a word with no audio yet.
cleanup();
void startPlayback(controls, readingSource, source, undefined, { sourceOffset: offset, voice });
}
};
document.addEventListener("dblclick", onArticleDoubleClick);

setCurrentPlaybackSpeed = (rate) => {
const frame = pendingSeekFrame ?? getAudibleFrame();
playbackRate = rate;
try { port.postMessage({ type: "set_speed", rate }); } catch {
reportError(RECONNECT_MESSAGE);
try { if (portConnected) port.postMessage({ type: "set_speed", rate }); } catch {
reportError(connectionErrorMessage(chrome.runtime));
return;
}
recordClientEvent("speed_changed", frame / SAMPLE_RATE * 1000, { rate });
Expand Down Expand Up @@ -1507,7 +1557,7 @@ async function startPlayback(
lastProgressAt = now;
persistProgress();
}
if (now - lastBufferStatusAt >= 250) {
if (portConnected && !streamFinished && now - lastBufferStatusAt >= 250) {
lastBufferStatusAt = now;
try {
port.postMessage({
Expand All @@ -1518,7 +1568,7 @@ async function startPlayback(
pendingSeek: pendingSeekFrame !== null,
});
} catch {
reportError(RECONNECT_MESSAGE);
reportError(connectionErrorMessage(chrome.runtime));
return;
}
}
Expand Down Expand Up @@ -1610,6 +1660,7 @@ async function startPlayback(
try {
if (options.resumePaused) await context.suspend();
else await context.resume();
if (stopped) return;
port.postMessage({
type: "start",
sessionId,
Expand All @@ -1622,11 +1673,14 @@ async function startPlayback(
wordCount: spokenText.trim().split(/\s+/).length,
startedAt: new Date().toISOString(),
});
if (options.resumePaused) {
port.postMessage({ type: "buffer_status", audibleFrame: 0, rate: playbackRate, paused: true, pendingSeek: false });
}
setPlayerStatus(controls, options.resumePaused ? `Paused · ${formatTime(elapsedBeforeSession)}` : "Buffering audio…");
setTransportState(controls, options.resumePaused ? "paused" : "buffering");
animationFrame = requestAnimationFrame(updatePlaybackTime);
} catch {
reportError("Audio playback could not start. Try again.");
if (!stopped) reportError("Audio playback could not start. Try again.");
}
}

Expand Down
4 changes: 4 additions & 0 deletions extension/extension-runtime.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
export const RECONNECT_MESSAGE = "Refresh this tab to reconnect Readflow.";

export function connectionErrorMessage(runtime: Pick<typeof chrome.runtime, "id"> | undefined): string {
return runtime?.id ? "Readflow lost its audio connection. Press Listen to try again." : RECONNECT_MESSAGE;
}

export function extensionRuntime(): typeof chrome.runtime {
if (typeof chrome === "undefined" || !chrome.runtime?.id) {
throw new Error(RECONNECT_MESSAGE);
Expand Down
2 changes: 1 addition & 1 deletion extension/highlight-timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export function mapSentenceTimings(
): SentenceTiming[] {
const offsets = alignSpokenWords(spoken.text, segments.map((segment) => segment.text));
const cachedRanges = new Map<string, Range[]>();
return segments.flatMap((segment, index) => {
return segments.flatMap<SentenceTiming>((segment, index) => {
if (!Number.isFinite(segment.start) || !Number.isFinite(segment.end)) {
return [];
}
Expand Down
21 changes: 21 additions & 0 deletions extension/reading-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,27 @@ export function sourceRanges(source: ReadingSource, start: number, end: number):
return ranges;
}

// Resolve the actual DOM occurrence, including whitespace collapsed during extraction.
export function sourceOffsetAt(source: ReadingSource, node: Node, offset: number): number | null {
const span = source.spans.find((candidate) => candidate.node === node);
if (!span || !span.node.isConnected || span.node.data !== span.originalText) return null;
const index = span.offsets.indexOf(offset);
return index < 0 || span.start + index >= source.text.length ? null : span.start + index;
}

export function selectedWordOffset(source: ReadingSource, range: Range): number | null {
if (range.collapsed) return null;
const offset = sourceOffsetAt(source, range.startContainer, range.startOffset);
if (offset === null) return null;
const words = new Intl.Segmenter(undefined, { granularity: "word" });
for (const word of words.segment(source.text)) {
if (word.isWordLike && word.index <= offset && offset < word.index + word.segment.length) {
return word.index;
}
}
return null;
}

export function sliceReadingSource(source: ReadingSource, start: number, end = source.text.length): ReadingSource {
return {
text: source.text.slice(start, end),
Expand Down
11 changes: 11 additions & 0 deletions extension/session-connection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// An open port alone does not keep a Manifest V3 worker alive. Run the heartbeat
// in the worker so hidden article tabs cannot throttle it while playback is paused.
export function keepSessionConnected(
runtime: Pick<typeof chrome.runtime, "getPlatformInfo" | "lastError">,
timers: Pick<typeof globalThis, "setInterval" | "clearInterval"> = globalThis,
): () => void {
const timer = timers.setInterval(() => {
runtime.getPlatformInfo(() => { void runtime.lastError; });
}, 20_000);
return () => timers.clearInterval(timer);
}
17 changes: 17 additions & 0 deletions tests/PLAYBACK_VALIDATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,20 @@ The browser reported no console errors.

The fixture does not establish Chrome extension installation, live Fish service behavior, or subjective listening quality through the user's output device.
Load this worktree's `dist/` folder in Chrome, refresh the article tab, and compare the same voice at 1×, 1.2×, and 1.4× for a final listening check.

## Reconnection and word seeking

Validated on 2026-10-04 on branch `fix/reconnect-and-double-click-seek`.

Type checking, the production build, all 62 JavaScript unit tests, both Python bridge tests, and `git diff --check` passed.
The new source-location tests cover repeated paragraphs, navigation exclusions, inline formatting, collapsed whitespace, stale nodes, selection boundaries, and original offsets after starting mid-article.
The session test simulates ten minutes without content-script messages and verifies worker API activity before Chrome's 30-second idle cutoff and cleanup on disconnect.

`node tests/playback-browser.mjs` passed in Chromium with the built content script, real Web Audio, synthetic PCM, and a controlled extension port.
It verifies a 45-second frozen-tab pause, cached word seeking without a new speech request, an uncached word starting a request at that word, recovery preserving the current word and pause state, a bounded retry, selected-text listening, seek-listener cleanup, and cached playback after stream completion and disconnect.

The environment's administrator policy blocks loading unpacked extensions, so these browser checks simulate the port rather than testing an installed extension's worker.
Actual worker lifecycle behavior and live Fish Audio still need a check in the user's installed Chrome extension.

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.
Loading
Loading