diff --git a/README.md b/README.md
index c346754..cb88c26 100644
--- a/README.md
+++ b/README.md
@@ -36,9 +36,12 @@ OpenReader WebUI is a document reader with Text-to-Speech capabilities, offering
- Recent version of Docker installed on your machine
- A TTS API server (Kokoro-FastAPI, Orpheus-FastAPI, Deepinfra, OpenAI, etc.) running and accessible
+> **Note:** If you have good hardware, you can run [Kokoro-FastAPI with Docker locally](#🗣️-local-kokoro-fastapi-quick-start-cpu-or-gpu) (see below).
+
### 1. 🐳 Start the Docker container:
```bash
docker run --name openreader-webui \
+ --restart unless-stopped \
-p 3003:3003 \
-v openreader_docstore:/app/docstore \
ghcr.io/richardr1126/openreader-webui:latest
@@ -47,6 +50,7 @@ OpenReader WebUI is a document reader with Text-to-Speech capabilities, offering
(Optionally): Set the TTS `API_BASE` URL and/or `API_KEY` to be default for all devices
```bash
docker run --name openreader-webui \
+ --restart unless-stopped \
-e API_KEY=none \
-e API_BASE=http://host.docker.internal:8880/v1 \
-p 3003:3003 \
@@ -72,36 +76,48 @@ docker rm openreader-webui && \
docker pull ghcr.io/richardr1126/openreader-webui:latest
```
-### (Alternate) 🐳 Configuration with Docker Compose and Kokoro-FastAPI
+### 🗣️ Local Kokoro-FastAPI Quick-start (CPU or GPU)
-A complete example docker-compose file with Kokoro-FastAPI and OpenReader WebUI is available in [`docs/examples/docker-compose.yml`](docs/examples/docker-compose.yml). You can download and use it:
+You can run the Kokoro TTS API server directly with Docker. **We are not responsible for issues with Kokoro-FastAPI.** For best performance, use an NVIDIA GPU (for GPU version) or Apple Silicon (for CPU version).
+> **Note:** When using these, set the `API_BASE` env var to `http://host.docker.internal:8880/v1` or `http://kokoro-tts:8880/v1`.
+> You can also use the example `docker-compose.yml` in `examples/docker-compose.yml` if you prefer Docker Compose.
+
+**CPU Version:**
```bash
-# Download example docker-compose.yml
-curl --create-dirs -L -o openreader-compose/docker-compose.yml https://raw.githubusercontent.com/richardr1126/OpenReader-WebUI/main/docs/examples/docker-compose.yml
-
-cd openreader-compose
-docker compose up -d
+docker run -d \
+ --name kokoro-tts \
+ --restart unless-stopped \
+ -p 8880:8880 \
+ -e ONNX_NUM_THREADS=8 \
+ -e ONNX_INTER_OP_THREADS=4 \
+ -e ONNX_EXECUTION_MODE=parallel \
+ -e ONNX_OPTIMIZATION_LEVEL=all \
+ -e ONNX_MEMORY_PATTERN=true \
+ -e ONNX_ARENA_EXTEND_STRATEGY=kNextPowerOfTwo \
+ -e API_LOG_LEVEL=DEBUG \
+ ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.4
```
-Or add OpenReader WebUI to your existing `docker-compose.yml`:
-```yaml
-services:
- openreader-webui:
- container_name: openreader-webui
- image: ghcr.io/richardr1126/openreader-webui:latest
- environment:
- - API_BASE=http://host.docker.internal:8880/v1
- ports:
- - "3003:3003"
- volumes:
- - docstore:/app/docstore
- restart: unless-stopped
-
-volumes:
- docstore:
+**GPU Version:**
+```bash
+docker run -d \
+ --name kokoro-tts \
+ --gpus all \
+ --user 1001:1001 \
+ --restart unless-stopped \
+ -p 8880:8880 \
+ -e USE_GPU=true \
+ -e PYTHONUNBUFFERED=1 \
+ -e API_LOG_LEVEL=DEBUG \
+ ghcr.io/remsky/kokoro-fastapi-gpu:v0.2.4
```
+> **Note:**
+> - These commands are for running the Kokoro TTS API server only. For issues or support, see the [Kokoro-FastAPI repository](https://github.com/remsky/Kokoro-FastAPI).
+> - The GPU version requires NVIDIA Docker support and works best with NVIDIA GPUs. The CPU version works best on Apple Silicon or modern x86 CPUs.
+> - Adjust environment variables as needed for your hardware and use case.
+
## Dev Installation
### Prerequisites
diff --git a/docs/issues-to-components.md b/docs/issues-to-components.md
deleted file mode 100644
index 861deaa..0000000
--- a/docs/issues-to-components.md
+++ /dev/null
@@ -1,100 +0,0 @@
-# OpenReader Issue Triage and Mapping
-
-Repository: https://github.com/richardr1126/OpenReader-WebUI/issues
-Reviewed via gh at 2025-11-10.
-
-Summary of open items included:
-- #59 Feature: Chapter-Based MP3 Export
-- #48 Bug: Failed to export after complete render with Kokoro
-- #47 Feature: Combine voices (Kokoro “plus” syntax)
-- #44 Bug: Dialog not chunked together
-- #40 Bug: PDF left/right extraction margins not working
-
-Global guardrails
-- Streaming-first playback (replace Howler with HTMLAudioElement/MSE)
-- Dexie DB replacing vanilla IndexedDB
-- Single engine cut-over (no dual engines)
-- Keep audiobook (m4b) and server-side sync
-
-Issue #59 — Chapter-Based MP3 Export
-Type: Feature
-Hypothesis / intent:
-- Users want one-mp3-per-chapter output (besides full-book m4b).
-Ideas:
-- Add chapterized MP3 pipeline that chunks by adapter “chapter” units.
-- Provide ZIP export for many MP3 files (streamed).
-API design:
-- POST /api/audio/convert?mode=chapters&format=mp3 -> returns stream of a ZIP.
-Logging to add:
-- Chapter boundaries, byte sizes, cumulative progress in [src/app/api/audio/convert/route.ts](src/app/api/audio/convert/route.ts:1).
-Acceptance:
-- A multi-chapter EPUB produces N mp3 parts named “NN - Chapter Title.mp3”; total duration ~ sum of parts; ZIP streamed without timeouts in Docker.
-
-Issue #48 — Failed export after complete render with Kokoro
-Type: Bug (large-book export)
-Observations:
-- UI reaches 100% then resets, no download.
-- Docker shows “fin … undefined”, likely final transfer problem (not TTS).
-Likely root causes:
-- Final m4b delivery uses single huge arrayBuffer; browser memory/timeout.
-- Missing Content-Disposition/Range; no resumable download.
-- Temp-file lifecycle cleanup racing with response.
-Ideas for remediation:
-- Serve final artifact as file on disk with streaming and Range:
- - New endpoint: GET /api/audio/convert/download?bookId=… that streams file with Accept-Ranges.
- - UI performs streamed download; no arrayBuffer buffering.
-- Optionally support S3-compatible offload in future.
-Touched modules:
-- [src/app/api/audio/convert/route.ts](src/app/api/audio/convert/route.ts:1)
-Instrumentation to add:
-- Book ID, file size on disk, stream chunk counts, and client-abort detection.
-- Add explicit log/error surfaces at:
- - [src/app/api/audio/convert/route.ts](src/app/api/audio/convert/route.ts:1)
-Acceptance:
-- 1–2 GB m4b exports download in Docker without UI reset; bookId persists until deletion; status 206 Range works.
-
-Issue #47 — Kokoro combined voices (“+” syntax)
-Type: Feature
-Intent:
-- Support voice strings like “bf_emma+af_heart” when provider is Kokoro/FastAPI or DeepInfra Kokoro.
-Ideas for implementation:
-- Allow free-form voice string entry and pass-through if not in known set.
-- Validate known providers; if “+” present, skip voice validation list.
-
-Logging:
-- Emit provider, model, raw voice string in request (no PII).
-Tests:
-- “a+b” voices produce audible combined output; works via DeepInfra pass-through and Kokoro-FastAPI; voice dropdown offers “Custom voice…” input.
-
-Issue #44 — Dialog not being chunked together
-Type: Bug (NLP splitting)
-Observations:
-- Dialog lines split mid-quote; needs grouping.
-Proposed improvements:
-- Extend [splitIntoSentences()](src/utils/nlp.ts:34) to apply quote-aware grouping.
-- When a sentence begins with an opening quote and the next ends with a closing quote, join them before MAX_BLOCK_LENGTH checks.
-Ideas for remediation:
-- Provide composable splitter strategy wrapping existing utility.
-- Add “dialog-preserve” flag toggled in settings.
-Logging to add:
-- Count of quote-joined sentences; per-page example of before/after lengths.
-Acceptance:
-- Quoted dialog flows as single units unless exceeding MAX_BLOCK_LENGTH by > X%; regression-safe for non-dialog text.
-
-Issue #40 — PDF left/right extraction margins not working
-Type: Bug (PDF extraction)
-Observations:
-- Current filter uses transform[4]/[5] with width heuristics. Edge cases: missing width, skew, page scale differences.
-Likely causes:
-- Some pdf.js TextItem miss width; horizontal margins computed but not applied due to scale mismatch.
-- Defaults in [src/contexts/ConfigContext.tsx](src/contexts/ConfigContext.tsx:216) set left/right to '0.0' on first run; UX confusion.
-Remediation:
-- Compute glyph bbox width when width absent using transform matrix.
-- Normalize x to [0..1] by dividing by pageWidth; compare to margins reliably.
-- Add visual debug overlay (dev mode) to draw margin boxes while extracting.
-Code touchpoints:
-- [src/utils/pdf.ts](src/utils/pdf.ts:60) extractTextFromPDF(): margin math and width fallback.
-Diagnostics to add:
-- Per-page: kept/filtered counts, extremes of x positions, computed margins.
-Acceptance:
-- Test PDFs show left/right trimming correctly; e2e highlight still robust.
\ No newline at end of file
diff --git a/docs/examples/docker-compose.yml b/examples/docker-compose.yml
similarity index 100%
rename from docs/examples/docker-compose.yml
rename to examples/docker-compose.yml
diff --git a/playwright.config.ts b/playwright.config.ts
index e654a15..35258ca 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -1,13 +1,5 @@
import { defineConfig, devices } from '@playwright/test';
-/**
- * Read environment variables from file.
- * https://github.com/motdotla/dotenv
- */
-// import dotenv from 'dotenv';
-// import path from 'path';
-// dotenv.config({ path: path.resolve(__dirname, '.env') });
-
/**
* See https://playwright.dev/docs/test-configuration.
*/
@@ -15,14 +7,11 @@ export default defineConfig({
testDir: './tests',
timeout: 30 * 1000,
outputDir: './tests/results',
- /* Run tests in files in parallel */
- fullyParallel: true,
+ fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
- /* Retry on CI only */
retries: process.env.CI ? 2 : 0,
- /* Opt out of parallel tests on CI. */
- workers: undefined,
+ workers: process.env.CI ? '100%' : '75%',
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
diff --git a/src/app/api/tts/route.ts b/src/app/api/tts/route.ts
index d22c817..a6a2ec1 100644
--- a/src/app/api/tts/route.ts
+++ b/src/app/api/tts/route.ts
@@ -217,7 +217,7 @@ export async function POST(req: NextRequest) {
console.log('TTS in-flight JOIN for key:', cacheKey.slice(0, 8));
existing.consumers += 1;
- const onAbort = (_evt: Event) => {
+ const onAbort = () => {
existing.consumers = Math.max(0, existing.consumers - 1);
if (existing.consumers === 0) {
existing.controller.abort();
@@ -262,7 +262,7 @@ export async function POST(req: NextRequest) {
inflightRequests.set(cacheKey, entry);
- const onAbort = (_evt: Event) => {
+ const onAbort = () => {
entry.consumers = Math.max(0, entry.consumers - 1);
if (entry.consumers === 0) {
entry.controller.abort();
diff --git a/src/components/DocumentSettings.tsx b/src/components/DocumentSettings.tsx
index 41dc385..72ac593 100644
--- a/src/components/DocumentSettings.tsx
+++ b/src/components/DocumentSettings.tsx
@@ -27,6 +27,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
viewType,
skipBlank,
epubTheme,
+ smartSentenceSplitting,
headerMargin,
footerMargin,
leftMargin,
@@ -308,6 +309,24 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
Automatically skip pages with no text content
}
+ {!html && (
+
+
+
+ Merge sentences across page or section breaks for smoother TTS.
+
+
+ )}
{epub && (
);
-}
\ No newline at end of file
+}
diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx
index 387703a..f99441d 100644
--- a/src/contexts/ConfigContext.tsx
+++ b/src/contexts/ConfigContext.tsx
@@ -29,6 +29,7 @@ type ConfigValues = {
ttsModel: string;
ttsInstructions: string;
savedVoices: SavedVoices;
+ smartSentenceSplitting: boolean;
};
/** Interface defining the configuration context shape and functionality */
@@ -41,6 +42,7 @@ interface ConfigContextType {
voice: string;
skipBlank: boolean;
epubTheme: boolean;
+ smartSentenceSplitting: boolean;
headerMargin: number;
footerMargin: number;
leftMargin: number;
@@ -73,6 +75,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
const [voice, setVoice] = useState('af_sarah');
const [skipBlank, setSkipBlank] = useState(true);
const [epubTheme, setEpubTheme] = useState(false);
+ const [smartSentenceSplitting, setSmartSentenceSplitting] = useState(true);
const [headerMargin, setHeaderMargin] = useState(0.07);
const [footerMargin, setFooterMargin] = useState(0.07);
const [leftMargin, setLeftMargin] = useState(0.07);
@@ -103,6 +106,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
const cachedAudioPlayerSpeed = await getItem('audioPlayerSpeed');
const cachedSkipBlank = await getItem('skipBlank');
const cachedEpubTheme = await getItem('epubTheme');
+ const cachedSmartSentenceSplitting = await getItem('smartSentenceSplitting');
const cachedHeaderMargin = await getItem('headerMargin');
const cachedFooterMargin = await getItem('footerMargin');
const cachedLeftMargin = await getItem('leftMargin');
@@ -187,6 +191,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
setAudioPlayerSpeed(parseFloat(cachedAudioPlayerSpeed || '1'));
setSkipBlank(cachedSkipBlank === 'false' ? false : true);
setEpubTheme(cachedEpubTheme === 'true');
+ setSmartSentenceSplitting(cachedSmartSentenceSplitting === 'false' ? false : true);
setHeaderMargin(parseFloat(cachedHeaderMargin || '0.07'));
setFooterMargin(parseFloat(cachedFooterMargin || '0.07'));
setLeftMargin(parseFloat(cachedLeftMargin || '0.07'));
@@ -211,6 +216,9 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
if (cachedEpubTheme === null) {
await setItem('epubTheme', 'false');
}
+ if (cachedSmartSentenceSplitting === null) {
+ await setItem('smartSentenceSplitting', 'true');
+ }
if (cachedHeaderMargin === null) await setItem('headerMargin', '0.07');
if (cachedFooterMargin === null) await setItem('footerMargin', '0.07');
if (cachedLeftMargin === null) await setItem('leftMargin', '0.0');
@@ -353,6 +361,9 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
case 'epubTheme':
setEpubTheme(value as boolean);
break;
+ case 'smartSentenceSplitting':
+ setSmartSentenceSplitting(value as boolean);
+ break;
case 'headerMargin':
setHeaderMargin(value as number);
break;
@@ -388,6 +399,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
voice,
skipBlank,
epubTheme,
+ smartSentenceSplitting,
headerMargin,
footerMargin,
leftMargin,
@@ -417,4 +429,4 @@ export function useConfig() {
throw new Error('useConfig must be used within a ConfigProvider');
}
return context;
-}
\ No newline at end of file
+}
diff --git a/src/contexts/EPUBContext.tsx b/src/contexts/EPUBContext.tsx
index dfac6d3..ebdd451 100644
--- a/src/contexts/EPUBContext.tsx
+++ b/src/contexts/EPUBContext.tsx
@@ -8,7 +8,7 @@ import {
useCallback,
useMemo,
useRef,
- RefObject,
+ RefObject
} from 'react';
import { indexedDBService } from '@/utils/indexedDB';
import { useTTS } from '@/contexts/TTSContext';
@@ -43,6 +43,81 @@ interface EPUBContextType {
const EPUBContext = createContext(undefined);
+const EPUB_CONTINUATION_CHARS = 600;
+
+const stepToNextNode = (node: Node | null, root: Node): Node | null => {
+ if (!node) return null;
+ if (node.firstChild) {
+ return node.firstChild;
+ }
+
+ let current: Node | null = node;
+ while (current) {
+ if (current === root) {
+ return null;
+ }
+ if (current.nextSibling) {
+ return current.nextSibling;
+ }
+ current = current.parentNode;
+ }
+
+ return null;
+};
+
+const getNextTextNode = (node: Node | null, root: Node): Text | null => {
+ let next = stepToNextNode(node, root);
+ while (next) {
+ if (next.nodeType === Node.TEXT_NODE) {
+ return next as Text;
+ }
+ next = stepToNextNode(next, root);
+ }
+ return null;
+};
+
+const collectContinuationFromRange = (range: Range | null | undefined, limit = EPUB_CONTINUATION_CHARS): string => {
+ if (typeof window === 'undefined' || !range) {
+ return '';
+ }
+
+ const root = range.commonAncestorContainer;
+ if (!root) {
+ return '';
+ }
+
+ const parts: string[] = [];
+ let remaining = limit;
+
+ const appendFromTextNode = (textNode: Text, offset: number) => {
+ if (remaining <= 0) return;
+ const textContent = textNode.textContent || '';
+ if (offset >= textContent.length) return;
+ const slice = textContent.slice(offset, offset + remaining);
+ if (slice) {
+ parts.push(slice);
+ remaining -= slice.length;
+ }
+ };
+
+ if (range.endContainer.nodeType === Node.TEXT_NODE) {
+ appendFromTextNode(range.endContainer as Text, range.endOffset);
+ let nextNode = getNextTextNode(range.endContainer, root);
+ while (nextNode && remaining > 0) {
+ appendFromTextNode(nextNode, 0);
+ nextNode = getNextTextNode(nextNode, root);
+ }
+ } else {
+ let nextNode = getNextTextNode(range.endContainer, root);
+ while (nextNode && remaining > 0) {
+ appendFromTextNode(nextNode, 0);
+ nextNode = getNextTextNode(nextNode, root);
+ }
+ }
+
+ return parts.join(' ').replace(/\s+/g, ' ').trim();
+};
+
/**
* Provider component for EPUB functionality
* Manages the state and operations for EPUB document handling
@@ -61,6 +136,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
ttsProvider,
ttsModel,
ttsInstructions,
+ smartSentenceSplitting,
} = useConfig();
// Current document state
const [currDocData, setCurrDocData] = useState();
@@ -141,8 +217,23 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
return '';
}
const textContent = range.toString().trim();
+ const continuationPreview = collectContinuationFromRange(range);
- setTTSText(textContent, shouldPause);
+ if (smartSentenceSplitting) {
+ setTTSText(textContent, {
+ shouldPause,
+ location: start.cfi,
+ nextLocation: end.cfi,
+ nextText: continuationPreview
+ });
+ } else {
+ // When smart splitting is disabled, behave like the original implementation:
+ // send only the current page/location text without any continuation preview.
+ setTTSText(textContent, {
+ shouldPause,
+ location: start.cfi,
+ });
+ }
setCurrDocText(textContent);
return textContent;
@@ -150,7 +241,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
console.error('Error extracting EPUB text:', error);
return '';
}
- }, [setTTSText]);
+ }, [setTTSText, smartSentenceSplitting]);
/**
* Extracts text content from the entire EPUB book
@@ -657,4 +748,4 @@ export function useEPUB() {
throw new Error('useEPUB must be used within an EPUBProvider');
}
return context;
-}
\ No newline at end of file
+}
diff --git a/src/contexts/PDFContext.tsx b/src/contexts/PDFContext.tsx
index 26879c3..4128816 100644
--- a/src/contexts/PDFContext.tsx
+++ b/src/contexts/PDFContext.tsx
@@ -22,6 +22,7 @@ import {
useCallback,
useMemo,
RefObject,
+ useRef,
} from 'react';
import { indexedDBService } from '@/utils/indexedDB';
@@ -33,6 +34,7 @@ import {
clearHighlights,
handleTextClick,
} from '@/utils/pdf';
+import { processTextToSentences } from '@/utils/nlp';
import type { PDFDocumentProxy } from 'pdfjs-dist';
import { withRetry } from '@/utils/audio';
@@ -70,6 +72,8 @@ interface PDFContextType {
// Create the context
const PDFContext = createContext(undefined);
+const CONTINUATION_PREVIEW_CHARS = 600;
+
/**
* PDFProvider Component
*
@@ -83,10 +87,11 @@ export function PDFProvider({ children }: { children: ReactNode }) {
const {
setText: setTTSText,
stop,
- currDocPageNumber: currDocPage,
+ currDocPageNumber,
currDocPages,
setCurrDocPages,
- setIsEPUB
+ setIsEPUB,
+ registerVisualPageChangeHandler,
} = useTTS();
const {
headerMargin,
@@ -100,6 +105,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
ttsProvider,
ttsModel,
ttsInstructions,
+ smartSentenceSplitting,
} = useConfig();
// Current document state
@@ -108,6 +114,12 @@ export function PDFProvider({ children }: { children: ReactNode }) {
const [currDocText, setCurrDocText] = useState();
const [pdfDocument, setPdfDocument] = useState();
const [isAudioCombining] = useState(false);
+ const pageTextCacheRef = useRef